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 BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form); } public sealed class StoryIntelligenceRelationshipImportService( IStoryIntelligenceResultRepository runs, ICharacterRepository characters, ISceneRepository scenes, IStoryIntelligencePipelineStateService pipelineState, ILogger logger) : IStoryIntelligenceRelationshipImportService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; public async Task 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 ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form) { var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) { return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing 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(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 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(StringComparer.OrdinalIgnoreCase); var alreadyLinked = 0; var hasCommittedScenes = false; foreach (var item in batch.Items) { var commit = await runs.GetImportCommitAsync(item.RunID); if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) { continue; } hasCommittedScenes = true; var sceneResults = await runs.ListSceneResultsAsync(item.RunID); 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)) .OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count()) .ThenBy(candidate => candidate.CharacterAName) .ThenBy(candidate => candidate.CharacterBName) .ToList(); return new RelationshipCandidateData(hasCommittedScenes, alreadyLinked, candidates); } private static void AddSceneRelationships( Dictionary groups, CharacterIndex characterIndex, IReadOnlyDictionary existingByPair, Scene importedScene, SceneIntelligenceScene parsed, IReadOnlyList 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 groups, CharacterIndex characterIndex, IReadOnlyDictionary existingByPair, Scene importedScene, SceneIntelligenceScene parsed, string? characterAName, string? characterBName, string? relationshipSignal, string? evidence, decimal? confidence, IReadOnlyList existingEvents) { var characterA = characterIndex.Find(characterAName); var characterB = characterIndex.Find(characterBName); if (characterA is null || characterB is null || characterA.CharacterID == characterB.CharacterID) { return; } var key = PairKey(characterA.CharacterID, characterB.CharacterID); 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, RelationshipTypeFromSignal(relationshipSignal, evidence), existingMatch?.CharacterRelationshipID, existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}"); groups[key] = candidate; } if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m)) { candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal, evidence); } 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 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 byId, IReadOnlyDictionary byRange, IReadOnlyDictionary 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(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(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 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 states, string? signal) { var value = Normalise(signal); var stateName = 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") ? "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"; 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 RelationshipTypeFromSignal(string? signal, string? evidence = null) { var value = NormaliseForMatching($"{signal} {evidence}"); if (ContainsAny(value, "mother", "father", "sister", "brother", "parent", "daughter", "son", " child ", "aunt", "uncle", "cousin", "family", "sibling", "parent and child")) { return "Family"; } if (ContainsAny(value, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like", "attraction", "flirt", "kiss", "proposal", "engaged")) { return "Romantic"; } if (ContainsAny(value, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor")) { return "Educational"; } if (ContainsAny(value, "police", "detective", "suspect", "witness", "authority", "guard", "prisoner", "investigator", "person of interest")) { return "Authority"; } if (ContainsAny(value, "criminal", "accomplice", "offender", "victim", "gang", "conspirator")) { return "Criminal"; } if (ContainsAny(value, "doctor", "patient", "nurse", "therapist", "medical")) { return "Medical"; } if (ContainsAny(value, "neighbour", "neighbor")) { return "Neighbour"; } if (ContainsAny(value, "colleague", "coworker", "co worker", "boss", "employee", "manager", "client", "professional", "work relationship")) { return "Professional"; } if (ContainsAny(value, "friend", "friendship", "best friend", "close friend", "childhood friend", "trust between friends", "affection between friends", "trust", "familiarity", "comfortable together", "socialise", "socialize", "spend time together", "spends time together", "hug", "hugs", "gift", "gifts", "present", "presents")) { return "Friend"; } if (ContainsAny(value, "rival", "enemy", "adversarial", "conflict", "mistrust", "antagonist", "competitor")) { return "Rival"; } if (ContainsAny(value, "ally", "protector", "supporter", "confidant", "dependence", "cooperation")) { return "Ally"; } return "Unknown"; } private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance) { var value = $"{appearance.Signal} {appearance.Evidence}"; return RelationshipEventWords.Any(word => value.Contains(word, 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 string BuildRelationshipNotes(RelationshipCandidate candidate) { var lines = new List { "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 ToRelationshipTypeOptions(IEnumerable 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 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 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", "trust", "hugs", "hug", "gifts", "gift", "presents", "socialises", "socializes", "attraction", "proposes", "proposal", "engaged", "accepts" ]; private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList 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 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 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 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) => byName.TryGetValue(Clean(name), out var match) ? match : null; } private sealed record CharacterMatch(int CharacterID, string CharacterName); }