using System.Text.Json; using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface ISceneImportResolver { Task ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList sceneResults); } public sealed class SceneImportResolver( ICharacterRepository characters, ILocationRepository locations, IAssetRepository assets, ILogger logger) : ISceneImportResolver { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; private static readonly HashSet GenericPeople = CreateSet( "the receptionist", "receptionist", "old man", "young woman", "police officer", "doctor", "nurse", "driver", "man", "woman", "women", "boy", "girl", "child", "children", "person", "people", "guard", "security guard", "cashier", "elderly attendant", "attendant", "driver of car behind", "shop assistant", "crowd", "family", "group of women", "small group", "women (small group)", "waiter", "waitress", "clerk", "shopkeeper", "elderly cashier"); private static readonly HashSet IgnoredPovNames = CreateSet("narrator", "unknown"); private static readonly HashSet ParentReferences = CreateSet( "mum", "mother", "mam", "mom", "mummy", "mommy", "ma"); private static readonly HashSet GenericLocations = CreateSet( "car park", "house", "kitchen", "stairwell", "stair", "doorway", "corridor", "room", "bedroom", "bathroom", "office", "reception", "sixth floor", "floor", "car", "hall", "table", "chair", "altar", "building lobby", "church", "garage", "ground floor lobby", "hallway", "kitchen/dining area", "lobby", "multistorey car park", "pavement", "rear pew", "stairwell/landing", "street", "street corner", "front door", "parked car", "back street"); private static readonly HashSet EnvironmentalAssets = CreateSet( "pink towel", "mug of tea", "magazine", "fire door", "old oak door", "iron handle", "reception desk", "stained-glass windows", "stained glass windows", "chair", "desk", "window", "windows", "cup", "floor", "wall", "door", "table", "towel", "mug", "handle", "telephone", "phone", "keys", "key"); private static readonly HashSet StoryAssetNames = CreateSet( "passport", "letter", "photograph", "photo", "bank book", "bank account", "necklace", "weapon", "folder", "document", "diary", "telephone number", "phone number", "scrap of paper", "wallet"); private static readonly string[] ParentheticalLocationSuffixes = [ "reception", "sixth floor", "sixth floor reception", "sixth-floor reception", "office", "lobby", "corridor", "stairwell" ]; public async Task ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList sceneResults) { if (run.ProjectID is not int projectId) { return new StoryIntelligenceImportPreview { Warnings = ["Import Preview requires a project id to resolve existing PlotDirector entities."] }; } var scenes = sceneResults .Where(scene => scene.ValidationErrorsCount == 0) .Select(TryReadScene) .Where(scene => scene is not null) .Cast() .ToList(); var characterIndex = await BuildCharacterIndexAsync(projectId); var locationIndex = await BuildLocationIndexAsync(projectId); var assetIndex = await BuildAssetIndexAsync(projectId); var existingRelationships = await characters.ListRelationshipsByProjectAsync(projectId); var characterResolutions = ResolveCharacters(scenes, characterIndex).ToList(); var locationResolutions = ResolveLocations(scenes, locationIndex).ToList(); var assetResolutions = ResolveAssets(scenes, assetIndex).ToList(); var relationshipPreview = ResolveRelationships(scenes, existingRelationships, characterResolutions).ToList(); var knowledgePreview = ResolveKnowledge(scenes, characterResolutions, includeResolved: true).ToList(); var unresolvedKnowledgePreview = ResolveKnowledge(scenes, characterResolutions, includeResolved: false).ToList(); var timelinePreview = ResolveTimeline(scenes).ToList(); var warnings = BuildWarnings(characterResolutions, locationResolutions, assetResolutions).ToList(); return new StoryIntelligenceImportPreview { Characters = characterResolutions, Locations = locationResolutions, Assets = assetResolutions, Relationships = relationshipPreview, Knowledge = knowledgePreview, UnresolvedKnowledge = unresolvedKnowledgePreview, Timeline = timelinePreview, Warnings = warnings }; } private IEnumerable ResolveCharacters(IEnumerable scenes, EntityIndex index) { var mentions = scenes .SelectMany(scene => (scene.Characters ?? []).Select(character => new CharacterMention( character.Name, character.Confidence, character.Notes, SceneNumber(scene), string.Join(" ", (character.Actions ?? []).Where(action => !string.IsNullOrWhiteSpace(action)))))) .Concat(scenes.Select(scene => new CharacterMention( scene.PointOfView?.CharacterName, scene.PointOfView?.Confidence, "Point of view character.", SceneNumber(scene), string.Empty))) .Where(item => !string.IsNullOrWhiteSpace(item.Name)) .GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase) .Select(group => new CharacterMentionGroup( group.Key, group.Max(item => item.Confidence), string.Join(" ", group.Select(item => item.Notes).Where(note => !string.IsNullOrWhiteSpace(note))), group.Select(item => item.SceneNumber).Where(number => number.HasValue).Select(number => number!.Value).Distinct().Order().ToList(), string.Join(" ", group.Select(item => item.Context).Where(context => !string.IsNullOrWhiteSpace(context))))) .ToList(); var resolutions = new List(); foreach (var item in mentions) { var name = Clean(item.Name); if (IgnoredPovNames.Contains(name)) { resolutions.Add(LogResolution(new StoryImportEntityResolution { InputName = name, EntityType = "Character", Status = StoryImportResolutionStatuses.Ignored, Confidence = item.Confidence, Notes = "Narrator identity is intentionally left for later chapter metadata resolution." })); continue; } if (IsGenericPerson(name)) { resolutions.Add(LogResolution(new StoryImportEntityResolution { InputName = name, EntityType = "Character", Status = StoryImportResolutionStatuses.UnresolvedPersonReference, Confidence = item.Confidence, Notes = IsCompoundGenericPerson(name) ? "Rejected compound generic person: all phrase parts are generic person roles." : "Rejected: generic unnamed role; would not create a Character record." })); continue; } var resolution = MatchEntity("Character", name, item.Confidence, index, StoryImportResolutionStatuses.NewCharacter); if (resolution.Status == StoryImportResolutionStatuses.NewCharacter && IsParentReference(name)) { var candidates = index.FindParentCandidates(); if (candidates.Count == 1) { resolution = WithStatusAndNotes(resolution, StoryImportResolutionStatuses.PossibleMatch, "Family-role reference may match an existing parent/mother figure; not resolved automatically."); resolution = WithCandidates(resolution, candidates); } else if (candidates.Count > 1) { resolution = WithStatusAndNotes(resolution, StoryImportResolutionStatuses.Ambiguous, "Family-role reference may match more than one existing parent/mother figure; not resolved automatically."); resolution = WithCandidates(resolution, candidates); } } resolutions.Add(LogResolution(resolution)); } foreach (var resolution in AddAliasCandidates(resolutions, mentions)) { yield return resolution; } } private IEnumerable ResolveLocations(IEnumerable scenes, EntityIndex index) { foreach (var group in scenes .SelectMany(scene => (scene.Locations ?? []).SelectMany(ExpandLocationMention)) .Where(location => !string.IsNullOrWhiteSpace(location.Name)) .GroupBy(location => NormaliseLocationName(location.Name), StringComparer.OrdinalIgnoreCase)) { var item = group.First(); var name = Clean(item.Name); if (IsGenericLocation(item)) { yield return LogResolution(new StoryImportEntityResolution { InputName = name, EntityType = "Location", Status = StoryImportResolutionStatuses.GenericReference, Confidence = item.Confidence, Notes = "Internal/generic location note; would not create a separate Location record." }); continue; } var resolution = MatchEntity("Location", group.Key, item.Confidence, index, StoryImportResolutionStatuses.NewLocation); var variants = group.Select(location => Clean(location.Name)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var internalAreas = group.Select(location => InternalLocationQualifier(location.Name)).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var routeSources = group.Select(location => location.Notes).Where(note => !string.IsNullOrWhiteSpace(note) && note.Contains("Grouped road reference", StringComparison.OrdinalIgnoreCase)).Distinct().ToList(); if (!string.Equals(group.Key, name, StringComparison.OrdinalIgnoreCase) || variants.Count > 1 || internalAreas.Count > 0 || routeSources.Count > 0) { var notes = new List(); if (variants.Count > 1 || !string.Equals(group.Key, name, StringComparison.OrdinalIgnoreCase)) { notes.Add($"Folded location variant(s): {string.Join(", ", variants)}."); } if (internalAreas.Count > 0) { notes.Add($"Internal area note(s): {string.Join(", ", internalAreas)}."); } if (routeSources.Count > 0) { notes.Add(string.Join(" ", routeSources)); } resolution = WithNotes(resolution, string.Join(" ", notes)); logger.LogInformation("Folded location variant: {InputName} -> {NormalisedName}. Reason={Reason}", name, group.Key, "Parenthetical/internal qualifier or grouped route reference."); } if (resolution.Status == StoryImportResolutionStatuses.NewLocation && IsNamedRoad(group.Key)) { resolution = WithNotes(resolution, AppendNote(resolution.Notes, "Accepted named road: proper road/street name.")); logger.LogInformation("Accepted named road: {InputName}. Reason={Reason}", group.Key, "Proper road/street name."); } yield return LogResolution(resolution); } } private IEnumerable ResolveAssets(IEnumerable scenes, EntityIndex index) { foreach (var item in scenes .SelectMany(scene => scene.Assets ?? []) .Where(asset => !string.IsNullOrWhiteSpace(asset.Name)) .GroupBy(asset => NormaliseAssetName(asset.Name), StringComparer.OrdinalIgnoreCase) .Select(group => group.First())) { var name = NormaliseAssetName(item.Name); if (IsRejectedAsset(item)) { logger.LogInformation("Rejected generic asset: {InputName}. Reason={Reason}", name, "Generic object without owner/name/significance."); yield return LogResolution(new StoryImportEntityResolution { InputName = name, EntityType = "Asset", Status = StoryImportResolutionStatuses.Ignored, Confidence = item.Confidence, Notes = "Rejected: environmental object or low-value prop; would not create an Asset record." }); continue; } logger.LogInformation("Accepted story asset: {InputName}. Reason={Reason}", name, "Specific owner/name/type/significance."); yield return LogResolution(MatchEntity("Asset", name, item.Confidence, index, StoryImportResolutionStatuses.NewAsset)); } } private static IEnumerable ResolveRelationships( IEnumerable scenes, IReadOnlyList existingRelationships, IReadOnlyList characterResolutions) { var resolvedCharacters = BuildResolvedCharacterNameMap(characterResolutions, IsRelationshipEligibleCharacter); var grouped = scenes .SelectMany(scene => (scene.Relationships ?? []).Select(relationship => new { Relationship = relationship, SceneNumber = scene.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(scene.SceneReference.SceneNumber.Value) : (int?)null })) .Where(item => !string.IsNullOrWhiteSpace(item.Relationship.CharacterA) && !string.IsNullOrWhiteSpace(item.Relationship.CharacterB) && resolvedCharacters.ContainsKey(Clean(item.Relationship.CharacterA)) && resolvedCharacters.ContainsKey(Clean(item.Relationship.CharacterB))) .GroupBy(item => RelationshipKey(resolvedCharacters[Clean(item.Relationship.CharacterA!)], resolvedCharacters[Clean(item.Relationship.CharacterB!)], item.Relationship.RelationshipSignal)); foreach (var group in grouped) { var first = group.First().Relationship; var characterA = resolvedCharacters[Clean(first.CharacterA)]; var characterB = resolvedCharacters[Clean(first.CharacterB)]; var exists = existingRelationships.Any(existing => NamesMatch(existing.CharacterAName, characterA) && NamesMatch(existing.CharacterBName, characterB) || NamesMatch(existing.CharacterAName, characterB) && NamesMatch(existing.CharacterBName, characterA)); yield return new StoryImportRelationshipPreview { Action = exists ? "Would Update Relationship" : "Would Create Relationship", CharacterA = characterA, CharacterB = characterB, Signal = first.RelationshipSignal ?? "relationship signal", Confidence = group.Max(item => item.Relationship.Confidence), SupportingSceneNumbers = group.Select(item => item.SceneNumber).Where(number => number.HasValue).Select(number => number!.Value).Distinct().Order().ToList() }; } } private static IEnumerable ResolveKnowledge( IEnumerable scenes, IReadOnlyList characterResolutions, bool includeResolved) { var characterStatus = BuildResolvedCharacterStatusMap(characterResolutions); foreach (var change in scenes.SelectMany(scene => scene.KnowledgeChanges ?? []) .Where(change => !string.IsNullOrWhiteSpace(change.RecipientCharacter) && !string.IsNullOrWhiteSpace(change.KnowledgeItem))) { var recipient = Clean(change.RecipientCharacter); var isResolved = characterStatus.TryGetValue(recipient, out var resolution) && IsKnowledgeEligibleCharacter(resolution); if (IgnoredPovNames.Contains(recipient)) { isResolved = false; } if (isResolved != includeResolved) { continue; } yield return new StoryImportKnowledgePreview { Action = includeResolved ? "Would Update Knowledge" : "Unresolved Knowledge Reference", Character = isResolved && resolution is not null ? RelationshipDisplayName(resolution) : recipient, KnowledgeItem = change.KnowledgeItem!, OldState = "Unknown", NewState = string.IsNullOrWhiteSpace(change.ChangeType) ? "Would update" : change.ChangeType!, Confidence = change.Confidence }; } } private static IEnumerable ResolveTimeline(IEnumerable scenes) => scenes.SelectMany(scene => (scene.TimelineClues ?? []).Select(clue => new StoryImportTimelinePreview { SceneNumber = scene.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(scene.SceneReference.SceneNumber.Value) : null, SceneDate = !string.IsNullOrWhiteSpace(clue.AbsoluteDate) ? clue.AbsoluteDate! : clue.Clue ?? "Timeline clue", Confidence = clue.Confidence })); private StoryImportEntityResolution MatchEntity(string entityType, string inputName, decimal? confidence, EntityIndex index, string newStatus) { var candidates = index.Find(inputName); return candidates.Count switch { 0 => new StoryImportEntityResolution { InputName = inputName, EntityType = entityType, Status = newStatus, Confidence = confidence }, 1 => new StoryImportEntityResolution { InputName = inputName, EntityType = entityType, Status = StoryImportResolutionStatuses.Matched, MatchedID = candidates[0].EntityID, MatchedName = candidates[0].Name, Confidence = confidence, Candidates = candidates }, _ => new StoryImportEntityResolution { InputName = inputName, EntityType = entityType, Status = StoryImportResolutionStatuses.Ambiguous, Confidence = confidence, Candidates = candidates } }; } private static StoryImportEntityResolution WithNotes(StoryImportEntityResolution resolution, string notes) => WithStatusAndNotes(resolution, resolution.Status, notes); private static StoryImportEntityResolution WithStatusAndNotes(StoryImportEntityResolution resolution, string status, string notes) => new() { InputName = resolution.InputName, Status = status, EntityType = resolution.EntityType, MatchedName = resolution.MatchedName, MatchedID = resolution.MatchedID, Confidence = resolution.Confidence, Candidates = resolution.Candidates, Notes = notes }; private static StoryImportEntityResolution WithCandidates(StoryImportEntityResolution resolution, IReadOnlyList candidates) => new() { InputName = resolution.InputName, Status = resolution.Status, EntityType = resolution.EntityType, MatchedName = resolution.MatchedName, MatchedID = resolution.MatchedID, Confidence = resolution.Confidence, Candidates = candidates, Notes = resolution.Notes }; private IEnumerable AddAliasCandidates( IReadOnlyList resolutions, IReadOnlyList mentions) { var hiddenAliasNames = new HashSet(StringComparer.OrdinalIgnoreCase); var aliasCandidates = new List(); var byName = mentions.ToDictionary(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase); foreach (var pair in BuildAliasPairs(resolutions, byName)) { hiddenAliasNames.Add(pair.First.InputName); hiddenAliasNames.Add(pair.Second.InputName); var inputName = $"{pair.First.InputName} / {pair.Second.InputName}"; var confidence = new[] { pair.First.Confidence, pair.Second.Confidence }.Where(value => value.HasValue).DefaultIfEmpty().Max(); aliasCandidates.Add(LogResolution(new StoryImportEntityResolution { InputName = inputName, EntityType = "Character", Status = StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate, Confidence = confidence, Notes = "Possible alias candidate: both names appear in the same conversation and source notes suggest alternate naming." })); logger.LogInformation("Possible alias candidate: {InputName}. Reason={Reason}", inputName, "Both names appear in same conversation and source notes suggest alternate naming."); } foreach (var resolution in resolutions.Where(resolution => !hiddenAliasNames.Contains(resolution.InputName))) { yield return resolution; } foreach (var candidate in aliasCandidates) { yield return candidate; } } private static IEnumerable<(StoryImportEntityResolution First, StoryImportEntityResolution Second)> BuildAliasPairs( IReadOnlyList resolutions, IReadOnlyDictionary mentionsByName) { var newCharacters = resolutions .Where(item => item.Status == StoryImportResolutionStatuses.NewCharacter) .ToList(); for (var i = 0; i < newCharacters.Count; i++) { for (var j = i + 1; j < newCharacters.Count; j++) { var first = newCharacters[i]; var second = newCharacters[j]; if (!mentionsByName.TryGetValue(Clean(first.InputName), out var firstMention) || !mentionsByName.TryGetValue(Clean(second.InputName), out var secondMention)) { continue; } if (IsAliasCandidate(first.InputName, second.InputName, firstMention, secondMention)) { yield return (first, second); } } } } private async Task BuildCharacterIndexAsync(int projectId) { var rows = new List(); foreach (var character in await characters.ListCharactersAsync(projectId)) { rows.Add(new EntityIndexRow(character.CharacterID, character.CharacterName, character.CharacterName, "Name")); if (!string.IsNullOrWhiteSpace(character.ShortName)) { rows.Add(new EntityIndexRow(character.CharacterID, character.CharacterName, character.ShortName!, "Display name")); } foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) { rows.Add(new EntityIndexRow(character.CharacterID, character.CharacterName, alias.Alias, "Alias")); } } return new EntityIndex(rows); } private async Task BuildLocationIndexAsync(int projectId) { var rows = new List(); foreach (var location in await locations.ListByProjectAsync(projectId)) { rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, location.LocationName, "Name")); var normalised = NormaliseLocationName(location.LocationName); if (!string.Equals(normalised, location.LocationName, StringComparison.OrdinalIgnoreCase)) { rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, normalised, "Normalised name")); } foreach (var alias in await locations.ListAliasesAsync(location.LocationID)) { rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, alias.Alias, "Alias")); var normalisedAlias = NormaliseLocationName(alias.Alias); if (!string.Equals(normalisedAlias, alias.Alias, StringComparison.OrdinalIgnoreCase)) { rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, normalisedAlias, "Normalised alias")); } } } return new EntityIndex(rows); } private async Task BuildAssetIndexAsync(int projectId) { var rows = new List(); foreach (var asset in await assets.ListAssetsAsync(projectId)) { rows.Add(new EntityIndexRow(asset.StoryAssetID, asset.AssetName, asset.AssetName, "Name")); foreach (var alias in await assets.ListAliasesAsync(asset.StoryAssetID)) { rows.Add(new EntityIndexRow(asset.StoryAssetID, asset.AssetName, alias.Alias, "Alias")); } } return new EntityIndex(rows); } private StoryImportEntityResolution LogResolution(StoryImportEntityResolution resolution) { logger.LogInformation( "Story Intelligence Import Preview resolved {EntityType}. Input={InputName} Status={Status} Resolved={ResolvedName} Confidence={ConfidencePercent} Candidates={Candidates} Notes={Notes}", resolution.EntityType, resolution.InputName, resolution.Status, resolution.MatchedName, resolution.ConfidencePercent, string.Join(", ", resolution.Candidates.Select(candidate => candidate.Name)), resolution.Notes); return resolution; } private static IEnumerable BuildWarnings( IReadOnlyList characterResolutions, IReadOnlyList locationResolutions, IReadOnlyList assetResolutions) { var unresolved = characterResolutions.Concat(locationResolutions).Concat(assetResolutions) .Count(item => item.Status is StoryImportResolutionStatuses.Ambiguous or StoryImportResolutionStatuses.UnresolvedPersonReference); yield return unresolved == 0 ? "No unresolved entities." : $"{unresolved:N0} unresolved or ambiguous reference(s)."; } 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 IsGenericLocation(SceneIntelligenceLocation location) { var name = Clean(location.Name); var normalised = NormaliseLocationName(name); return !IsNamedRoad(name) && !IsNamedPlace(name) && (GenericLocations.Contains(name.ToLowerInvariant()) || IsGenericCompoundLocation(name) || IsCarLocationReference(name) || string.Equals(normalised, name, StringComparison.OrdinalIgnoreCase) && GenericLocations.Contains(RemovePossessiveOwner(name).ToLowerInvariant())); } private static bool IsGenericPerson(string name) { var clean = Clean(name).ToLowerInvariant(); return GenericPeople.Contains(clean) || clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase) && GenericPeople.Contains(clean[4..]) || IsCompoundGenericPerson(clean) || clean.Contains(" group", StringComparison.OrdinalIgnoreCase) || clean.Contains("crowd", StringComparison.OrdinalIgnoreCase); } private static bool IsCompoundGenericPerson(string name) { var clean = StripParenthetical(Clean(name)).ToLowerInvariant(); var parts = Regex.Split(clean, @"\s*(/|,|\band\b|\bor\b)\s*") .Where(part => !string.IsNullOrWhiteSpace(part) && part is not "/" and not "," and not "and" and not "or") .Select(part => RemoveLeadingArticle(part.Trim())) .ToList(); return parts.Count > 1 && parts.All(part => GenericPeople.Contains(part)); } private static bool IsParentReference(string name) => ParentReferences.Contains(RemoveLeadingArticle(Clean(name)).ToLowerInvariant()); private static bool IsAliasCandidate(string firstName, string secondName, CharacterMentionGroup first, CharacterMentionGroup second) { var combined = $"{firstName} {secondName} {first.Notes} {first.Context} {second.Notes} {second.Context}"; var isSusieDebbie = NamesMatch(firstName, "Susie") && NamesMatch(secondName, "Debbie") || NamesMatch(firstName, "Debbie") && NamesMatch(secondName, "Susie"); var hasSusieDebbieEvidence = isSusieDebbie && (combined.Contains("I use Susie", StringComparison.OrdinalIgnoreCase) || Regex.IsMatch(combined, @"\bDebbie\s*,?\s+or\s+Susie\b|\bSusie\s*,?\s+or\s+Debbie\b", RegexOptions.IgnoreCase) || Regex.IsMatch(combined, @"\b(Debbie|Susie)\b.{0,80}\b(alias|real name|also known as|called herself|uses the name|used the name|she said her name was)\b.{0,80}\b(Debbie|Susie)\b", RegexOptions.IgnoreCase)); var hasExplicitNameLink = Regex.IsMatch(combined, $@"\b{Regex.Escape(firstName)}\b.{{0,80}}\b(alias|real name|also known as|called herself|uses the name|used the name|she said her name was)\b.{{0,80}}\b{Regex.Escape(secondName)}\b", RegexOptions.IgnoreCase) || Regex.IsMatch(combined, $@"\b{Regex.Escape(secondName)}\b.{{0,80}}\b(alias|real name|also known as|called herself|uses the name|used the name|she said her name was)\b.{{0,80}}\b{Regex.Escape(firstName)}\b", RegexOptions.IgnoreCase); return hasSusieDebbieEvidence || hasExplicitNameLink; } private static bool IsRejectedAsset(SceneIntelligenceAsset asset) { var name = NormaliseAssetName(asset.Name); var lower = name.ToLowerInvariant(); if (IsAcceptedStoryAsset(asset)) { return false; } if (EnvironmentalAssets.Contains(lower) || EnvironmentalAssets.Contains(RemoveLeadingArticle(lower))) { return true; } if (StoryAssetNames.Contains(RemoveLeadingArticle(lower))) { return false; } var signalText = $"{asset.Status} {asset.OwnerOrHolder} {asset.Notes} {asset.AssetType}".ToLowerInvariant(); return !ContainsStoryAssetSignal(signalText); } private static bool IsAcceptedStoryAsset(SceneIntelligenceAsset asset) { var name = NormaliseAssetName(asset.Name); var lower = name.ToLowerInvariant(); var signalText = $"{name} {asset.Status} {asset.OwnerOrHolder} {asset.Notes} {asset.AssetType}".ToLowerInvariant(); if ((lower.Contains("'s car", StringComparison.OrdinalIgnoreCase) || lower.Contains("porsche", StringComparison.OrdinalIgnoreCase)) && !string.Equals(RemoveLeadingArticle(lower), "car", StringComparison.OrdinalIgnoreCase)) { return true; } if (lower.Contains("bank account", StringComparison.OrdinalIgnoreCase) && (HasParentheticalQualifier(name) || !string.IsNullOrWhiteSpace(asset.OwnerOrHolder))) { return true; } if (lower.Contains("shop keys", StringComparison.OrdinalIgnoreCase) || lower.Contains("telephone number", StringComparison.OrdinalIgnoreCase) || lower.Contains("phone number", StringComparison.OrdinalIgnoreCase) || lower.Contains("contact detail", StringComparison.OrdinalIgnoreCase) || lower.Contains("scrap of paper", StringComparison.OrdinalIgnoreCase) && lower.Contains("number", StringComparison.OrdinalIgnoreCase)) { return true; } if (lower.Contains("record", StringComparison.OrdinalIgnoreCase) && (signalText.Contains("evidence", StringComparison.OrdinalIgnoreCase) || signalText.Contains("official", StringComparison.OrdinalIgnoreCase))) { return true; } if (lower.Contains("parking ticket", StringComparison.OrdinalIgnoreCase) && ContainsStoryAssetSignal(signalText)) { return true; } if (lower.Contains("key", StringComparison.OrdinalIgnoreCase) && !string.Equals(RemoveLeadingArticle(lower), "key", StringComparison.OrdinalIgnoreCase) && !string.Equals(RemoveLeadingArticle(lower), "keys", StringComparison.OrdinalIgnoreCase)) { return true; } return false; } private static bool ContainsStoryAssetSignal(string value) => value.Contains("sought", StringComparison.OrdinalIgnoreCase) || value.Contains("lost", StringComparison.OrdinalIgnoreCase) || value.Contains("hidden", StringComparison.OrdinalIgnoreCase) || value.Contains("stolen", StringComparison.OrdinalIgnoreCase) || value.Contains("exchanged", StringComparison.OrdinalIgnoreCase) || value.Contains("missing", StringComparison.OrdinalIgnoreCase) || value.Contains("important", StringComparison.OrdinalIgnoreCase) || value.Contains("document", StringComparison.OrdinalIgnoreCase) || value.Contains("evidence", StringComparison.OrdinalIgnoreCase); private static string NormaliseLocationName(string? value) { var clean = RemoveLeadingArticle(Clean(value)); var parenthetical = InternalLocationQualifier(clean); if (!string.IsNullOrWhiteSpace(parenthetical)) { clean = StripParenthetical(clean); } var lower = clean.ToLowerInvariant(); var suffixes = new[] { " sixth floor reception", " sixth-floor reception", " reception", " sixth floor", " office", " corridor", " stairwell" }; foreach (var suffix in suffixes) { if (lower.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) && clean.Length > suffix.Length) { return clean[..^suffix.Length].Trim(); } } return clean; } private static IEnumerable ExpandLocationMention(SceneIntelligenceLocation location) { var name = Clean(location.Name); if (!name.Contains('/')) { yield return location; yield break; } var parenthetical = InternalLocationQualifier(name); var baseName = StripParenthetical(name); var parts = baseName.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(Clean) .Where(part => !string.IsNullOrWhiteSpace(part)) .ToList(); if (parts.Count > 1 && parts.All(IsNamedRoad)) { foreach (var part in parts) { yield return new SceneIntelligenceLocation { Name = part, LocationType = location.LocationType, GenericRoomType = location.GenericRoomType, ParentLocationHint = location.ParentLocationHint, PresentInScene = location.PresentInScene, MentionedOnly = location.MentionedOnly, Confidence = location.Confidence, Notes = $"Grouped road reference from {name}{(string.IsNullOrWhiteSpace(parenthetical) ? string.Empty : $" ({parenthetical})")}." }; } yield break; } yield return location; } private static bool IsNamedRoad(string? value) { var clean = StripParenthetical(RemoveLeadingArticle(Clean(value))); if (string.IsNullOrWhiteSpace(clean) || clean.Contains('/')) { return false; } return Regex.IsMatch(clean, @"\b(Street|Road|Lane|Avenue|Drive|Place|Square|Way|Close|Court|Terrace|Crescent|Queensway|High Street|New Street)\b$", RegexOptions.IgnoreCase); } private static bool IsNamedPlace(string? value) { var clean = StripParenthetical(RemoveLeadingArticle(Clean(value))); if (string.IsNullOrWhiteSpace(clean)) { return false; } return Regex.IsMatch(clean, @"^\d+\s+\S+", RegexOptions.IgnoreCase) || clean.Contains("Ashdown Trust", StringComparison.OrdinalIgnoreCase) || clean.Contains("Sleepy Joe", StringComparison.OrdinalIgnoreCase) || Regex.IsMatch(clean, @"\bSt\.?\s+\S+|\bSaint\s+\S+", RegexOptions.IgnoreCase); } private static bool IsGenericCompoundLocation(string? value) { var clean = StripParenthetical(RemoveLeadingArticle(Clean(value))).Replace('’', '\'').ToLowerInvariant(); if (!clean.Contains('/')) { return false; } var parts = clean.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(RemoveLeadingArticle) .ToList(); return parts.Count > 1 && parts.All(part => GenericLocations.Contains(part)); } private static bool IsCarLocationReference(string? value) { var clean = StripParenthetical(RemoveLeadingArticle(Clean(value))).ToLowerInvariant(); return clean == "car" || clean.EndsWith(" car", StringComparison.OrdinalIgnoreCase) || clean.Contains("'s car", StringComparison.OrdinalIgnoreCase) || clean.Contains("parked car", StringComparison.OrdinalIgnoreCase); } private static string RemovePossessiveOwner(string? value) { var clean = StripParenthetical(RemoveLeadingArticle(Clean(value))).Replace('’', '\''); var possessive = clean.IndexOf("'s ", StringComparison.OrdinalIgnoreCase); return possessive >= 0 ? clean[(possessive + 3)..].Trim() : clean; } private static string InternalLocationQualifier(string? value) { var clean = Clean(value); var open = clean.IndexOf('('); var close = clean.LastIndexOf(')'); if (open >= 0 && close > open) { var qualifier = Clean(clean[(open + 1)..close]); return qualifier; } var lower = clean.ToLowerInvariant(); foreach (var suffix in ParentheticalLocationSuffixes) { if (lower.EndsWith(" " + suffix, StringComparison.OrdinalIgnoreCase) && clean.Length > suffix.Length + 1) { return suffix; } } return string.Empty; } private static string StripParenthetical(string? value) { var clean = Clean(value); var open = clean.IndexOf('('); return open >= 0 ? Clean(clean[..open]) : clean; } private static string NormaliseAssetName(string? value) => Clean(value).Replace('’', '\'').Replace('`', '\''); private static bool HasParentheticalQualifier(string value) => value.Contains('(') && value.Contains(')'); private static string AppendNote(string? existing, string note) => string.IsNullOrWhiteSpace(existing) ? note : $"{existing} {note}"; private static string Clean(string? value) => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); private static string RemoveLeadingArticle(string value) => value.StartsWith("the ", StringComparison.OrdinalIgnoreCase) ? value[4..].Trim() : value; private static bool NamesMatch(string? first, string? second) => string.Equals(Clean(first), Clean(second), StringComparison.OrdinalIgnoreCase); private static int? SceneNumber(SceneIntelligenceScene scene) => scene.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(scene.SceneReference.SceneNumber.Value) : null; private static bool IsRelationshipEligibleCharacter(StoryImportEntityResolution resolution) => resolution.Status is StoryImportResolutionStatuses.Matched or StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate or StoryImportResolutionStatuses.PossibleMatch; private static bool IsKnowledgeEligibleCharacter(StoryImportEntityResolution resolution) => resolution.Status is StoryImportResolutionStatuses.Matched or StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate or StoryImportResolutionStatuses.PossibleMatch; private static string RelationshipDisplayName(StoryImportEntityResolution resolution) => resolution.Status == StoryImportResolutionStatuses.Matched && !string.IsNullOrWhiteSpace(resolution.MatchedName) ? resolution.MatchedName! : resolution.InputName; private static Dictionary BuildResolvedCharacterNameMap( IReadOnlyList characterResolutions, Func eligible) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var resolution in characterResolutions.Where(eligible)) { AddCharacterMapEntry(map, resolution, RelationshipDisplayName(resolution)); } return map; } private static Dictionary BuildResolvedCharacterStatusMap(IReadOnlyList characterResolutions) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var resolution in characterResolutions) { AddCharacterMapEntry(map, resolution, resolution); } return map; } private static void AddCharacterMapEntry(Dictionary map, StoryImportEntityResolution resolution, T value) { var inputName = Clean(resolution.InputName); if (!string.IsNullOrWhiteSpace(inputName)) { map.TryAdd(inputName, value); } if (resolution.Status != StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate || !inputName.Contains('/')) { return; } foreach (var part in inputName.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { map.TryAdd(Clean(part), value); } } private static string RelationshipKey(string characterA, string characterB, string? signal) { var names = new[] { Clean(characterA), Clean(characterB) }.Order(StringComparer.OrdinalIgnoreCase).ToArray(); return $"{names[0]}|{names[1]}|{Clean(signal).ToLowerInvariant()}"; } private static HashSet CreateSet(params string[] values) => new(values, StringComparer.OrdinalIgnoreCase); private sealed record EntityIndexRow(int EntityID, string EntityName, string MatchValue, string MatchReason); private sealed record CharacterMention(string? Name, decimal? Confidence, string? Notes, int? SceneNumber, string? Context); private sealed record CharacterMentionGroup(string Name, decimal? Confidence, string Notes, IReadOnlyList SceneNumbers, string Context); private sealed class EntityIndex(IReadOnlyList rows) { public IReadOnlyList Find(string value) { var clean = Clean(value); var withoutArticle = RemoveLeadingArticle(clean); var matches = rows .Where(row => string.Equals(Clean(row.MatchValue), clean, StringComparison.Ordinal) || string.Equals(Clean(row.MatchValue), clean, StringComparison.OrdinalIgnoreCase) || string.Equals(RemoveLeadingArticle(Clean(row.MatchValue)), withoutArticle, StringComparison.OrdinalIgnoreCase)) .GroupBy(row => row.EntityID) .Select(group => { var first = group.First(); return new StoryImportResolutionCandidate { EntityID = first.EntityID, Name = first.EntityName, MatchReason = first.MatchReason }; }) .ToList(); return matches; } public IReadOnlyList FindParentCandidates() { var parentMatches = rows .Where(row => { var value = RemoveLeadingArticle(Clean(row.MatchValue)).ToLowerInvariant(); var name = Clean(row.EntityName).ToLowerInvariant(); return ParentReferences.Contains(value) || value.Contains("mother", StringComparison.OrdinalIgnoreCase) || value.Contains("mum", StringComparison.OrdinalIgnoreCase) || value.Contains("mom", StringComparison.OrdinalIgnoreCase) || name.Contains("mother", StringComparison.OrdinalIgnoreCase) || name.Contains("mum", StringComparison.OrdinalIgnoreCase) || name.Contains("mom", StringComparison.OrdinalIgnoreCase); }) .GroupBy(row => row.EntityID) .Select(group => { var first = group.First(); return new StoryImportResolutionCandidate { EntityID = first.EntityID, Name = first.EntityName, MatchReason = first.MatchReason }; }) .ToList(); return parentMatches; } } }