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 IStoryIntelligenceKnowledgeImportService { Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form); } public sealed class StoryIntelligenceKnowledgeImportService( IStoryIntelligenceResultRepository runs, ICharacterRepository characters, ISceneRepository scenes, IAssetRepository assets, IStoryIntelligencePipelineStateService pipelineState, ILogger logger) : IStoryIntelligenceKnowledgeImportService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates); var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var selectedStateIds = batch.KnowledgeSelections.ToDictionary(selection => selection.Key, selection => selection.KnowledgeStateID, StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); return new StoryIntelligenceKnowledgeReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, KnowledgeStateOptions = lookupData.KnowledgeStates .OrderBy(state => state.SortOrder) .ThenBy(state => state.StateName) .Select(state => new SelectListItem(state.StateName, state.KnowledgeStateID.ToString())) .ToList(), Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceKnowledgeReviewCandidateViewModel { Key = candidate.Key, CharacterName = candidate.CharacterName, KnowledgeStatement = candidate.KnowledgeStatement, KnowledgeStateID = ResolveKnowledgeStateID(lookupData.KnowledgeStates, selectedStateIds.GetValueOrDefault(candidate.Key), candidate.KnowledgeStateName), KnowledgeStateName = candidate.KnowledgeStateName, AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), Confidence = ConfidenceBand(candidate.Confidence), FirstScene = candidate.FirstScene, ExampleScene = candidate.ExampleScene, Evidence = candidate.Evidence, ExistingKnowledgeID = candidate.ExistingKnowledgeID, ExistingKnowledgeLabel = candidate.ExistingKnowledgeLabel }).ToList() }; } public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form) { var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates); if (!data.HasCommittedScenes) { return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing knowledge." }; } var choices = form.Knowledge .Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); SaveKnowledgeSelections(batch, choices.Values); if (choices.Count == 0) { if (data.Candidates.Count == 0) { batch.KnowledgeStageComplete = true; batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult(); await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); return new StoryIntelligenceImportCommitResult { Success = true, Message = "Knowledge review completed. No knowledge decisions were waiting." }; } return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one knowledge item to create, link, merge or ignore." }; } var stateIds = lookupData.KnowledgeStates.Select(state => state.KnowledgeStateID).ToHashSet(); var created = 0; var linkedExisting = 0; var merged = 0; var ignored = 0; var resolvedKnowledge = 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, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) { ignored++; AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Ignore, null, KnowledgeLabel(candidate), false); continue; } if (string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase)) { continue; } var knowledgeId = string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase) ? candidate.ExistingKnowledgeID : null; if (!knowledgeId.HasValue) { if (!choice.KnowledgeStateID.HasValue || !stateIds.Contains(choice.KnowledgeStateID.Value)) { return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid knowledge state for each knowledge item you want to create." }; } var firstAppearance = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).First(); knowledgeId = await characters.SaveKnowledgeAsync(new CharacterKnowledgeItem { CharacterID = candidate.CharacterID, StoryAssetID = candidate.StoryAssetID, PlotThreadID = null, SceneID = firstAppearance.SceneID, KnowledgeStateID = choice.KnowledgeStateID.Value, SourceEventID = null, Description = BuildKnowledgeDescription(candidate, firstAppearance) }); created++; } else { linkedExisting++; } resolvedKnowledge[candidate.Key] = knowledgeId.Value; AddDecision(batch, candidate.Key, choice.Action, knowledgeId.Value, KnowledgeLabel(candidate), true); } foreach (var candidate in data.Candidates) { if (!choices.TryGetValue(candidate.Key, out var choice) || !string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.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, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase) || string.Equals(targetChoice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase) || !candidatesByKey.ContainsKey(targetKey) || !resolvedKnowledge.TryGetValue(targetKey, out var targetKnowledgeId)) { logger.LogWarning( "Story Intelligence knowledge merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.", candidate.Key, targetKey); continue; } resolvedKnowledge[candidate.Key] = targetKnowledgeId; merged++; AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Alias, targetKnowledgeId, KnowledgeLabel(candidate), true); } batch.KnowledgeStageComplete = true; batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult { KnowledgeCreated = created, KnowledgeLinked = linkedExisting, KnowledgeMerged = merged, KnowledgeIgnored = ignored }; await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); logger.LogInformation( "Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}", batch.BatchID, created, linkedExisting, merged, ignored); return new StoryIntelligenceImportCommitResult { Success = true, Message = $"Knowledge imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored." }; } private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch, IReadOnlyList knowledgeStates) { var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID); var assetIndex = await BuildAssetIndexAsync(batch.ProjectID); var existingKnowledge = await ListExistingKnowledgeAsync(batch.ProjectID); 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 existingSceneKnowledge = await characters.ListKnowledgeBySceneAsync(importedScene.SceneID); alreadyLinked += existingSceneKnowledge.Count; AddSceneKnowledge(groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, knowledgeStates); } } var candidates = groups.Values .OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count()) .ThenBy(candidate => candidate.CharacterName) .ThenBy(candidate => candidate.KnowledgeStatement) .ToList(); return new KnowledgeCandidateData(hasCommittedScenes, alreadyLinked, candidates); } private static void AddSceneKnowledge( Dictionary groups, CharacterIndex characterIndex, AssetIndex assetIndex, IReadOnlyDictionary existingKnowledge, Scene importedScene, SceneIntelligenceScene parsed, IReadOnlyList knowledgeStates) { foreach (var change in parsed.KnowledgeChanges ?? []) { AddKnowledge(groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, change.RecipientCharacter, change.KnowledgeItem, change.ChangeType, change.Evidence, change.Confidence, knowledgeStates); } foreach (var observation in parsed.Observations ?? []) { if (!IsKnowledgeObservation(observation)) { continue; } AddKnowledge( groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, observation.SubjectName, FirstConfigured(observation.Description, JoinConfigured(observation.Predicate, observation.ObjectName)), FirstConfigured(observation.Predicate, observation.ObservationType), observation.Evidence, observation.Confidence, knowledgeStates); } } private static void AddKnowledge( Dictionary groups, CharacterIndex characterIndex, AssetIndex assetIndex, IReadOnlyDictionary existingKnowledge, Scene importedScene, SceneIntelligenceScene parsed, string? characterName, string? knowledgeItem, string? changeType, string? evidence, decimal? confidence, IReadOnlyList knowledgeStates) { var character = characterIndex.Find(characterName); var statement = CleanKnowledgeStatement(knowledgeItem); if (character is null || string.IsNullOrWhiteSpace(statement)) { return; } var key = KnowledgeKey(character.CharacterID, statement); if (!groups.TryGetValue(key, out var candidate)) { var existingKey = ExistingKnowledgeKey(character.CharacterID, statement); existingKnowledge.TryGetValue(existingKey, out var existingMatch); var stateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates); var assetMatch = assetIndex.Find(statement); candidate = new KnowledgeCandidate( key, character.CharacterID, character.CharacterName, statement, stateName, assetMatch?.StoryAssetID, existingMatch?.CharacterKnowledgeID, existingMatch is null ? null : ExistingKnowledgeLabel(existingMatch)); groups[key] = candidate; } if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m)) { candidate.KnowledgeStateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates); } candidate.Confidence = Max(candidate.Confidence, confidence); candidate.FirstScene ??= BuildSceneReference(importedScene); candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed); candidate.Evidence ??= Clean(evidence); candidate.Appearances.Add(new KnowledgeAppearanceImport( importedScene.SceneID, importedScene.SceneNumber, importedScene.SceneTitle, changeType, evidence, confidence)); } private async Task> ListExistingKnowledgeAsync(int projectId) { var rows = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var character in await characters.ListCharactersAsync(projectId)) { foreach (var item in await characters.ListKnowledgeByCharacterAsync(character.CharacterID)) { var key = ExistingKnowledgeKey(item.CharacterID, KnowledgeTitle(item)); if (!rows.ContainsKey(key)) { rows[key] = item; } } } return rows; } 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 async Task BuildAssetIndexAsync(int projectId) { var index = new AssetIndex(); foreach (var asset in await assets.ListAssetsAsync(projectId)) { index.Add(asset.AssetName, asset.StoryAssetID, asset.AssetName); foreach (var alias in await assets.ListAliasesAsync(asset.StoryAssetID)) { index.Add(alias.Alias, asset.StoryAssetID, asset.AssetName); } } 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 IsKnowledgeObservation(SceneIntelligenceObservation observation) { var type = Clean(observation.ObservationType); if (!type.Contains("knowledge", StringComparison.OrdinalIgnoreCase) && !type.Contains("discover", StringComparison.OrdinalIgnoreCase) && !type.Contains("belief", StringComparison.OrdinalIgnoreCase) && !type.Contains("suspect", StringComparison.OrdinalIgnoreCase)) { return false; } return IsCharacterEntity(observation.SubjectEntityType) && !string.IsNullOrWhiteSpace(observation.SubjectName) && !string.IsNullOrWhiteSpace(FirstConfigured(observation.Description, observation.Predicate, observation.ObjectName)); } private static string KnowledgeStateFromSignal(string? changeType, string? statement, IReadOnlyList states) { var value = NormaliseForMatching($"{changeType} {statement}"); var preferred = ContainsAny(value, "unaware", "does not know", "doesn't know", "not know", "forgets", "forgot", "stops believing") ? "Unaware" : ContainsAny(value, "suspects", "suspect", "suspicion") ? "Suspects" : ContainsAny(value, "misunderstands", "misunderstand", "mistaken", "false belief", "wrongly believes") ? "Misunderstands" : ContainsAny(value, "believes", "belief", "partially", "may know") ? "Partially Knows" : ContainsAny(value, "learns", "discovers", "confirms", "realises", "realizes", "is told", "knows", "reveals", "revealed") ? "Knows" : "Knows"; return states.FirstOrDefault(state => string.Equals(state.StateName, preferred, StringComparison.OrdinalIgnoreCase))?.StateName ?? states.FirstOrDefault(state => string.Equals(state.StateName, "Knows", StringComparison.OrdinalIgnoreCase))?.StateName ?? states.FirstOrDefault()?.StateName ?? "Knows"; } private static int ResolveKnowledgeStateID(IReadOnlyList states, int? selectedKnowledgeStateId, string? suggestedState) => selectedKnowledgeStateId.HasValue && states.Any(state => state.KnowledgeStateID == selectedKnowledgeStateId.Value) ? selectedKnowledgeStateId.Value : states.FirstOrDefault(state => string.Equals(state.StateName, suggestedState, StringComparison.OrdinalIgnoreCase))?.KnowledgeStateID ?? states.FirstOrDefault(state => string.Equals(state.StateName, "Knows", StringComparison.OrdinalIgnoreCase))?.KnowledgeStateID ?? states.First().KnowledgeStateID; private static void SaveKnowledgeSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable choices) { foreach (var choice in choices) { if (string.IsNullOrWhiteSpace(choice.Key) || choice.KnowledgeStateID is not { } knowledgeStateId) { continue; } batch.KnowledgeSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase)); batch.KnowledgeSelections.Add(new OnboardingStoryIntelligenceKnowledgeSelection { Key = choice.Key, KnowledgeStateID = knowledgeStateId }); } } private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? knowledgeId, string? label, bool createdOrLinked) { batch.KnowledgeDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase)); batch.KnowledgeDecisions.Add(new OnboardingStoryIntelligenceKnowledgeDecision { Key = key, Action = action, CharacterKnowledgeID = knowledgeId, KnowledgeLabel = label, CreatedOrLinked = createdOrLinked }); } private static string BuildKnowledgeDescription(KnowledgeCandidate candidate, KnowledgeAppearanceImport appearance) { var lines = new List { candidate.KnowledgeStatement }; if (!string.IsNullOrWhiteSpace(appearance.ChangeType)) { lines.Add($"Change: {Clean(appearance.ChangeType)}."); } if (!string.IsNullOrWhiteSpace(appearance.Evidence)) { lines.Add($"Evidence: {Clean(appearance.Evidence)}"); } lines.Add("Imported from Story Intelligence knowledge review."); return string.Join(Environment.NewLine, lines); } private static string KnowledgeLabel(KnowledgeCandidate candidate) => $"{candidate.CharacterName}: {candidate.KnowledgeStatement}"; private static string ExistingKnowledgeLabel(CharacterKnowledgeItem item) => $"{item.CharacterName}: {item.KnowledgeStateName} - {KnowledgeTitle(item)}"; private static string KnowledgeTitle(CharacterKnowledgeItem item) => CleanKnowledgeStatement(item.Description); private static string CleanKnowledgeStatement(string? value) { var cleaned = Clean(value); if (string.IsNullOrWhiteSpace(cleaned)) { return string.Empty; } foreach (var prefix in new[] { "Knowledge:", "Evidence:", "Change:" }) { if (cleaned.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { cleaned = cleaned[prefix.Length..].Trim(); } } var firstLine = cleaned.Split('\n', '\r').Select(line => line.Trim()).FirstOrDefault(line => !string.IsNullOrWhiteSpace(line)); return firstLine ?? cleaned; } private static string KnowledgeKey(int characterId, string statement) => $"{characterId}:{CanonicalKnowledgeKey(statement)}"; private static string ExistingKnowledgeKey(int characterId, string statement) => KnowledgeKey(characterId, statement); private static string CanonicalKnowledgeKey(string? value) { var normalised = NormaliseForMatching(value); var possessiveFamily = Regex.Match( normalised, @"\b(?\w+)\s+(?:is|was)\s+(?\w+)\s+s\s+(?:biological\s+)?(?father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b", RegexOptions.IgnoreCase); if (possessiveFamily.Success) { return $"{possessiveFamily.Groups["relation"].Value} {possessiveFamily.Groups["subject"].Value} {possessiveFamily.Groups["holder"].Value}"; } var pronounFamily = Regex.Match( normalised, @"\b(?\w+)\s+.*?\b(?\w+)\s+(?:is|was)\s+(?:her|his|their)\s+(?:biological\s+)?(?father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b", RegexOptions.IgnoreCase); if (pronounFamily.Success) { return $"{pronounFamily.Groups["relation"].Value} {pronounFamily.Groups["subject"].Value} {pronounFamily.Groups["holder"].Value}"; } normalised = Regex.Replace(normalised, @"\b(discovers?|learns?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is told|that|the|a|an|his|her|their|its|about)\b", " ", RegexOptions.IgnoreCase); return Whitespace.Replace(normalised, " ").Trim(); } 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 bool IsCharacterEntity(string? entityType) => string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase); private static string ConfidenceBand(decimal? confidence) => confidence switch { >= 0.75m => "High", >= 0.45m => "Medium", null => "Unknown", _ => "Low" }; private static decimal? Max(decimal? current, decimal? next) => current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next; private static string? JoinConfigured(params string?[] values) { var configured = values.Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(); return configured.Count == 0 ? null : string.Join(" ", configured); } 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 sealed record KnowledgeCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList Candidates); private sealed class KnowledgeCandidate( string key, int characterId, string characterName, string knowledgeStatement, string knowledgeStateName, int? storyAssetId, int? existingKnowledgeId, string? existingKnowledgeLabel) { public string Key { get; } = key; public int CharacterID { get; } = characterId; public string CharacterName { get; } = characterName; public string KnowledgeStatement { get; } = knowledgeStatement; public string KnowledgeStateName { get; set; } = knowledgeStateName; public int? StoryAssetID { get; } = storyAssetId; public int? ExistingKnowledgeID { get; } = existingKnowledgeId; public string? ExistingKnowledgeLabel { get; } = existingKnowledgeLabel; public decimal? Confidence { get; set; } public string? FirstScene { get; set; } public string? ExampleScene { get; set; } public string? Evidence { get; set; } public List Appearances { get; } = []; } private sealed record KnowledgeAppearanceImport( int SceneID, decimal SceneNumber, string SceneTitle, string? ChangeType, string? Evidence, decimal? Confidence); 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 class AssetIndex { private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); public void Add(string? name, int storyAssetId, string assetName) { var key = Clean(name); if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key)) { return; } byName[key] = new AssetMatch(storyAssetId, assetName); } public AssetMatch? Find(string? statement) { var value = NormaliseForMatching(statement); return byName.Values.FirstOrDefault(asset => value.Contains(NormaliseForMatching(asset.AssetName), StringComparison.Ordinal)); } } private sealed record CharacterMatch(int CharacterID, string CharacterName); private sealed record AssetMatch(int StoryAssetID, string AssetName); }