using System.Text.Json; using System.Text.RegularExpressions; 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 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, Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel { Key = candidate.Key, CharacterAName = candidate.CharacterAName, CharacterBName = candidate.CharacterBName, RelationshipType = 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); 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 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) { relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship { ProjectID = batch.ProjectID, CharacterAID = candidate.CharacterAID, CharacterBID = candidate.CharacterBID, RelationshipTypeID = MatchRelationshipType(lookupData.RelationshipTypes, choice.RelationshipType ?? candidate.RelationshipType), IsPermanent = false, StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).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())) { 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 importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) .Where(scene => scene.ImportRunID == item.RunID) .ToList(); 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); var sceneResults = await runs.ListSceneResultsAsync(item.RunID); foreach (var sceneResult in sceneResults) { var parsed = TryReadScene(sceneResult); if (parsed is null) { continue; } var importedScene = ResolveImportedScene(sceneResult, 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), 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); } 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.SceneNumber, 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 byRange, IReadOnlyDictionary byNumber) { 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 MatchRelationshipType(IReadOnlyList types, string? signal) { var normalised = Normalise(signal); var mapped = RelationshipTypeFromSignal(signal); return types.FirstOrDefault(type => string.Equals(type.TypeName, mapped, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID ?? types.FirstOrDefault(type => normalised.Contains(Normalise(type.TypeName), 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("romantic") || value.Contains("lover") || value.Contains("love") ? "Romantic" : value.Contains("friend") || value.Contains("ally") || value.Contains("support") ? "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) { var value = Normalise(signal); if (value.Contains("mother") || value.Contains("father") || value.Contains("parent")) return "Parent"; if (value.Contains("daughter") || value.Contains("son") || value.Contains("child")) return "Child"; if (value.Contains("sister") || value.Contains("brother") || value.Contains("sibling")) return "Sibling"; if (value.Contains("spouse") || value.Contains("husband") || value.Contains("wife") || value.Contains("married")) return "Spouse"; if (value.Contains("romantic") || value.Contains("lover") || value.Contains("love")) return "Lover"; if (value.Contains("friend")) return "Friend"; if (value.Contains("enemy") || value.Contains("adversarial") || value.Contains("hostile")) return "Enemy"; if (value.Contains("rival")) return "Rival"; if (value.Contains("suspicious") || value.Contains("suspect")) return "Suspicious Of"; if (value.Contains("neighbour") || value.Contains("neighbor")) return "Neighbour"; if (value.Contains("teacher")) return "Teacher"; if (value.Contains("student")) return "Student"; if (value.Contains("employer") || value.Contains("boss")) return "Employer"; if (value.Contains("employee")) return "Employee"; if (value.Contains("colleague") || value.Contains("professional") || value.Contains("police") || value.Contains("detective") || value.Contains("sergeant")) return "Colleague"; if (value.Contains("ally") || value.Contains("support")) return "Ally"; if (value.Contains("guardian") || value.Contains("protector")) return "Protector"; if (value.Contains("estranged") || value.Contains("separat")) return "Estranged From"; 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 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 string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed) { var summary = Clean(parsed.Summary?.Short); return string.IsNullOrWhiteSpace(summary) ? BuildSceneReference(scene) : $"{BuildSceneReference(scene)}: {summary}"; } private static string BuildSceneReference(Scene scene) => $"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 Clean(string? value) => string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim(); private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled); private static readonly string[] RelationshipSignalWords = [ "friend", "family", "romantic", "lover", "spouse", "parent", "child", "sibling", "enemy", "rival", "ally", "argument", "conflict", "reconciled", "separated", "deteriorates", "meeting" ]; 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" ]; 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 SceneNumber, 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); }