using System.Text.Json; using System.Text.RegularExpressions; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryIntelligenceAssetImportService { Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form); } public sealed class StoryIntelligenceAssetImportService( IStoryIntelligenceResultRepository runs, IAssetRepository assets, ICharacterRepository characters, ISceneRepository scenes, IStoryIntelligencePipelineStateService pipelineState, ILogger logger) : IStoryIntelligenceAssetImportService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; private static readonly HashSet GenericObjects = CreateSet( "chair", "chairs", "table", "tables", "wall", "walls", "floor", "floors", "window", "windows", "door", "doors", "tree", "trees", "grass", "lamp", "lamps", "cup", "cups", "plate", "plates", "knife and fork", "people", "person", "building", "car park", "street", "road", "room", "desk", "bed", "sofa", "sink", "towel", "mug", "glass", "bottle", "phone", "telephone", "key", "keys", "car", "vehicle", "coat", "dress", "shoes", "shirt", "trousers", "weather", "rain", "food", "drink"); private static readonly HashSet AcceptedAssetNames = CreateSet( "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note", "notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring", "ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence", "driving license", "licence", "license", "document", "folder", "wallet", "bank book", "address book", "scrap of paper", "keepsake box", "keepsake tin"); private static readonly HashSet StorySignals = CreateSet( "lost", "found", "hidden", "stolen", "missing", "taken", "given", "carried", "used", "opened", "revealed", "evidence", "clue", "important", "sought", "passport", "licence", "license", "letter", "note", "photograph", "photo", "map", "diary", "notebook", "keys", "necklace", "ring"); private static readonly HashSet AdjectivesToFold = CreateSet( "red", "old", "new", "small", "large", "little", "anonymous", "folded", "sealed", "missing"); private static readonly HashSet RelationshipWords = CreateSet( "mother", "father", "mum", "mom", "dad", "sister", "brother", "parent", "parents", "daughter", "son", "child", "children", "aunt", "uncle", "wife", "husband", "partner", "friend", "neighbour", "neighbor", "teacher", "student", "examiner", "doctor", "nurse"); public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); if (batch.AssetStageComplete || pipeline?.AssetReviewCompletedUtc is not null) { return new StoryIntelligenceAssetReviewViewModel { HasCommittedScenes = true, CanImport = false, IsComplete = true, Candidates = [] }; } var data = await BuildCandidateDataAsync(batch); var decidedKeys = batch.AssetDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); return new StoryIntelligenceAssetReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceAssetReviewCandidateViewModel { Key = candidate.Key, AssetName = candidate.DisplayName, ImportName = candidate.DisplayName, Category = candidate.Category, AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), Confidence = ConfidenceBand(candidate.Confidence), PossibleOwner = candidate.OwnerOrHolder, ExampleFirstAppearance = candidate.FirstAppearanceNote, ExampleContext = candidate.Description, PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(), ExistingAssetID = candidate.ExistingAssetID, ExistingAssetName = candidate.ExistingAssetName }).ToList() }; } public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form) { var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) { return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing assets." }; } var choices = form.Assets .Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); if (choices.Count == 0) { if (data.Candidates.Count == 0) { batch.AssetStageComplete = true; batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult(); await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); return new StoryIntelligenceImportCommitResult { Success = true, Message = "Asset review completed. No asset decisions were waiting." }; } return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one asset to create, link or ignore." }; } var existingIndex = await BuildAssetIndexAsync(batch.ProjectID); var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID); var lookupData = await assets.GetLookupsAsync(batch.ProjectID); var created = 0; var linkedExisting = 0; var ignored = 0; var aliasesAdded = 0; var sceneEventsCreated = 0; var ownershipLinksCreated = 0; 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, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) { ignored++; AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.Ignore, candidate.DisplayName, null, false); continue; } if (string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)) { continue; } var requestedName = Clean(choice.ImportName); var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; var linkExisting = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase); var matchedExistingId = linkExisting ? candidate.ExistingAssetID ?? existingIndex.Find(importName)?.StoryAssetID ?? existingIndex.Find(candidate.DisplayName)?.StoryAssetID : null; var assetId = matchedExistingId; if (!assetId.HasValue) { assetId = await assets.SaveAssetAsync(new StoryAsset { ProjectID = batch.ProjectID, AssetName = importName, AssetKindID = MatchAssetKind(lookupData.AssetKinds, candidate), Description = BuildAssetDescription(candidate), Importance = 5, CurrentStateID = MatchAssetState(lookupData.AssetStates, "Known To Exist") }); created++; } else { linkedExisting++; } resolvedCandidateIds[candidate.Key] = assetId.Value; AddDecision(batch, candidate.Key, choice.Action, importName, assetId.Value, true); foreach (var alias in candidate.Aliases) { if (await TryAddAliasAsync(assetId.Value, alias, importName)) { aliasesAdded++; } } } foreach (var candidate in data.Candidates) { if (!choices.TryGetValue(candidate.Key, out var choice) || !string.Equals(choice.Action, StoryIntelligenceAssetImportActions.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, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase) || string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase) || !candidateByKey.TryGetValue(targetKey, out var targetCandidate) || !resolvedCandidateIds.TryGetValue(targetKey, out var targetAssetId)) { logger.LogWarning( "Story Intelligence asset alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.", candidate.Key, targetKey); continue; } var targetName = targetCandidate.ExistingAssetName ?? targetChoice.ImportName ?? targetCandidate.DisplayName; if (await TryAddAliasAsync(targetAssetId, candidate.DisplayName, targetName)) { aliasesAdded++; } foreach (var alias in candidate.Aliases) { if (await TryAddAliasAsync(targetAssetId, alias, targetName)) { aliasesAdded++; } } resolvedCandidateIds[candidate.Key] = targetAssetId; AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.Alias, candidate.DisplayName, targetAssetId, true); } foreach (var candidate in data.Candidates) { if (!choices.TryGetValue(candidate.Key, out var choice) || string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) { continue; } var assetId = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase) ? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey)) : resolvedCandidateIds.GetValueOrDefault(candidate.Key); if (assetId <= 0) { continue; } foreach (var appearance in candidate.Appearances .Where(appearance => !appearance.AlreadyLinked) .GroupBy(appearance => appearance.SceneID) .Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First())) { await assets.SaveEventAsync(new AssetEvent { StoryAssetID = assetId, SceneID = appearance.SceneID, AssetEventTypeID = MatchAssetEventType(lookupData.AssetEventTypes, appearance.EventType), ToStateID = MatchAssetState(lookupData.AssetStates, StateFromEventType(appearance.EventType)), EventTitle = appearance.EventType, EventDescription = appearance.Notes }); sceneEventsCreated++; var owner = Clean(appearance.OwnerOrHolder ?? candidate.OwnerOrHolder); if (!string.IsNullOrWhiteSpace(owner) && characterIndex.Find(owner) is CharacterMatch ownerMatch && lookupData.AssetCustodyEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Has", StringComparison.OrdinalIgnoreCase)) is { } custodyType && lookupData.CustodyRoles.FirstOrDefault(role => string.Equals(role.RoleName, "Has Custody", StringComparison.OrdinalIgnoreCase)) is { } role) { await assets.SaveCustodyEventAsync( new AssetCustodyEvent { StoryAssetID = assetId, SceneID = appearance.SceneID, AssetCustodyEventTypeID = custodyType.AssetCustodyEventTypeID, Description = $"Initial owner/holder from Story Intelligence: {owner}" }, owner, [ownerMatch.CharacterID], role.CustodyRoleID); ownershipLinksCreated++; } } } batch.AssetStageComplete = true; batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult { AssetsCreated = created, AssetsLinked = linkedExisting, AssetAliasesAdded = aliasesAdded, AssetsIgnored = ignored, SceneAssetEventsCreated = sceneEventsCreated, OwnershipLinksCreated = ownershipLinksCreated }; await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); logger.LogInformation( "Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}", batch.BatchID, created, linkedExisting, aliasesAdded, ignored, sceneEventsCreated, ownershipLinksCreated); return new StoryIntelligenceImportCommitResult { Success = true, ScenesCreated = sceneEventsCreated, Message = $"Assets imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneEventsCreated:N0} scene asset event(s) created." }; } private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch) { var existingIndex = await BuildAssetIndexAsync(batch.ProjectID); var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); 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; } var existingEvents = await assets.ListEventsBySceneAsync(importedScene.SceneID); alreadyLinked += existingEvents.Select(assetEvent => assetEvent.StoryAssetID).Distinct().Count(); AddSceneAssets(groups, existingIndex, importedScene, parsed, existingEvents); } } var candidates = groups.Values .Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked)) .Where(IsVisibleAssetCandidate) .OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count()) .ThenBy(candidate => candidate.DisplayName) .ToList(); return new AssetCandidateData(hasCommittedScenes, alreadyLinked, candidates); } private static void AddSceneAssets( Dictionary groups, AssetIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed, IReadOnlyList existingEvents) { AddStructuralSummaryAssets(groups, existingIndex, importedScene, parsed, existingEvents); foreach (var asset in parsed.Assets ?? []) { AddAsset(groups, existingIndex, importedScene, parsed, asset, existingEvents); } foreach (var observation in parsed.Observations ?? []) { if (IsAssetEntity(observation.SubjectEntityType)) { AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset { Name = observation.SubjectName, AssetType = observation.ObservationType, Status = observation.Predicate, Confidence = observation.Confidence, Notes = observation.Description }, existingEvents); } if (IsAssetEntity(observation.ObjectEntityType)) { AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset { Name = observation.ObjectName, AssetType = observation.ObservationType, Status = observation.Predicate, MentionedOnly = true, Confidence = observation.Confidence, Notes = observation.Description }, existingEvents); } } } private static void AddAsset( Dictionary groups, AssetIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed, SceneIntelligenceAsset asset, IReadOnlyList existingEvents) { var cleanName = ResolveContextualAssetName(asset.Name, asset.OwnerOrHolder, importedScene); if (!IsAssetNameCandidate(cleanName) && !CanAttachToExistingContextualAsset(groups, cleanName)) { return; } var key = ResolveCandidateKey(groups, existingIndex, cleanName); if (!groups.TryGetValue(key, out var candidate)) { var match = existingIndex.Find(cleanName); candidate = new AssetCandidate(key, DisplayAssetName(cleanName), match?.StoryAssetID, match?.AssetName) { Category = Categorise(cleanName, asset.AssetType) }; groups[key] = candidate; } else if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase)) { candidate.Aliases.Add(cleanName); } candidate.Confidence = Max(candidate.Confidence, asset.Confidence); candidate.Description ??= Clean(asset.Notes); candidate.OwnerOrHolder ??= ResolveOwnerName(asset.OwnerOrHolder, importedScene); candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName); candidate.Appearances.Add(new AssetAppearanceImport( importedScene.SceneID, importedScene.SceneNumber, importedScene.SceneTitle, EventTypeFromAsset(asset), asset.OwnerOrHolder, asset.Notes, asset.Confidence, candidate.ExistingAssetID.HasValue && existingEvents.Any(existing => existing.StoryAssetID == candidate.ExistingAssetID.Value))); } private static void AddStructuralSummaryAssets( Dictionary groups, AssetIndex existingIndex, Scene importedScene, SceneIntelligenceScene parsed, IReadOnlyList existingEvents) { var source = FirstConfigured(importedScene.StructuralSummary, importedScene.SceneTitle); if (string.IsNullOrWhiteSpace(source)) { return; } foreach (Match match in StructuralPossessiveAssetPattern.Matches(source)) { var owner = match.Groups["owner"].Value; var objectName = CleanStructuralAssetPhrase(match.Groups["asset"].Value); if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(objectName)) { continue; } var name = $"{owner}'s {objectName}"; AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset { Name = name, OwnerOrHolder = owner, Status = "structuralSummarySignificance", MentionedOnly = true, Confidence = 0.86m, Notes = $"Structural summary identifies {name}." }, existingEvents); } foreach (Match match in StructuralSignificantAssetPattern.Matches(source)) { var objectName = CleanStructuralAssetPhrase(match.Groups["asset"].Value); if (string.IsNullOrWhiteSpace(objectName) || !IsOwnedAssetName(objectName)) { continue; } var owner = Clean(importedScene.POVCharacterName ?? importedScene.POVSuggestedName); AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset { Name = objectName, OwnerOrHolder = owner, Status = "structuralSummarySignificance", MentionedOnly = true, Confidence = 0.82m, Notes = $"Structural summary identifies {objectName} as significant." }, existingEvents); } } 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 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 TryAddAliasAsync(int storyAssetId, string alias, string primaryName) { var cleanAlias = Clean(alias); if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase)) { return false; } var existingAliases = await assets.ListAliasesAsync(storyAssetId); if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase))) { return false; } await assets.AddAliasAsync(storyAssetId, cleanAlias, null); return true; } 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 MatchAssetKind(IReadOnlyList kinds, AssetCandidate candidate) => kinds.FirstOrDefault(kind => candidate.Category.Contains(kind.KindName, StringComparison.OrdinalIgnoreCase) || kind.KindName.Contains(candidate.Category, StringComparison.OrdinalIgnoreCase))?.AssetKindID ?? kinds.FirstOrDefault(kind => string.Equals(kind.KindName, "Physical Object", StringComparison.OrdinalIgnoreCase))?.AssetKindID ?? kinds.First().AssetKindID; private static int MatchAssetEventType(IReadOnlyList eventTypes, string eventType) => eventTypes.FirstOrDefault(type => string.Equals(type.TypeName, eventType, StringComparison.OrdinalIgnoreCase))?.AssetEventTypeID ?? eventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Mentioned", StringComparison.OrdinalIgnoreCase))?.AssetEventTypeID ?? eventTypes.First().AssetEventTypeID; private static int? MatchAssetState(IReadOnlyList states, string? stateName) => string.IsNullOrWhiteSpace(stateName) ? null : states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.AssetStateID; private static string StateFromEventType(string eventType) => eventType switch { "First Mentioned" => "Known To Exist", "Mentioned" => "Mentioned", "Found" => "Found", "Given" => "Possessed", "Taken" => "Possessed", "Lost" => "Lost", "Hidden" => "Hidden", "Used" => "Used", "Opened" => "Opened", "Revealed" => "Revealed", "Investigated" => "Investigated", _ => "Known To Exist" }; private static string BuildAssetDescription(AssetCandidate candidate) { var lines = new List(); if (!string.IsNullOrWhiteSpace(candidate.OwnerOrHolder)) { lines.Add($"Possible owner/holder from Story Intelligence: {candidate.OwnerOrHolder}"); } if (!string.IsNullOrWhiteSpace(candidate.Description)) { lines.Add(candidate.Description); } return string.Join(Environment.NewLine, lines); } private static bool IsVisibleAssetCandidate(AssetCandidate candidate) { if (IsAcceptedStoryAssetName(candidate.DisplayName)) { return true; } var sceneCount = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(); var signalText = $"{candidate.DisplayName} {candidate.Category} {candidate.Description} {candidate.OwnerOrHolder} {string.Join(' ', candidate.Appearances.Select(a => a.Notes))}"; return ContainsStructuralSignificance(candidate) || sceneCount >= 2 && (ContainsStoryAssetSignal(signalText) || IsDistinctiveAssetName(candidate.DisplayName)); } private static bool ContainsStructuralSignificance(AssetCandidate candidate) => candidate.Appearances.Any(appearance => string.Equals(appearance.EventType, "Mentioned", StringComparison.OrdinalIgnoreCase) && IsStructuralSummaryEvidence(appearance.Notes) && IsDistinctiveAssetName(candidate.DisplayName)); private static bool IsAssetNameCandidate(string? name) { var clean = CleanAssetName(name); if (string.IsNullOrWhiteSpace(clean) || clean.Length < 2 || !clean.Any(char.IsLetter)) { return false; } if (IsAcceptedStoryAssetName(clean)) { return true; } var normalised = Normalise(clean); return !GenericObjects.Contains(normalised) && !GenericObjects.Contains(RemoveLeadingArticle(normalised)) && !LooksLikePersonOrRelationshipFact(clean); } private static bool IsAcceptedStoryAssetName(string? name) { var normalised = Normalise(CleanAssetName(name)); var withoutArticle = RemoveLeadingArticle(normalised); return AcceptedAssetNames.Contains(normalised) || AcceptedAssetNames.Contains(withoutArticle) || normalised.Contains(" keepsake ", StringComparison.OrdinalIgnoreCase) || normalised.Contains("'s car", StringComparison.OrdinalIgnoreCase) || normalised.Contains("’s car", StringComparison.OrdinalIgnoreCase); } private static bool ContainsStoryAssetSignal(string value) => StorySignals.Any(signal => value.Contains(signal, StringComparison.OrdinalIgnoreCase)); private static string EventTypeFromAsset(SceneIntelligenceAsset asset) { var value = $"{asset.Status} {asset.Notes}".ToLowerInvariant(); if (value.Contains("found")) return "Found"; if (value.Contains("lost")) return "Lost"; if (value.Contains("given") || value.Contains("gives")) return "Given"; if (value.Contains("taken") || value.Contains("takes")) return "Taken"; if (value.Contains("stolen")) return "Stolen"; if (value.Contains("hidden") || value.Contains("hides")) return "Hidden"; if (value.Contains("opened")) return "Opened"; if (value.Contains("used") || value.Contains("uses")) return "Used"; if (value.Contains("revealed") || value.Contains("reveals")) return "Revealed"; if (value.Contains("investigated")) return "Investigated"; return asset.MentionedOnly == true ? "Mentioned" : "First Mentioned"; } private static string Categorise(string name, string? assetType) { var value = $"{name} {assetType}".ToLowerInvariant(); if (value.Contains("passport") || value.Contains("licence") || value.Contains("license") || value.Contains("letter") || value.Contains("note") || value.Contains("map") || value.Contains("diary") || value.Contains("notebook") || value.Contains("document")) { return "Document"; } if (value.Contains("photo") || value.Contains("evidence") || value.Contains("clue")) { return "Evidence"; } if (value.Contains("car") || value.Contains("vehicle")) { return "Vehicle"; } if (value.Contains("necklace") || value.Contains("ring") || value.Contains("watch")) { return "Physical Object"; } return string.IsNullOrWhiteSpace(assetType) ? "Physical Object" : Clean(assetType); } private static string ResolveCandidateKey( IReadOnlyDictionary groups, AssetIndex existingIndex, string name) { var existingMatch = existingIndex.Find(name); if (existingMatch is not null) { return CanonicalAssetKey(existingMatch.AssetName); } var canonical = CanonicalAssetKey(name); if (groups.ContainsKey(canonical)) { return canonical; } var contextual = FindContextualAssetKey(groups.Keys, canonical); return contextual ?? canonical; } private static string? FindContextualAssetKey(IEnumerable existingKeys, string candidateKey) { if (string.IsNullOrWhiteSpace(candidateKey) || HasPossessiveOwner(candidateKey) || LooksLikePersonOrRelationshipFact(candidateKey)) { return null; } var matches = existingKeys .Where(key => key.EndsWith($" {candidateKey}", StringComparison.OrdinalIgnoreCase)) .ToList(); return matches.Count == 1 ? matches[0] : null; } private static bool CanAttachToExistingContextualAsset(IReadOnlyDictionary groups, string name) => FindContextualAssetKey(groups.Keys, CanonicalAssetKey(name)) is not null; private static string ResolveContextualAssetName(string? name, string? ownerOrHolder, Scene scene) { var cleanName = CleanAssetName(name); if (string.IsNullOrWhiteSpace(cleanName)) { return cleanName; } var owner = ResolveOwnerName(ownerOrHolder, scene); if (cleanName.StartsWith("my ", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(scene.POVCharacterName)) { return $"{scene.POVCharacterName}'s {cleanName[3..].Trim()}"; } if (!string.IsNullOrWhiteSpace(owner) && !cleanName.Contains("'s", StringComparison.OrdinalIgnoreCase) && !cleanName.Contains("’s", StringComparison.OrdinalIgnoreCase) && IsOwnedAssetName(cleanName)) { return $"{owner}'s {cleanName}"; } return cleanName; } private static string ResolveOwnerName(string? ownerOrHolder, Scene scene) { var owner = Clean(ownerOrHolder); if (string.IsNullOrWhiteSpace(owner)) { return owner; } if (string.Equals(owner, "Narrator", StringComparison.OrdinalIgnoreCase) || string.Equals(owner, "me", StringComparison.OrdinalIgnoreCase) || string.Equals(owner, "my", StringComparison.OrdinalIgnoreCase)) { return Clean(scene.POVCharacterName ?? scene.POVSuggestedName); } return owner; } private static bool IsOwnedAssetName(string name) => ContainsStoryAssetSignal(name) || IsAcceptedStoryAssetName(name) || IsDistinctiveAssetName(name); private static bool HasPossessiveOwner(string value) => value.Contains("'s ", StringComparison.OrdinalIgnoreCase) || value.Contains("’s ", StringComparison.OrdinalIgnoreCase); private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName) { var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short)); return string.IsNullOrWhiteSpace(summary) ? $"{BuildSceneReference(scene)}: {assetName}" : $"{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 void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? assetName, int? storyAssetId, bool createdOrLinked) { batch.AssetDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase)); batch.AssetDecisions.Add(new OnboardingStoryIntelligenceAssetDecision { Key = key, Action = action, AssetName = assetName, StoryAssetID = storyAssetId, CreatedOrLinked = createdOrLinked }); } private static bool IsAssetEntity(string? entityType) => string.Equals(Clean(entityType), "Asset", StringComparison.OrdinalIgnoreCase) || string.Equals(Clean(entityType), "Object", StringComparison.OrdinalIgnoreCase); 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 DisplayAssetName(string name) { var clean = CleanAssetName(name); return string.IsNullOrWhiteSpace(clean) ? clean : char.ToUpperInvariant(clean[0]) + clean[1..]; } private static string CanonicalAssetKey(string name) { var clean = RemoveLeadingArticle(Normalise(CleanAssetName(name))); var modelCode = ExtractDistinctiveModelCode(clean); if (!string.IsNullOrWhiteSpace(modelCode)) { return modelCode; } var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); while (words.Count > 1 && AdjectivesToFold.Contains(words[0])) { words.RemoveAt(0); } if (words.Count == 2 && words[1].Equals("keys", StringComparison.OrdinalIgnoreCase)) { return "keys"; } if (clean is "photo") { return "photograph"; } if (clean is "driving license") { return "driving licence"; } return string.Join(' ', words); } private static string CleanAssetName(string? value) => StoryIntelligenceEntityTextNormaliser.CleanName(value); private static bool IsStructuralSummaryEvidence(string? value) => Clean(value).Contains("Structural summary identifies", StringComparison.OrdinalIgnoreCase); private static bool IsDistinctiveAssetName(string? value) { var clean = RemoveLeadingArticle(Normalise(CleanAssetName(value))); if (string.IsNullOrWhiteSpace(clean) || GenericObjects.Contains(clean) || LooksLikePersonOrRelationshipFact(clean)) { return false; } if (!string.IsNullOrWhiteSpace(ExtractDistinctiveModelCode(clean))) { return true; } if (clean.Contains("'s ", StringComparison.OrdinalIgnoreCase) || clean.Contains("’s ", StringComparison.OrdinalIgnoreCase)) { return true; } var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries); return words.Length >= 2 && words.Any(word => !AdjectivesToFold.Contains(word) && !GenericObjects.Contains(word)); } private static bool LooksLikePersonOrRelationshipFact(string? value) { var clean = RemoveLeadingArticle(Normalise(CleanAssetName(value))); if (string.IsNullOrWhiteSpace(clean) || IsAcceptedStoryAssetName(clean)) { return false; } if (clean.Contains(" dead in ", StringComparison.OrdinalIgnoreCase) || clean.Contains(" in bed", StringComparison.OrdinalIgnoreCase) || clean.Contains(" before leaving", StringComparison.OrdinalIgnoreCase) || clean.Contains(" written name", StringComparison.OrdinalIgnoreCase)) { return true; } var subject = StripPossessiveOwner(clean); var words = subject.Split(' ', StringSplitOptions.RemoveEmptyEntries); return words.Length is >= 1 and <= 4 && words.Any(word => RelationshipWords.Contains(word)) && !words.Any(word => AcceptedAssetNames.Contains(word) || StorySignals.Contains(word)); } private static string StripPossessiveOwner(string value) { var straightIndex = value.IndexOf("'s ", StringComparison.OrdinalIgnoreCase); var curlyIndex = value.IndexOf("’s ", StringComparison.OrdinalIgnoreCase); var index = straightIndex >= 0 ? straightIndex : curlyIndex; return index >= 0 ? value[(index + 3)..] : value; } private static string? ExtractDistinctiveModelCode(string? value) { var clean = Normalise(value); var matches = Regex.Matches(clean, @"\b(?=[a-z0-9]*[a-z])(?=[a-z0-9]*\d)[a-z]{1,6}\d[a-z0-9]{0,6}\b", RegexOptions.IgnoreCase); return matches.Count == 1 ? matches[0].Value : null; } private static string CleanStructuralAssetPhrase(string? value) { var clean = CleanAssetName(value); if (string.IsNullOrWhiteSpace(clean)) { return string.Empty; } foreach (var separator in StructuralAssetTailSeparators) { var index = clean.IndexOf(separator, StringComparison.OrdinalIgnoreCase); if (index > 0) { clean = clean[..index].Trim(); } } return clean; } private static string RemoveLeadingArticle(string value) => value.StartsWith("the ", StringComparison.OrdinalIgnoreCase) ? value[4..] : value.StartsWith("a ", StringComparison.OrdinalIgnoreCase) ? value[2..] : value.StartsWith("an ", StringComparison.OrdinalIgnoreCase) ? value[3..] : value; private static string Normalise(string? value) => StoryIntelligenceEntityTextNormaliser.SafeKey(value); private static string Clean(string? value) => StoryIntelligenceEntityTextNormaliser.Clean(value); private static HashSet CreateSet(params string[] values) => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); private static readonly string[] StructuralAssetTailSeparators = [ " and ", " or ", " while ", " that ", " which ", " containing ", " contains ", " with ", " inside ", "," ]; private static readonly Regex StructuralPossessiveAssetPattern = new( @"\b(?[A-Z][\p{L}'-]{1,40})'s\s+(?[A-Z][\p{L}\p{N}'-]*(?:\s+[A-Z][\p{L}\p{N}'-]*){0,3})\b", RegexOptions.Compiled); private static readonly Regex StructuralSignificantAssetPattern = new( @"\b(?:opens?|retrieves?|discovers?|examines?|keeps?|hides?|finds?|carries?|takes?|gives?|receives?|reads?)\s+(?:her|his|their|my|the|a|an)\s+(?[\p{L}\p{N}'-]+(?:\s+[\p{L}\p{N}'-]+){0,4})\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); private sealed class AssetIndex { private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); public void Add(string? name, int storyAssetId, string assetName) { var key = CanonicalAssetKey(name ?? string.Empty); if (!string.IsNullOrWhiteSpace(key)) { byName.TryAdd(key, new AssetMatch(storyAssetId, assetName)); } } public AssetMatch? Find(string name) => byName.TryGetValue(CanonicalAssetKey(name), out var match) ? match : null; } private sealed class CharacterIndex { private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); public void Add(string? name, int characterId, string characterName) { var key = Normalise(name); if (!string.IsNullOrWhiteSpace(key)) { byName.TryAdd(key, new CharacterMatch(characterId, characterName)); } } public CharacterMatch? Find(string name) => byName.TryGetValue(Normalise(name), out var match) ? match : null; } private sealed record AssetMatch(int StoryAssetID, string AssetName); private sealed record CharacterMatch(int CharacterID, string CharacterName); private sealed class AssetCandidate(string key, string displayName, int? existingAssetId, string? existingAssetName) { public string Key { get; } = key; public string DisplayName { get; } = displayName; public int? ExistingAssetID { get; } = existingAssetId; public string? ExistingAssetName { get; } = existingAssetName; public string Category { get; set; } = "Physical Object"; public string? OwnerOrHolder { get; set; } public HashSet Aliases { get; } = new(StringComparer.OrdinalIgnoreCase); public List Appearances { get; } = []; public decimal? Confidence { get; set; } public string? Description { get; set; } public string? FirstAppearanceNote { get; set; } } private sealed record AssetAppearanceImport( int SceneID, decimal SceneNumber, string SceneTitle, string EventType, string? OwnerOrHolder, string? Notes, decimal? Confidence, bool AlreadyLinked); private sealed record AssetCandidateData( bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList Candidates); }