using System.Text.Json; using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryIntelligenceCharacterImportService { Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form); } public sealed class StoryIntelligenceCharacterImportService( IStoryIntelligenceResultRepository runs, ICharacterRepository characters, ISceneRepository scenes, IStoryIntelligencePipelineStateService pipelineState, INameIntelligenceService nameIntelligence, ICoreImportCharacterMappingService coreImportCharacterMapping, ICurrentUserService currentUser, ILogger logger) : IStoryIntelligenceCharacterImportService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; private static readonly HashSet IgnoredNames = CreateSet( "narrator", "unknown", "unknown narrator", "i", "me", "my", "mine", "we", "us", "our", "ours", "you", "he", "him", "his", "she", "her", "hers", "they", "them", "their", "theirs"); private static readonly HashSet GenericPeople = CreateSet( "man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse", "police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress", "clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group", "police", "officer", "officers", "staff", "customers", "boys", "girls", "men", "women", "neighbour", "neighbours", "passenger", "passengers", "examiner", "examiners", "instructor", "instructors", "candidate", "candidates", "social worker", "social workers", "responders"); private static readonly HashSet GenericGroupHeads = CreateSet( "man", "men", "woman", "women", "boy", "boys", "girl", "girls", "child", "children", "person", "people", "group", "crowd", "family", "neighbour", "neighbours", "passenger", "passengers", "examiner", "examiners", "driver", "drivers", "instructor", "instructors", "candidate", "candidates", "officer", "officers", "staff", "customers", "responders", "worker", "workers", "lad", "lads"); private static readonly HashSet QuantityWords = CreateSet( "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "several", "some", "many", "few", "couple", "various", "other", "groups"); private static readonly HashSet DeterminerWords = CreateSet( "the", "a", "an", "my", "your", "his", "her", "our", "their"); private static readonly HashSet FamilyRoleNames = CreateSet( "aunt", "uncle", "mother", "father", "mum", "mom", "mam", "dad", "brother", "sister", "grandmother", "grandfather", "grandma", "grandad", "granddad"); private static readonly HashSet CharacterTitlePrefixes = CreateSet( "mr", "mrs", "miss", "ms", "dr", "reverend", "rev", "detective", "sergeant", "ds", "aunt", "uncle"); private static readonly HashSet NonCharacterNames = CreateSet( "christmas", "house", "street", "door", "window", "car", "church", "building", "lobby", "room", "hallway", "kitchen", "garage", "pavement", "road", "desk", "table", "phone", "letter", "note", "social services", "under-flyover / link road"); private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled); private static readonly Regex LeadingQuantity = new(@"^\s*(\d+|one|two|three|four|five|six|seven|eight|nine|ten|several|some|many|few|couple|various|other)\s+", RegexOptions.IgnoreCase | RegexOptions.Compiled); public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var data = await BuildCandidateDataAsync(batch); var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); var nameWarnings = await BuildNameIntelligenceWarningsAsync(visibleCandidates, existingIndex); var existingOptions = existingIndex.Characters .Select(character => new StoryIntelligenceCharacterExistingOptionViewModel { CharacterID = character.CharacterID, CharacterName = character.CharacterName }) .OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase) .ToList(); var classified = visibleCandidates .Select(candidate => new { Candidate = candidate, Classification = ClassifyCandidate(candidate) }) .ToList(); return new StoryIntelligenceCharacterReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, GenericReferenceCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.GenericReference), ExistingMatchCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.ExistingCharacter), NewCandidateCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.NewCharacter || item.Classification == StoryIntelligenceCharacterClassifications.NewAlias), AmbiguousCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.Ambiguous), AliasTargetOptions = existingOptions, Candidates = classified.Select(item => new StoryIntelligenceCharacterReviewCandidateViewModel { DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(item.Candidate.DisplayName, item.Candidate.ExistingCharacterID, item.Classification), Classification = item.Classification, Key = item.Candidate.Key, CharacterName = item.Candidate.DisplayName, ImportName = item.Candidate.DisplayName, AppearsInScenes = item.Candidate.PresentAppearances.Concat(item.Candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(), Confidence = ConfidenceBand(item.Candidate.Confidence), PossibleAliases = item.Candidate.Aliases.OrderBy(value => value).ToList(), ExampleFirstAppearance = item.Candidate.FirstAppearanceNote, ExistingCharacterID = item.Candidate.ExistingCharacterID, ExistingCharacterName = item.Candidate.ExistingCharacterName, SelectedExistingCharacterID = item.Candidate.ExistingCharacterID, ExistingCharacterOptions = existingOptions, NameIntelligenceWarning = nameWarnings.GetValueOrDefault(item.Candidate.Key) }).ToList() }; } private async Task> BuildNameIntelligenceWarningsAsync( IReadOnlyList candidates, CharacterIndex existingIndex) { if (candidates.Count == 0) { return new Dictionary(); } var warnings = new Dictionary(StringComparer.OrdinalIgnoreCase); var existingForms = existingIndex.Rows .Select(row => new { row.CharacterName, row.MatchName, FirstName = FirstNameWithoutTitle(row.MatchName) ?? string.Empty }) .Where(row => !string.IsNullOrWhiteSpace(row.FirstName)) .ToList(); foreach (var candidate in candidates) { var candidateFirst = FirstNameWithoutTitle(candidate.DisplayName); if (string.IsNullOrWhiteSpace(candidateFirst)) { continue; } var candidateEvidence = await nameIntelligence.RecogniseAsync(candidateFirst, "en-US"); if (!candidateEvidence.Recognised) { continue; } foreach (var existing in existingForms) { if (string.Equals(existing.FirstName, candidateFirst, StringComparison.OrdinalIgnoreCase)) { continue; } var match = candidateEvidence.RelatedForms.FirstOrDefault(relationship => string.Equals(relationship.RelationshipType, "SpellingVariant", StringComparison.OrdinalIgnoreCase) && relationship.Strength >= 0.9m && (string.Equals(relationship.FromDisplayName, existing.FirstName, StringComparison.OrdinalIgnoreCase) || string.Equals(relationship.ToDisplayName, existing.FirstName, StringComparison.OrdinalIgnoreCase))); if (match is null) { continue; } var occurrences = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances) .Select(appearance => appearance.SceneNumber) .Distinct() .OrderBy(value => value) .Take(5) .Select(value => value.ToString("0.##")) .ToList(); var sceneLabel = occurrences.Count == 0 ? "the imported manuscript" : $"scene {string.Join(", ", occurrences)}"; warnings[candidate.Key] = $"Possible inconsistent name: manuscript form '{candidate.DisplayName}' is a strong spelling variant of existing {existing.MatchName} for {existing.CharacterName}; seen in {sceneLabel}."; break; } } return warnings; } public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form) { var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) { return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing characters." }; } var choices = form.Characters .Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); if (choices.Count == 0) { return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one character to create, link or ignore." }; } var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var roleTypes = lookupData.RoleTypes; var presenceTypes = lookupData.PresenceTypes; var created = 0; var linkedExisting = 0; var ignored = 0; var aliased = 0; var linkedAppearances = 0; var povLinks = 0; var resolvedCharacters = new Dictionary(StringComparer.OrdinalIgnoreCase); var resolvedCandidateIds = new Dictionary(StringComparer.OrdinalIgnoreCase); var candidateByKey = 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, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) { ignored++; AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Ignore, candidate.DisplayName, null, false); continue; } if (string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)) { continue; } var requestedName = Clean(choice.ImportName); var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase); var matchedExistingId = linkExisting ? existingIndex.Find(choice.ExistingCharacterID)?.CharacterID ?? candidate.ExistingCharacterID ?? existingIndex.Find(importName)?.CharacterID ?? existingIndex.Find(candidate.DisplayName)?.CharacterID : null; var characterId = matchedExistingId; if (!characterId.HasValue) { characterId = await characters.SaveCharacterAsync(new Character { ProjectID = batch.ProjectID, CharacterName = importName, ShortName = FirstName(importName), DefaultDescription = candidate.Description }); created++; } else { linkedExisting++; } AddResolvedName(resolvedCharacters, candidate.DisplayName, characterId.Value); AddResolvedName(resolvedCharacters, importName, characterId.Value); foreach (var alias in candidate.Aliases) { AddResolvedName(resolvedCharacters, alias, characterId.Value); await TryAddAliasAsync(characterId.Value, alias, importName); } foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked)) { await characters.SaveSceneCharacterAsync(new SceneCharacter { SceneID = appearance.SceneID, CharacterID = characterId.Value, RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene), PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly), AppearanceNotes = appearance.Notes }); linkedAppearances++; } resolvedCandidateIds[candidate.Key] = characterId.Value; AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true); } foreach (var candidate in data.Candidates) { if (!choices.TryGetValue(candidate.Key, out var choice) || !string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)) { continue; } var target = existingIndex.Find(choice.AliasTargetCharacterID); CharacterCandidate? targetCandidate = null; var targetCharacterId = target?.CharacterID; var targetName = target?.CharacterName; if (!targetCharacterId.HasValue) { 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, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase) && !string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase) && candidateByKey.TryGetValue(targetKey, out targetCandidate) && resolvedCandidateIds.TryGetValue(targetKey, out var resolvedTargetCharacterId)) { targetCharacterId = resolvedTargetCharacterId; targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName; } } if (!targetCharacterId.HasValue) { logger.LogWarning( "Story Intelligence character alias candidate {CandidateKey} could not be imported because no canonical target was selected. AliasTargetCharacterID={AliasTargetCharacterID}", candidate.Key, choice.AliasTargetCharacterID); continue; } targetName ??= $"Character {targetCharacterId.Value:N0}"; if (candidate.ExistingCharacterID.HasValue && candidate.ExistingCharacterID.Value != targetCharacterId.Value) { await characters.MergeAsync(candidate.ExistingCharacterID.Value, targetCharacterId.Value, currentUser.UserId); } await TryAddAliasAsync(targetCharacterId.Value, candidate.DisplayName, targetName); AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId.Value); foreach (var alias in candidate.Aliases) { await TryAddAliasAsync(targetCharacterId.Value, alias, targetName); AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value); } foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked)) { await characters.SaveSceneCharacterAsync(new SceneCharacter { SceneID = appearance.SceneID, CharacterID = targetCharacterId.Value, RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene), PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly), AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetName, appearance.Notes) }); linkedAppearances++; } aliased++; AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId.Value, true); } foreach (var scene in data.SceneAnalyses) { var povName = Clean(scene.Parsed.PointOfView?.CharacterName); if (string.IsNullOrWhiteSpace(povName) || !resolvedCharacters.TryGetValue(povName, out var characterId)) { continue; } await scenes.UpdatePovCharacterAsync(scene.SceneID, characterId); povLinks++; } batch.CharacterStageComplete = true; batch.LastCharacterImportResult = new StoryIntelligenceCharacterImportBatchResult { CharactersCreated = created, CharactersLinked = linkedExisting, CharactersIgnored = ignored, CharactersAliased = aliased, PovLinksResolved = povLinks, ScenePeoplePanelsUpdated = linkedAppearances }; var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID); await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID); logger.LogInformation( "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks} RemappedScenes={RemappedScenes} RemappedLinks={RemappedLinks}", batch.BatchID, created, linkedExisting, aliased, ignored, linkedAppearances, povLinks, remap.ScenesScanned, remap.LinksSaved); return new StoryIntelligenceImportCommitResult { Success = true, ScenesCreated = linkedAppearances, Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved. Deterministic remapping updated {remap.LinksSaved:N0} link(s)." }; } private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? characterName, int? characterId, bool createdOrLinked) { batch.CharacterDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase)); batch.CharacterDecisions.Add(new OnboardingStoryIntelligenceCharacterDecision { Key = key, Action = action, CharacterName = characterName, CharacterID = characterId, CreatedOrLinked = createdOrLinked }); } private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch) { var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); var sceneAnalyses = new List(); 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; } sceneAnalyses.Add(new ImportedSceneAnalysis(importedScene.SceneID, parsed)); var existingAppearances = await characters.ListSceneCharactersAsync(importedScene.SceneID); alreadyLinked += existingAppearances.Count; AddSceneCharacterMentions(groups, existingIndex, importedScene, parsed, existingAppearances); } } var candidates = groups.Values .Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0) .Where(candidate => candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Any(appearance => !appearance.AlreadyLinked)) .Where(IsVisibleCharacterCandidate) .OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count()) .ThenByDescending(candidate => candidate.MentionedOnlyAppearances.Select(appearance => appearance.SceneID).Distinct().Count()) .ThenBy(candidate => candidate.DisplayName) .ToList(); return new CharacterCandidateData(hasCommittedScenes, alreadyLinked, candidates, sceneAnalyses); } private static void AddMention( Dictionary groups, CharacterIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character, IReadOnlyList existingAppearances) { var name = CleanCharacterName(character.Name); if (!IsCharacterNameCandidate(name)) { return; } var key = ResolveCandidateKey(groups, existingIndex, name); if (!groups.TryGetValue(key, out var candidate)) { var match = existingIndex.Find(name); candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName); groups[key] = candidate; } else if (!string.Equals(candidate.DisplayName, name, StringComparison.OrdinalIgnoreCase)) { candidate.Aliases.Add(name); } foreach (var alias in character.Aliases ?? []) { var cleanAlias = CleanCharacterName(alias); if (IsCharacterNameCandidate(cleanAlias) && !string.Equals(cleanAlias, candidate.DisplayName, StringComparison.OrdinalIgnoreCase)) { candidate.Aliases.Add(cleanAlias); } } var notes = Clean(character.Notes); if (string.IsNullOrWhiteSpace(notes) && character.Actions?.Count > 0) { notes = Clean(string.Join("; ", character.Actions)); } var appearance = new CharacterAppearanceImport( importedScene.SceneID, importedScene.SceneNumber, importedScene.SceneTitle, character.RoleInScene, character.MentionedOnly == true, notes, character.Confidence, candidate.ExistingCharacterID.HasValue && existingAppearances.Any(appearance => appearance.CharacterID == candidate.ExistingCharacterID.Value)); if (character.MentionedOnly == true) { candidate.MentionedOnlyAppearances.Add(appearance); } else { candidate.PresentAppearances.Add(appearance); } candidate.Confidence = Max(candidate.Confidence, character.Confidence); candidate.Description ??= notes; candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, character); } private static void AddSceneCharacterMentions( Dictionary groups, CharacterIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed, IReadOnlyList existingAppearances) { foreach (var character in parsed.Characters ?? []) { AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances); } foreach (var appearance in parsed.CharacterAppearance ?? []) { AddCharacterAppearanceAliases(groups, existingIndex, importedScene, parsed, appearance, existingAppearances); } var povName = CleanCharacterName(parsed.PointOfView?.CharacterName); if (IsCharacterNameCandidate(povName) && !importedScene.POVCharacterID.HasValue) { AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter { Name = povName, RoleInScene = "POV", MentionedOnly = false, Confidence = parsed.PointOfView?.Confidence ?? 0.7m, Notes = "Point of view candidate from Scene Intelligence." }, existingAppearances); } foreach (var relationship in parsed.Relationships ?? []) { AddObservedName(groups, existingIndex, importedScene, parsed, relationship.CharacterA, "Relationship participant", false, relationship.Confidence, relationship.RelationshipSignal, existingAppearances); AddObservedName(groups, existingIndex, importedScene, parsed, relationship.CharacterB, "Relationship participant", false, relationship.Confidence, relationship.RelationshipSignal, existingAppearances); } foreach (var knowledge in parsed.KnowledgeChanges ?? []) { AddObservedName(groups, existingIndex, importedScene, parsed, knowledge.RecipientCharacter, "Knowledge recipient", false, knowledge.Confidence, knowledge.KnowledgeItem, existingAppearances); AddObservedName(groups, existingIndex, importedScene, parsed, knowledge.SourceCharacter, "Knowledge source", true, knowledge.Confidence, knowledge.KnowledgeItem, existingAppearances); } foreach (var observation in parsed.Observations ?? []) { if (IsCharacterEntity(observation.SubjectEntityType)) { AddObservedName(groups, existingIndex, importedScene, parsed, observation.SubjectName, observation.ObservationType, false, observation.Confidence, observation.Description, existingAppearances); } if (IsCharacterEntity(observation.ObjectEntityType)) { AddObservedName(groups, existingIndex, importedScene, parsed, observation.ObjectName, observation.ObservationType, true, observation.Confidence, observation.Description, existingAppearances); } } } private static void AddCharacterAppearanceAliases( Dictionary groups, CharacterIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed, SceneIntelligenceCharacterAppearance appearance, IReadOnlyList existingAppearances) { var canonicalName = CleanCharacterName(appearance.CanonicalName); if (!IsCharacterNameCandidate(canonicalName)) { return; } AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter { Name = canonicalName, RoleInScene = appearance.RoleOrOccupation, MentionedOnly = false, Aliases = appearance.Aliases, Confidence = appearance.Confidence, Notes = appearance.Evidence }, existingAppearances); } private static void AddObservedName( Dictionary groups, CharacterIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed, string? name, string? role, bool mentionedOnly, decimal? confidence, string? notes, IReadOnlyList existingAppearances) { var cleanName = CleanCharacterName(name); if (!IsCharacterNameCandidate(cleanName) || IsResolvedPovReference(cleanName, importedScene)) { return; } AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter { Name = cleanName, RoleInScene = role, MentionedOnly = mentionedOnly, Confidence = confidence ?? 0.65m, Notes = notes }, existingAppearances); } private static bool IsResolvedPovReference(string name, Scene importedScene) => importedScene.POVCharacterID.HasValue && (IgnoredNames.Contains(Normalise(name)) || string.Equals(name, "Narrator", StringComparison.OrdinalIgnoreCase)); private async Task BuildCharacterIndexAsync(int projectId) { var index = new CharacterIndex(); foreach (var character in await characters.ListCharactersAsync(projectId)) { index.AddCharacter(character.CharacterID, character.CharacterName); index.Add(character.CharacterName, character.CharacterID, character.CharacterName); index.Add(FirstName(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); index.Add(FirstName(alias.Alias), character.CharacterID, character.CharacterName); } } return index; } private async Task TryAddAliasAsync(int characterId, string alias, string primaryName) { var cleanAlias = Clean(alias); if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase)) { return; } var existingAliases = await characters.ListAliasesAsync(characterId); if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase))) { return; } await characters.AddAliasAsync(characterId, cleanAlias, null); } 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 int? MatchRole(IReadOnlyList roleTypes, string? role) { var value = Clean(role); if (string.IsNullOrWhiteSpace(value)) { return null; } return roleTypes.FirstOrDefault(type => value.Contains(type.TypeName, StringComparison.OrdinalIgnoreCase) || type.TypeName.Contains(value, StringComparison.OrdinalIgnoreCase))?.CharacterRoleInSceneTypeID; } private static int? MatchPresence(IReadOnlyList presenceTypes, bool mentionedOnly) { var preferred = mentionedOnly ? "Mentioned" : "Present"; return presenceTypes.FirstOrDefault(type => type.TypeName.Contains(preferred, StringComparison.OrdinalIgnoreCase))?.PresenceTypeID; } private static void AddResolvedName(Dictionary map, string name, int characterId) { var clean = Clean(name); if (!string.IsNullOrWhiteSpace(clean)) { map[clean] = characterId; } } private static string? BuildAliasAppearanceNote(string aliasName, string targetName, string? notes) { var prefix = $"Imported as alias '{aliasName}' of {targetName}."; return string.IsNullOrWhiteSpace(notes) ? prefix : $"{prefix} {notes}"; } private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character) { var note = Clean(character.Notes); if (!string.IsNullOrWhiteSpace(note)) { return $"{BuildSceneReference(scene)}: {note}"; } var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short)); return string.IsNullOrWhiteSpace(summary) ? $"{BuildSceneReference(scene)}: {scene.SceneTitle}" : $"{BuildSceneReference(scene)}: {summary}"; } private static string? FirstConfigured(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim(); private static string BuildSceneReference(Scene scene) => $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}"; 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? FirstName(string name) { var parts = Clean(name).Split(' ', StringSplitOptions.RemoveEmptyEntries); return parts.Length > 1 ? parts[0] : null; } private static bool IsGenericPerson(string name) => GenericPeople.Contains(name) || GenericPeople.Contains(Normalise(name)); private static bool IsCharacterEntity(string? entityType) => string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase); private static bool IsVisibleCharacterCandidate(CharacterCandidate candidate) { var name = CleanCharacterName(candidate.DisplayName); if (!IsCharacterNameCandidate(name)) { return false; } if (IsNamedOrTitledPerson(name)) { return true; } if (IsFamilyRoleOnly(name)) { return candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances) .Select(appearance => appearance.SceneID) .Distinct() .Count() >= 2; } return !IsGenericGroupReference(name); } private static string ClassifyCandidate(CharacterCandidate candidate) { var name = CleanCharacterName(candidate.DisplayName); if (!IsCharacterNameCandidate(name)) { return StoryIntelligenceCharacterClassifications.NonCharacter; } if (candidate.ExistingCharacterID.HasValue) { return StoryIntelligenceCharacterClassifications.ExistingCharacter; } if (StoryIntelligenceCharacterReviewDefaults.IsGenericContextualReference(name) || IsFamilyRoleOnly(name) || IsGenericGroupReference(name)) { return StoryIntelligenceCharacterClassifications.GenericReference; } if (candidate.Aliases.Count > 0) { return StoryIntelligenceCharacterClassifications.NewAlias; } if (candidate.Confidence is null or < 0.65m) { return StoryIntelligenceCharacterClassifications.Ambiguous; } return StoryIntelligenceCharacterClassifications.NewCharacter; } private static bool IsCharacterNameCandidate(string? name) { var clean = CleanCharacterName(name); if (string.IsNullOrWhiteSpace(clean) || IgnoredNames.Contains(clean) || IsGenericPerson(clean) || NonCharacterNames.Contains(clean)) { return false; } if (!clean.Any(char.IsLetter) || clean.Length < 2) { return false; } return true; } private static bool IsGenericGroupReference(string? name) { var clean = CleanCharacterName(name); if (string.IsNullOrWhiteSpace(clean)) { return true; } var withoutParentheses = Clean(ParentheticalSuffix.Replace(clean, string.Empty)); var normalised = Normalise(withoutParentheses); if (GenericPeople.Contains(normalised) || NonCharacterNames.Contains(normalised)) { return true; } var words = WordsForFiltering(withoutParentheses); if (words.Count == 0) { return true; } if (LeadingQuantity.IsMatch(withoutParentheses) && words.Any(word => GenericGroupHeads.Contains(word))) { return true; } if (words.Count > 1 && words.Any(word => GenericGroupHeads.Contains(word)) && !HasSpecificPossessiveFamilyReference(words)) { return true; } if (words.Count > 1 && DeterminerWords.Contains(words[0]) && words.Skip(1).Any(word => FamilyRoleNames.Contains(word) || GenericGroupHeads.Contains(word))) { return true; } if (words.Count > 1 && QuantityWords.Contains(words[0]) && words.Skip(1).Any(word => GenericGroupHeads.Contains(word))) { return true; } return words.All(word => GenericGroupHeads.Contains(word) || QuantityWords.Contains(word)); } private static bool IsNamedOrTitledPerson(string name) { var words = WordsForFiltering(name); if (words.Count == 0) { return false; } return CharacterTitlePrefixes.Contains(words[0]) && words.Count > 1; } private static bool IsFamilyRoleOnly(string name) { var words = WordsForFiltering(name); return words.Count == 1 && FamilyRoleNames.Contains(words[0]); } private static bool HasSpecificPossessiveFamilyReference(IReadOnlyList words) => words.Count == 2 && FamilyRoleNames.Contains(words[1]) && words[0].EndsWith("s", StringComparison.OrdinalIgnoreCase) && !DeterminerWords.Contains(words[0]); private static string ResolveCandidateKey( IReadOnlyDictionary groups, CharacterIndex existingIndex, string name) { var canonical = CanonicalCharacterKey(name); if (groups.ContainsKey(canonical)) { return canonical; } var existingMatch = existingIndex.Find(name); if (existingMatch is not null) { var existingKey = CanonicalCharacterKey(existingMatch.CharacterName); if (groups.ContainsKey(existingKey)) { return existingKey; } } var first = FirstNameWithoutTitle(name); if (!string.IsNullOrWhiteSpace(first)) { foreach (var candidate in groups.Values) { if (string.Equals(FirstNameWithoutTitle(candidate.DisplayName), first, StringComparison.OrdinalIgnoreCase) && IsSafeFirstNameMerge(candidate.DisplayName, name)) { return candidate.Key; } } } return canonical; } private static bool IsSafeFirstNameMerge(string current, string next) { var currentWords = WordsWithoutTitles(current); var nextWords = WordsWithoutTitles(next); return currentWords.Count == 1 || nextWords.Count == 1 || currentWords.SequenceEqual(nextWords, StringComparer.OrdinalIgnoreCase); } private static string CanonicalCharacterKey(string name) => Normalise(string.Join(' ', WordsWithoutTitles(CleanCharacterName(name)))); private static string? FirstNameWithoutTitle(string? name) => WordsWithoutTitles(name).FirstOrDefault(); private static List WordsWithoutTitles(string? name) { var words = CleanCharacterName(name) .Split(' ', StringSplitOptions.RemoveEmptyEntries) .Select(word => word.Trim('.', ',', ';', ':', '!', '?', '"', '\'')) .Where(word => !string.IsNullOrWhiteSpace(word)) .ToList(); if (words.Count > 1 && IsMergeableTitle(words[0])) { words.RemoveAt(0); } return words; } private static List WordsForFiltering(string? name) => CleanCharacterName(name) .Replace("/", " ", StringComparison.Ordinal) .Replace("-", " ", StringComparison.Ordinal) .Split(' ', StringSplitOptions.RemoveEmptyEntries) .Select(word => word.Trim('.', ',', ';', ':', '!', '?', '"', '\'', '(', ')').ToLowerInvariant()) .Where(word => !string.IsNullOrWhiteSpace(word)) .ToList(); private static bool IsMergeableTitle(string value) => value.Equals("instructor", StringComparison.OrdinalIgnoreCase) || value.Equals("detective", StringComparison.OrdinalIgnoreCase) || value.Equals("sergeant", StringComparison.OrdinalIgnoreCase) || value.Equals("ds", StringComparison.OrdinalIgnoreCase); private static string CleanCharacterName(string? value) { var clean = Clean(value) .Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\''); if (clean.EndsWith("'s", StringComparison.OrdinalIgnoreCase)) { clean = clean[..^2]; } else if (clean.EndsWith("’s", StringComparison.OrdinalIgnoreCase)) { clean = clean[..^2]; } return Clean(clean.Replace("Mrs.", "Mrs", StringComparison.OrdinalIgnoreCase) .Replace("Mr.", "Mr", StringComparison.OrdinalIgnoreCase) .Replace("Ms.", "Ms", StringComparison.OrdinalIgnoreCase) .Replace("Dr.", "Dr", StringComparison.OrdinalIgnoreCase)); } private static string Normalise(string? value) => Clean(value).ToLowerInvariant(); private static string Clean(string? value) => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); private static HashSet CreateSet(params string[] values) => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); private sealed class CharacterIndex { private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary byId = new(); private readonly List rows = []; private readonly List characters = []; public IReadOnlyList Rows => rows; public IReadOnlyList Characters => characters; public void AddCharacter(int characterId, string characterName) { var match = new CharacterMatch(characterId, characterName); byId.TryAdd(characterId, match); if (!characters.Any(character => character.CharacterID == characterId)) { characters.Add(new CharacterIndexCharacter(characterId, characterName)); } } public void Add(string? name, int characterId, string characterName) { var key = Normalise(name); if (!string.IsNullOrWhiteSpace(key)) { byName.TryAdd(key, new CharacterMatch(characterId, characterName)); rows.Add(new CharacterIndexRow(name!, characterName)); } } public CharacterMatch? Find(string name) => byName.TryGetValue(Normalise(name), out var match) ? match : null; public CharacterMatch? Find(int? characterId) => characterId.HasValue && byId.TryGetValue(characterId.Value, out var match) ? match : null; } private sealed record CharacterMatch(int CharacterID, string CharacterName); private sealed record CharacterIndexRow(string MatchName, string CharacterName); private sealed record CharacterIndexCharacter(int CharacterID, string CharacterName); private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName) { public string Key { get; } = key; public string DisplayName { get; } = displayName; public int? ExistingCharacterID { get; } = existingCharacterId; public string? ExistingCharacterName { get; } = existingCharacterName; public HashSet Aliases { get; } = new(StringComparer.OrdinalIgnoreCase); public List PresentAppearances { get; } = []; public List MentionedOnlyAppearances { get; } = []; public decimal? Confidence { get; set; } public string? Description { get; set; } public string? FirstAppearanceNote { get; set; } } private sealed record CharacterAppearanceImport( int SceneID, decimal SceneNumber, string SceneTitle, string? RoleInScene, bool MentionedOnly, string? Notes, decimal? Confidence, bool AlreadyLinked); private sealed record ImportedSceneAnalysis(int SceneID, SceneIntelligenceScene Parsed); private sealed record CharacterCandidateData( bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList Candidates, IReadOnlyList SceneAnalyses); }