PlotDirector/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs

1013 lines
46 KiB
C#

using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceRelationshipImportService
{
Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form);
}
public sealed class StoryIntelligenceRelationshipImportService(
IStoryIntelligenceResultRepository runs,
ICharacterRepository characters,
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceRelationshipImportService> logger) : IStoryIntelligenceRelationshipImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch);
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var typeOptions = ToRelationshipTypeOptions(lookupData.RelationshipTypes);
var selectedTypeIds = batch.RelationshipSelections.ToDictionary(selection => selection.Key, selection => selection.RelationshipTypeID, StringComparer.OrdinalIgnoreCase);
var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceRelationshipReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
RelationshipTypeOptions = typeOptions,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
{
Key = candidate.Key,
CharacterAName = candidate.CharacterAName,
CharacterBName = candidate.CharacterBName,
RelationshipType = candidate.RelationshipType,
RelationshipTypeID = ResolveRelationshipTypeID(lookupData.RelationshipTypes, selectedTypeIds.GetValueOrDefault(candidate.Key), candidate.RelationshipType),
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
FirstAppearance = candidate.FirstAppearanceNote,
LastAppearance = candidate.LastAppearanceNote,
ExampleScene = candidate.ExampleScene,
ExampleContext = candidate.ExampleContext,
ExistingRelationshipID = candidate.ExistingRelationshipID,
ExistingRelationshipLabel = candidate.ExistingRelationshipLabel
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing relationships." };
}
var choices = form.Relationships
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
SaveRelationshipSelections(batch, choices.Values);
if (choices.Count == 0)
{
if (data.Candidates.Count == 0)
{
batch.RelationshipStageComplete = true;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult();
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = "Relationship review completed. No relationship decisions were waiting."
};
}
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one relationship to create, link or ignore." };
}
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var relationshipTypesById = lookupData.RelationshipTypes.ToDictionary(type => type.RelationshipTypeID);
var created = 0;
var linkedExisting = 0;
var merged = 0;
var ignored = 0;
var eventsCreated = 0;
var resolvedRelationships = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidatesByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice))
{
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Ignore, null, RelationshipLabel(candidate), false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relationshipId = string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
? candidate.ExistingRelationshipID
: null;
if (!relationshipId.HasValue)
{
if (!choice.RelationshipTypeID.HasValue || !relationshipTypesById.ContainsKey(choice.RelationshipTypeID.Value))
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid relationship type for each relationship you want to create." };
}
relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
{
ProjectID = batch.ProjectID,
CharacterAID = candidate.CharacterAID,
CharacterBID = candidate.CharacterBID,
RelationshipTypeID = choice.RelationshipTypeID.Value,
IsPermanent = false,
StartSceneID = candidate.Appearances.OrderBy(StoryOrder).FirstOrDefault()?.SceneID,
EndSceneID = null,
IsKnownToReader = true,
Notes = BuildRelationshipNotes(candidate),
IsInitialRelationship = false,
InitialBookID = batch.BookID,
ReaderInitiallyKnows = true,
InitialRelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipType),
InitialIntensity = ConfidenceIntensity(candidate.Confidence),
IsReciprocal = true
});
created++;
}
else
{
linkedExisting++;
}
resolvedRelationships[candidate.Key] = relationshipId.Value;
AddDecision(batch, candidate.Key, choice.Action, relationshipId.Value, RelationshipLabel(candidate), true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var targetKey = Clean(choice.AliasTargetKey);
if (string.IsNullOrWhiteSpace(targetKey)
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|| !choices.TryGetValue(targetKey, out var targetChoice)
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidatesByKey.ContainsKey(targetKey)
|| !resolvedRelationships.TryGetValue(targetKey, out var targetRelationshipId))
{
logger.LogWarning(
"Story Intelligence relationship merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
resolvedRelationships[candidate.Key] = targetRelationshipId;
merged++;
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Alias, targetRelationshipId, RelationshipLabel(candidate), true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| !resolvedRelationships.TryGetValue(candidate.Key, out var relationshipId))
{
continue;
}
foreach (var appearance in candidate.Appearances
.Where(appearance => !appearance.AlreadyLinked)
.GroupBy(appearance => appearance.SceneID)
.Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First())
.OrderBy(StoryOrder))
{
if (!HasMeaningfulRelationshipEvent(appearance))
{
continue;
}
await characters.SaveRelationshipEventAsync(new RelationshipEvent
{
CharacterRelationshipID = relationshipId,
SceneID = appearance.SceneID,
RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, appearance.Signal ?? candidate.RelationshipType),
Intensity = ConfidenceIntensity(appearance.Confidence ?? candidate.Confidence),
IsKnownToOtherCharacter = true,
Description = BuildRelationshipEventDescription(candidate, appearance)
});
eventsCreated++;
}
}
batch.RelationshipStageComplete = true;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult
{
RelationshipsCreated = created,
RelationshipsLinked = linkedExisting,
RelationshipsMerged = merged,
RelationshipsIgnored = ignored,
RelationshipEventsCreated = eventsCreated
};
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}",
batch.BatchID,
created,
linkedExisting,
merged,
ignored,
eventsCreated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = eventsCreated,
Message = $"Relationships imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored, {eventsCreated:N0} relationship event(s) added."
};
}
private async Task<RelationshipCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var existingRelationships = await characters.ListRelationshipsByProjectAsync(batch.ProjectID);
var existingByPair = existingRelationships
.GroupBy(relationship => PairKey(relationship.CharacterAID, relationship.CharacterBID))
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
var groups = new Dictionary<string, RelationshipCandidate>(StringComparer.OrdinalIgnoreCase);
var alreadyLinked = 0;
var hasCommittedScenes = false;
foreach (var item in batch.Items)
{
var run = await runs.GetRunAsync(item.RunID);
if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{
continue;
}
hasCommittedScenes = true;
var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
foreach (var sceneResult in sceneResults)
{
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
}
var existingEvents = await characters.ListRelationshipEventsBySceneAsync(importedScene.SceneID);
alreadyLinked += existingEvents.Select(item => item.CharacterRelationshipID).Distinct().Count();
AddSceneRelationships(groups, characterIndex, existingByPair, importedScene, parsed, existingEvents);
}
}
var candidates = groups.Values
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.OrderBy(CandidateFirstStoryOrder)
.ThenBy(candidate => candidate.CharacterAName)
.ThenBy(candidate => candidate.CharacterBName)
.ToList();
return new RelationshipCandidateData(hasCommittedScenes, alreadyLinked, candidates);
}
private static void AddSceneRelationships(
Dictionary<string, RelationshipCandidate> groups,
CharacterIndex characterIndex,
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<RelationshipEvent> existingEvents)
{
foreach (var relationship in parsed.Relationships ?? [])
{
AddRelationship(groups, characterIndex, existingByPair, importedScene, parsed, relationship.CharacterA, relationship.CharacterB, relationship.RelationshipSignal, relationship.Evidence, relationship.Confidence, existingEvents);
}
foreach (var observation in parsed.Observations ?? [])
{
if (!IsRelationshipObservation(observation))
{
continue;
}
AddRelationship(
groups,
characterIndex,
existingByPair,
importedScene,
parsed,
observation.SubjectName,
observation.ObjectName,
FirstConfigured(observation.Predicate, observation.ObservationType),
FirstConfigured(observation.Description, observation.Evidence),
observation.Confidence,
existingEvents);
}
}
private static void AddRelationship(
Dictionary<string, RelationshipCandidate> groups,
CharacterIndex characterIndex,
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
Scene importedScene,
SceneIntelligenceScene parsed,
string? characterAName,
string? characterBName,
string? relationshipSignal,
string? evidence,
decimal? confidence,
IReadOnlyList<RelationshipEvent> existingEvents)
{
var characterA = characterIndex.Find(characterAName, importedScene);
var characterB = characterIndex.Find(characterBName, importedScene);
if (characterA is null || characterB is null || characterA.CharacterID == characterB.CharacterID)
{
return;
}
var key = PairKey(characterA.CharacterID, characterB.CharacterID);
var classification = RelationshipTypeFromEvidence(
relationshipSignal,
evidence,
importedScene.StructuralSummary,
characterA.CharacterName,
characterB.CharacterName);
if (!groups.TryGetValue(key, out var candidate))
{
existingByPair.TryGetValue(key, out var existingMatch);
candidate = new RelationshipCandidate(
key,
characterA.CharacterID,
characterA.CharacterName,
characterB.CharacterID,
characterB.CharacterName,
classification.Type,
existingMatch?.CharacterRelationshipID,
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
candidate.RelationshipTypeClassification = classification;
groups[key] = candidate;
}
if (classification.CompareTo(candidate.RelationshipTypeClassification) > 0
|| classification.CompareTo(candidate.RelationshipTypeClassification) == 0 && (confidence ?? 0m) >= (candidate.Confidence ?? 0m))
{
candidate.RelationshipType = classification.Type;
candidate.RelationshipTypeClassification = classification;
}
candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.FirstAppearanceNote ??= BuildSceneReference(importedScene);
candidate.LastAppearanceNote = BuildSceneReference(importedScene);
candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed);
candidate.ExampleContext ??= Clean(evidence);
candidate.Appearances.Add(new RelationshipAppearanceImport(
importedScene.SceneID,
importedScene.ChapterNumber,
importedScene.SceneNumber,
importedScene.SortOrder,
importedScene.SceneTitle,
relationshipSignal,
evidence,
confidence,
candidate.ExistingRelationshipID.HasValue && existingEvents.Any(existing => existing.CharacterRelationshipID == candidate.ExistingRelationshipID.Value)));
}
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
{
var index = new CharacterIndex();
foreach (var character in await characters.ListCharactersAsync(projectId))
{
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
{
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
}
}
return index;
}
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
{
return rangeMatch;
}
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null;
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
{
if (string.IsNullOrWhiteSpace(result.ParsedJson))
{
return null;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
{
return direct;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
}
return null;
}
private static bool IsRelationshipObservation(SceneIntelligenceObservation observation)
{
var type = Clean(observation.ObservationType);
if (!type.Contains("relationship", StringComparison.OrdinalIgnoreCase)
&& !type.Contains("character", StringComparison.OrdinalIgnoreCase)
&& !RelationshipSignalWords.Any(word => type.Contains(word, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
return IsCharacterEntity(observation.SubjectEntityType)
&& IsCharacterEntity(observation.ObjectEntityType)
&& !string.IsNullOrWhiteSpace(observation.SubjectName)
&& !string.IsNullOrWhiteSpace(observation.ObjectName);
}
private static int ResolveRelationshipTypeID(IReadOnlyList<RelationshipType> types, int? selectedRelationshipTypeId, string? suggestedType)
=> selectedRelationshipTypeId.HasValue && types.Any(type => type.RelationshipTypeID == selectedRelationshipTypeId.Value)
? selectedRelationshipTypeId.Value
: types.FirstOrDefault(type => string.Equals(type.TypeName, suggestedType, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.First().RelationshipTypeID;
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
{
var stateName = RelationshipStateNameFromSignal(signal);
return states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.First().RelationshipStateID;
}
private static string RelationshipStateNameFromSignal(string? signal)
{
var value = Normalise(signal);
return
value.Contains("reconcil") ? "Reconciled" :
value.Contains("engaged") || value.Contains("proposal") || value.Contains("proposes") ? "Engaged" :
value.Contains("romantic") || value.Contains("attraction") || value.Contains("lover") || value.Contains("love") || value.Contains("couple") ? "Romantic" :
value.Contains("friend") || value.Contains("ally") || value.Contains("support") || value.Contains("trust") || value.Contains("hug") || value.Contains("gift") ? "Friendly" :
value.Contains("argument") || value.Contains("argue") || value.Contains("conflict") || value.Contains("hostile") || value.Contains("enemy") ? "Hostile" :
value.Contains("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" :
value.Contains("estranged") || value.Contains("separat") || value.Contains("deteriorat") ? "Estranged" :
value.Contains("tense") || value.Contains("tension") ? "Tense" :
"Unknown";
}
private static string RelationshipTypeFromSignal(string? signal, string? evidence = null)
=> RelationshipTypeFromEvidence(signal, evidence, null, null, null).Type;
private static RelationshipTypeClassification RelationshipTypeFromEvidence(
string? signal,
string? evidence,
string? sceneContext,
string? characterAName,
string? characterBName)
{
var relationshipText = NormaliseForMatching($"{evidence} {characterAName} {characterBName}");
var explicitText = NormaliseForMatching($"{evidence} {sceneContext} {characterAName} {characterBName}");
var allText = NormaliseForMatching($"{signal} {evidence} {sceneContext} {characterAName} {characterBName}");
if (ContainsAny(relationshipText, "best friend", "close friend", "friend since", "childhood friend"))
{
return new("Friend", 500);
}
if (HasDirectSiblingEvidence(evidence, characterAName, characterBName))
{
return new("Sibling", 490);
}
if (HasDirectParentEvidence(evidence, characterAName, characterBName))
{
return new("Parent", 480);
}
if (ContainsAny(explicitText, "neighbour", "neighbor", "next door"))
{
return new("Neighbour", 470);
}
if (ContainsAny(explicitText, "coworker", "co worker", "co-worker", "colleague", "workmate", "coworkers", "co workers", "co-workers", "colleagues"))
{
return new("Professional", 460);
}
if (ContainsAny(explicitText, "examiner", "candidate", "instructor", "learner", "teacher", "student", "mentor", "pupil", "tutor"))
{
return new("Educational", 455);
}
if (ContainsAny(relationshipText, "aunt", "uncle", "cousin", "family", "like a sister", "like a brother"))
{
return new("Family", 420);
}
if (ContainsAny(allText,
"rival", "enemy", "adversarial", "conflict", "mistrust", "distrust", "antagonist", "competitor",
"confront", "attack", "attacks", "attacked", "assault", "assaults", "assaulted", "slap", "slaps",
"slapped", "threat", "threaten", "threatens", "threatened", "violence", "never speak", "disgust",
"betray", "betrays", "betrayed", "spiked", "drugged", "grope", "gropes", "groped", "force",
"forces", "forced", "insult", "insults", "anger", "hostile"))
{
return new("Rival", 370);
}
if (ContainsAny(allText, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like", "kiss", "proposal", "engaged", "couple"))
{
return new("Romantic", 360);
}
if (ContainsAny(allText, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor", "examiner", "candidate"))
{
return new("Educational", 350);
}
if (ContainsAny(explicitText, "police", "detective", "suspect", "witness", "guard", "prisoner", "investigator", "person of interest", "social worker", "social services"))
{
return new("Authority", 340);
}
if (ContainsAny(allText, "criminal", "accomplice", "offender", "victim", "gang", "conspirator"))
{
return new("Criminal", 330);
}
if (ContainsAny(relationshipText, "doctor patient", "patient doctor", "nurse patient", "patient nurse", "therapist patient", "patient therapist", "treats", "treated by", "medical care"))
{
return new("Medical", 320);
}
if (ContainsAny(allText, "neighbour", "neighbor"))
{
return new("Neighbour", 310);
}
if (ContainsAny(explicitText, "colleague", "coworker", "co worker", "co-worker", "boss", "employee", "manager", "client", "work relationship"))
{
return new("Professional", 300);
}
if (ContainsAny(NormaliseForMatching(signal), "friend", "friendship"))
{
return new("Friend", 290);
}
if (ContainsAny(allText, "friendship", "trust between friends", "affection between friends", "familiarity", "comfortable together", "socialise", "socialize", "spend time together", "spends time together"))
{
return new("Friend", 270);
}
if (ContainsAny(explicitText, "ally", "protector", "supporter", "confidant", "protects", "supports"))
{
return new("Ally", 250);
}
return new("Unknown", 0);
}
private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance)
{
var value = $"{appearance.Signal} {appearance.Evidence}";
return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
}
private static bool HasDirectSiblingEvidence(string? evidence, string? characterAName, string? characterBName)
{
var clean = Clean(evidence);
if (string.IsNullOrWhiteSpace(clean)
|| FigurativeSibling.IsMatch(clean)
|| ReportedThirdPartyKinship.IsMatch(clean))
{
return false;
}
var normalised = NormaliseForMatching(clean);
return ContainsAny(normalised, "my brother", "my sister", "her brother", "her sister", "his brother", "his sister", "their brother", "their sister")
|| ContainsAny(normalised, "two siblings", "both siblings", "the siblings")
|| HasOwnedKinshipBetweenParticipants("brother", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("sister", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("sibling", characterAName, characterBName);
}
private static bool HasDirectParentEvidence(string? evidence, string? characterAName, string? characterBName)
{
var normalised = NormaliseForMatching(evidence);
return ContainsAny(normalised, "my mother", "my mum", "my mom", "my father", "my dad", "me and mum", "me and mother", "me and dad", "me and father")
|| HasNamedParentEvidence(normalised, characterAName, characterBName)
|| HasNamedParentEvidence(normalised, characterBName, characterAName)
|| HasOwnedKinshipBetweenParticipants("mother", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("father", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("mum", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("mom", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("dad", characterAName, characterBName);
}
private static bool HasNamedParentEvidence(string normalisedEvidence, string? possibleParentName, string? possibleChildName)
{
var parent = Clean(possibleParentName);
var child = Clean(possibleChildName);
if (string.IsNullOrWhiteSpace(parent) || string.IsNullOrWhiteSpace(child))
{
return false;
}
return ContainsAny(
normalisedEvidence,
$"{child}'s mother {parent}",
$"{child}'s mum {parent}",
$"{child}'s mom {parent}",
$"{child}'s father {parent}",
$"{child}'s dad {parent}");
}
private static bool HasOwnedKinshipBetweenParticipants(string kinship, string? characterAName, string? characterBName)
=> IsOwnedKinshipFor(characterAName, characterBName, kinship)
|| IsOwnedKinshipFor(characterBName, characterAName, kinship);
private static bool IsOwnedKinshipFor(string? possibleKinshipCharacter, string? possibleOwnerCharacter, string kinship)
{
var owner = Clean(possibleOwnerCharacter);
if (string.IsNullOrWhiteSpace(owner))
{
return false;
}
var identityName = KinshipIdentityName(kinship);
var expected = NormaliseForMatching($"{owner}'s {identityName}");
var actual = NormaliseForMatching(possibleKinshipCharacter);
if (string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
{
return true;
}
string[] equivalentKinshipNames = identityName switch
{
"mother" => ["mother", "mum", "mom"],
"father" => ["father", "dad"],
var value => [value]
};
var qualified = equivalentKinshipNames.Select(name => NormaliseForMatching($"{owner}'s {name}"))
.Concat(new[] { "adoptive", "biological", "step", "foster" }
.SelectMany(qualifier => equivalentKinshipNames.Select(name => NormaliseForMatching($"{owner}'s {qualifier} {name}"))));
return qualified.Any(value => string.Equals(actual, value, StringComparison.OrdinalIgnoreCase));
}
private static (decimal ChapterNumber, decimal SceneNumber, int SortOrder, int SceneID) StoryOrder(RelationshipAppearanceImport appearance)
=> (appearance.ChapterNumber, appearance.SceneNumber, appearance.SortOrder, appearance.SceneID);
private static (decimal ChapterNumber, decimal SceneNumber, int SortOrder, int SceneID) CandidateFirstStoryOrder(RelationshipCandidate candidate)
=> candidate.Appearances
.Select(StoryOrder)
.OrderBy(order => order.ChapterNumber)
.ThenBy(order => order.SceneNumber)
.ThenBy(order => order.SortOrder)
.ThenBy(order => order.SceneID)
.FirstOrDefault((decimal.MaxValue, decimal.MaxValue, int.MaxValue, int.MaxValue));
private static string RelationshipStateAtScene(
string initialState,
IEnumerable<(int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder, string StateName)> events,
int sceneId)
{
var ordered = events.OrderBy(item => item.ChapterNumber).ThenBy(item => item.SceneNumber).ThenBy(item => item.SortOrder).ThenBy(item => item.SceneID).ToList();
var scene = ordered.FirstOrDefault(item => item.SceneID == sceneId);
if (scene.SceneID == 0)
{
return initialState;
}
return ordered
.Where(item => (item.ChapterNumber, item.SceneNumber, item.SortOrder, item.SceneID)
.CompareTo((scene.ChapterNumber, scene.SceneNumber, scene.SortOrder, scene.SceneID)) <= 0)
.LastOrDefault().StateName ?? initialState;
}
private static string BuildRelationshipNotes(RelationshipCandidate candidate)
{
var lines = new List<string> { "Imported from Story Intelligence relationship review." };
if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
lines.Add(candidate.ExampleContext);
}
if (!string.IsNullOrWhiteSpace(candidate.FirstAppearanceNote))
{
lines.Add($"First detected: {candidate.FirstAppearanceNote}");
}
return string.Join(Environment.NewLine, lines);
}
private static string BuildRelationshipEventDescription(RelationshipCandidate candidate, RelationshipAppearanceImport appearance)
=> FirstConfigured(
Clean(appearance.Evidence),
Clean(appearance.Signal),
$"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}") ?? RelationshipLabel(candidate);
private static string RelationshipLabel(RelationshipCandidate candidate)
=> $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}";
private static IReadOnlyList<SelectListItem> ToRelationshipTypeOptions(IEnumerable<RelationshipType> relationshipTypes)
=> relationshipTypes
.OrderBy(type => type.CategorySortOrder)
.ThenBy(type => type.SortOrder)
.ThenBy(type => type.TypeName)
.Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString()))
.ToList();
private static void SaveRelationshipSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices)
{
foreach (var choice in choices)
{
if (string.IsNullOrWhiteSpace(choice.Key) || choice.RelationshipTypeID is not { } relationshipTypeId)
{
continue;
}
batch.RelationshipSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase));
batch.RelationshipSelections.Add(new OnboardingStoryIntelligenceRelationshipSelection
{
Key = choice.Key,
RelationshipTypeID = relationshipTypeId
});
}
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary)
? BuildSceneReference(scene)
: $"{BuildSceneReference(scene)}: {summary}";
}
private static string BuildSceneReference(Scene scene)
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? relationshipId, string? label, bool createdOrLinked)
{
batch.RelationshipDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
batch.RelationshipDecisions.Add(new OnboardingStoryIntelligenceRelationshipDecision
{
Key = key,
Action = action,
CharacterRelationshipID = relationshipId,
RelationshipLabel = label,
CreatedOrLinked = createdOrLinked
});
}
private static bool IsCharacterEntity(string? entityType)
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
private static int? ConfidenceIntensity(decimal? confidence)
=> confidence.HasValue ? Math.Clamp((int)Math.Round(confidence.Value * 10m, MidpointRounding.AwayFromZero), 1, 10) : null;
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
private static string ConfidenceBand(decimal? confidence)
=> confidence switch
{
>= 0.75m => "High",
>= 0.45m => "Medium",
null => "Unknown",
_ => "Low"
};
private static string PairKey(int characterAId, int characterBId)
=> characterAId <= characterBId ? $"{characterAId}:{characterBId}" : $"{characterBId}:{characterAId}";
private static string? FirstConfigured(params string?[] values)
=> values.Select(Clean).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string NormaliseForMatching(string? value)
=> $" {NonWord.Replace(Normalise(value), " ")} ";
private static bool ContainsAny(string value, params string[] needles)
=> needles.Any(needle => value.Contains(NormaliseForMatching(needle), StringComparison.Ordinal));
private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim();
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
private static readonly Regex NonWord = new(@"[^\p{L}\p{N}]+", RegexOptions.Compiled);
private static readonly Regex FigurativeSibling = new(@"\blike\s+(?:a|an)?\s*(?:older|younger|big|little)?\s*(?:sister|brother)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ReportedThirdPartyKinship = new(@"\b(?:about|for|of)\s+(?:looking|searching|look|search|find|finding)\s+for\s+(?:his|her|their|my)\s+(?:sister|brother|mother|father|mum|mom|dad)\b|\babout\s+(?:his|her|their|my)\s+(?:sister|brother|mother|father|mum|mom|dad)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly HashSet<string> StandaloneKinshipReferences = new(StringComparer.OrdinalIgnoreCase)
{
"mum", "mom", "mother", "dad", "father", "parent", "parents", "brother", "sister", "sibling", "aunt", "uncle"
};
private static readonly string[] RelationshipSignalWords =
[
"friend", "family", "romantic", "lover", "spouse", "parent", "child", "sibling", "enemy",
"rival", "ally", "argument", "conflict", "reconciled", "separated", "deteriorates", "meeting",
"trust", "hug", "gift", "present", "proposal", "engaged", "attraction"
];
private static readonly string[] RelationshipEventWords =
[
"begins", "begin", "starts", "first meeting", "meet", "met", "deteriorates", "deteriorate",
"reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
"estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects",
"becomes", "become", "establishes", "established", "explicit", "couple", "relationship",
"attraction", "romantic", "proposes", "proposal", "engaged", "accepts"
];
private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<RelationshipCandidate> Candidates);
private sealed class RelationshipCandidate(
string key,
int characterAId,
string characterAName,
int characterBId,
string characterBName,
string relationshipType,
int? existingRelationshipId,
string? existingRelationshipLabel)
{
public string Key { get; } = key;
public int CharacterAID { get; } = characterAId;
public string CharacterAName { get; } = characterAName;
public int CharacterBID { get; } = characterBId;
public string CharacterBName { get; } = characterBName;
public string RelationshipType { get; set; } = relationshipType;
public RelationshipTypeClassification RelationshipTypeClassification { get; set; } = new("Unknown", 0);
public int? ExistingRelationshipID { get; } = existingRelationshipId;
public string? ExistingRelationshipLabel { get; } = existingRelationshipLabel;
public decimal? Confidence { get; set; }
public string? FirstAppearanceNote { get; set; }
public string? LastAppearanceNote { get; set; }
public string? ExampleScene { get; set; }
public string? ExampleContext { get; set; }
public List<RelationshipAppearanceImport> Appearances { get; } = [];
}
private sealed record RelationshipAppearanceImport(
int SceneID,
decimal ChapterNumber,
decimal SceneNumber,
int SortOrder,
string SceneTitle,
string? Signal,
string? Evidence,
decimal? Confidence,
bool AlreadyLinked);
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int characterId, string characterName)
{
var key = Clean(name);
if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key))
{
return;
}
byName[key] = new CharacterMatch(characterId, characterName);
}
public CharacterMatch? Find(string? name, Scene scene)
{
var clean = Clean(name);
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
if (IsStandaloneKinshipReference(clean))
{
var pov = Clean(scene.POVCharacterName ?? scene.POVSuggestedName);
if (!string.IsNullOrWhiteSpace(pov))
{
var ownedReference = $"{pov}'s {KinshipIdentityName(clean)}";
if (byName.TryGetValue(ownedReference, out var localMatch))
{
return localMatch;
}
}
return null;
}
return byName.TryGetValue(clean, out var match) ? match : null;
}
}
private static bool IsStandaloneKinshipReference(string name)
=> StandaloneKinshipReferences.Contains(Normalise(name));
private static string KinshipIdentityName(string name)
=> Normalise(name) switch
{
"mum" or "mom" => "mother",
"dad" => "father",
var value => value
};
private sealed record RelationshipTypeClassification(string Type, int Score) : IComparable<RelationshipTypeClassification>
{
public int CompareTo(RelationshipTypeClassification? other)
{
if (other is null)
{
return 1;
}
var score = Score.CompareTo(other.Score);
return score != 0 ? score : Specificity(Type).CompareTo(Specificity(other.Type));
}
}
private static int Specificity(string type)
=> type switch
{
"Sibling" or "Parent" or "Child" or "Adoptive Parent" or "Biological Parent" or "Spouse" => 5,
"Neighbour" or "Educational" or "Professional" or "Medical" or "Authority" => 4,
"Friend" or "Romantic" or "Criminal" or "Rival" => 3,
"Family" or "Ally" => 2,
_ => 0
};
private sealed record CharacterMatch(int CharacterID, string CharacterName);
}