using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Text.Json; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryIntelligenceVisualisationSnapshotService { Task BuildSnapshotAsync(int runId, int userId); Task BuildImportSessionSnapshotAsync(int importSessionId, int userId); Task> ListRunOptionsAsync(int userId); Task> ListImportSessionOptionsAsync(int userId); } public sealed class StoryIntelligenceVisualisationSnapshotService( IStoryIntelligenceResultRepository repository, IStoryIntelligencePipelineRepository pipelines, IStoryIntelligenceSourceRepository sources, IIllustrationLibraryService illustrationLibrary, IStoryIntelligenceIllustrationMatchingService illustrationMatcher, ILogger logger) : IStoryIntelligenceVisualisationSnapshotService { private const string FallbackRoot = "/images/story-intelligence/prototype"; private const int SceneWindowPrevious = 4; private const int SceneWindowFollowing = 2; private const int SnapshotWarningBytes = 1024 * 1024; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; public async Task> ListRunOptionsAsync(int userId) { var runs = await repository.ListRunsAsync(); return runs .Where(run => run.UserID == userId) .OrderByDescending(run => run.CreatedUtc) .Take(12) .Select(run => new StoryIntelligenceExperienceRunOption { RunId = run.StoryIntelligenceRunID, Status = run.Status, CreatedUtc = run.CreatedUtc, Label = $"{DisplayTitle(run.ProjectTitle, "Project")} / {DisplayTitle(run.BookTitle, "Book")} - {run.Status} - {run.CreatedUtc:u}" }) .ToList(); } public async Task> ListImportSessionOptionsAsync(int userId) { await EnsureActiveImportSessionsAsync(userId); var sessions = await pipelines.ListForUserAsync(userId); return sessions .OrderByDescending(session => session.UpdatedUtc) .Take(12) .Select(session => new StoryIntelligenceExperienceRunOption { ImportSessionId = session.StoryIntelligenceBookPipelineID, RunId = session.LastRunID ?? 0, Status = session.Status, CreatedUtc = session.CreatedUtc, Label = $"{DisplayTitle(session.ProjectName, "Project")} / {DisplayTitle(session.BookDisplayTitle, "Book")} - {session.Status} - started {session.CreatedUtc:u}" }) .ToList(); } private async Task EnsureActiveImportSessionsAsync(int userId) { var runs = await repository.ListRunsAsync(); var activeBookRuns = runs .Where(run => run.UserID == userId && run.ProjectID.HasValue && run.BookID.HasValue && !IsTerminal(run.Status)) .GroupBy(run => new { ProjectID = run.ProjectID!.Value, BookID = run.BookID!.Value }) .Select(group => group.OrderByDescending(run => run.CreatedUtc).First()) .ToList(); foreach (var run in activeBookRuns) { await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest { ProjectID = run.ProjectID!.Value, BookID = run.BookID!.Value, CurrentStage = StoryIntelligencePipelineStages.Chapters, LastCompletedStage = null, CurrentReviewStage = null, Status = StoryIntelligencePipelineStatuses.InProgress, CompletedUtc = null, LastRunID = run.StoryIntelligenceRunID }); } } public async Task BuildSnapshotAsync(int runId, int userId) { var stopwatch = Stopwatch.StartNew(); var run = await repository.GetRunAsync(runId); if (run is null || run.UserID != userId) { return null; } var results = (await repository.ListSceneResultsAsync(runId)).ToList(); var parsed = results .Select(result => ReadSceneResult(runId, result)) .OrderBy(item => SceneSortChapter(item)) .ThenBy(item => SceneSortNumber(item)) .ThenBy(item => item.Result.SceneResultID) .ToList(); var completed = parsed .Where(item => item.Scene is not null) .ToList(); var current = PickCurrentScene(run, completed); var storyMemory = new StoryMemory(); var visibleSceneIds = VisibleSceneResultIds(completed, current, SceneWindowPrevious, SceneWindowFollowing); var scenes = BuildSceneStates(run, parsed, completed, current, storyMemory: storyMemory, includedSceneResultIds: visibleSceneIds).ToList(); if (scenes.Count == 0) { scenes.Add(BuildEmptyScene(run)); } var model = new StoryIntelligenceExperiencePrototypeViewModel { Mode = "real", RunId = run.StoryIntelligenceRunID, RunStatus = run.Status, GeneratedUtc = DateTime.UtcNow, ChangeToken = ChangeToken(run, results), IsTerminal = IsTerminal(run.Status), SnapshotMessage = SnapshotMessage(run, completed.Count), Diagnostics = BuildDiagnostics(run, results, parsed, completed, current, storyMemory), ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"), BookTitle = DisplayTitle(run.BookTitle, "Untitled book"), Scenes = scenes }; StabiliseCharacterIllustrations(model); await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); if (run.ProjectID.HasValue) { await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model); ApplyIllustrationDiagnostics(model); } StripLiveDiagnostics(model); LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds); return model; } public async Task BuildImportSessionSnapshotAsync(int importSessionId, int userId) { var stopwatch = Stopwatch.StartNew(); var session = await pipelines.GetByIdForUserAsync(importSessionId, userId); if (session is null) { return null; } var runs = (await repository.ListRunsByBookForUserAsync(session.BookID, userId)) .OrderBy(run => run.ChapterNumber ?? decimal.MaxValue) .ThenBy(run => run.ChapterID ?? int.MaxValue) .ThenBy(run => run.StoryIntelligenceRunID) .ToList(); if (runs.Count == 0) { return null; } var runSnapshots = new List(); foreach (var run in runs) { var results = (await repository.ListSceneResultsAsync(run.StoryIntelligenceRunID)).ToList(); var parsed = results .Select(result => ReadSceneResult(run.StoryIntelligenceRunID, result)) .OrderBy(item => SceneSortChapter(item)) .ThenBy(item => SceneSortNumber(item)) .ThenBy(item => item.Result.SceneResultID) .ToList(); var completed = parsed.Where(item => item.Scene is not null).ToList(); runSnapshots.Add(new(run, results, parsed, completed, PickCurrentScene(run, completed))); } var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last(); var current = activeSnapshot.Current ?? runSnapshots.LastOrDefault(snapshot => snapshot.Completed.Count > 0)?.Current; var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count); var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.Count); var totalScenes = Math.Max( runSnapshots.Sum(snapshot => Math.Max(snapshot.Run.TotalDetectedScenes ?? 0, snapshot.Results.Count)), parsedScenes); var sourceChapterCount = (await sources.ListChaptersAsync(session.BookID)).Count; var bookChapterTotal = Math.Max(1, Math.Max(sourceChapterCount, runSnapshots.Count)); var completedChapterRuns = runSnapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); var sessionSuccessfullyComplete = IsSuccessfulSession(session); var allKnownRunsSuccessfullyComplete = runSnapshots.Count > 0 && runSnapshots.All(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); var isSuccessfullyComplete = sessionSuccessfullyComplete || allKnownRunsSuccessfullyComplete; var isTerminal = isSuccessfullyComplete || IsTerminalSession(session) || runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status)); var calculatedVisualisationProgress = OverallProgress(parsedScenes, totalScenes, isSuccessfullyComplete); var authoritativeImportProgress = BookProgress(runSnapshots, bookChapterTotal, isSuccessfullyComplete); var displayedProgressReason = sessionSuccessfullyComplete ? "Successful terminal import session" : allKnownRunsSuccessfullyComplete ? "All known chapter runs completed successfully" : "Weighted chapter-run progress"; var overallProgressPercent = authoritativeImportProgress; var overallElapsed = SessionElapsed(runSnapshots); var overallRemaining = isSuccessfullyComplete ? "Ready to review" : isTerminal ? "Ready to review" : EstimateSessionRemaining(runSnapshots, parsedScenes, totalScenes); var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run); var scenes = new List(); var nextSceneNumber = 1; var storyMemory = new StoryMemory(); var allCompleted = runSnapshots.SelectMany(snapshot => snapshot.Completed).ToList(); var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, SceneWindowFollowing); foreach (var snapshot in runSnapshots) { var runScenes = BuildSceneStates( snapshot.Run, snapshot.Parsed, snapshot.Completed, current, continuousSceneNumberStart: nextSceneNumber, overallCompletedScenes: parsedScenes, overallTotalScenes: totalScenes, overallChaptersAnalysed: completedChapterRuns, overallChapterTotal: bookChapterTotal, overallProgressPercent: overallProgressPercent, overallStage: overallStage, overallElapsed: overallElapsed, overallRemaining: overallRemaining, continuousSceneNumbering: true, overallTerminal: isTerminal, storyMemory: storyMemory, includedSceneResultIds: visibleSceneIds) .ToList(); scenes.AddRange(runScenes); nextSceneNumber += snapshot.Completed.Count; } if (scenes.Count == 0) { scenes.Add(BuildEmptyScene(activeSnapshot.Run)); } var model = new StoryIntelligenceExperiencePrototypeViewModel { Mode = "real", ImportSessionId = session.StoryIntelligenceBookPipelineID, RunId = activeSnapshot.Run.StoryIntelligenceRunID, RunStatus = SessionStatus(session, runSnapshots), GeneratedUtc = DateTime.UtcNow, ChangeToken = SessionChangeToken(session, runSnapshots), IsTerminal = isTerminal, SnapshotMessage = SessionSnapshotMessage(session, runSnapshots), Diagnostics = BuildSessionDiagnostics( session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults, totalScenes, overallProgressPercent, authoritativeImportProgress, calculatedVisualisationProgress, bookChapterTotal, completedChapterRuns, displayedProgressReason, storyMemory), ProjectName = DisplayTitle(session.ProjectName, "Untitled project"), BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"), Scenes = scenes }; StabiliseCharacterIllustrations(model); await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model); ApplyIllustrationDiagnostics(model); StripLiveDiagnostics(model); LogPayload(model, $"import session {importSessionId}", stopwatch.ElapsedMilliseconds); return model; } private static IEnumerable BuildSceneStates( StoryIntelligenceSavedRun run, IReadOnlyList allResults, IReadOnlyList completed, ParsedSceneResult? current, int continuousSceneNumberStart = 1, int? overallCompletedScenes = null, int? overallTotalScenes = null, int? overallChaptersAnalysed = null, int? overallChapterTotal = null, int? overallProgressPercent = null, string? overallStage = null, string? overallElapsed = null, string? overallRemaining = null, bool continuousSceneNumbering = false, bool overallTerminal = false, StoryMemory? storyMemory = null, IReadOnlySet? includedSceneResultIds = null) { storyMemory ??= new StoryMemory(); var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count); var completedScenes = run.CompletedScenes ?? completed.Count; var displayIndex = 0; foreach (var item in completed) { var scene = item.Scene!; storyMemory.Update(scene, item); var sourceSceneNumber = SceneNumber(item); var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber; if (includedSceneResultIds is not null && !includedSceneResultIds.Contains(item.Result.SceneResultID)) { continue; } var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID; yield return new StoryIntelligenceExperienceSceneState { Id = $"scene-result-{item.Result.SceneResultID}", ChapterLabel = ChapterLabel(run, item), SceneNumber = sceneNumber, Title = SceneTitle(scene, item.Result, sceneNumber), Summary = Trim(FirstConfigured(scene.Summary?.Short, scene.Summary?.Detailed, run.CurrentMessage, "Scene analysed."), 180), Stage = overallStage ?? StageDisplay(run), TimelineLabel = TimelineLabel(scene, item.Result), TimelineEvent = Trim(FirstConfigured(scene.Summary?.Short, scene.ScenePurpose?.ObservedFunction, "Scene analysed"), 42), ProgressPercent = overallProgressPercent ?? (overallTotalScenes.HasValue ? OverallProgress(overallCompletedScenes ?? completedScenes, overallTotalScenes.Value, overallTerminal) : Progress(run, completedScenes, totalScenes)), ChaptersAnalysed = overallChaptersAnalysed ?? ChapterCount(completed), ChapterTotal = Math.Max(1, overallChapterTotal ?? run.SourceChapterCount ?? ChapterCount(completed)), ScenesAnalysed = overallCompletedScenes ?? completedScenes, SceneTotal = Math.Max(1, overallTotalScenes ?? totalScenes), ElapsedTime = overallElapsed ?? EstimateElapsed(run), EstimatedRemaining = overallRemaining ?? EstimateRemaining(run, completedScenes, totalScenes), PovCharacterId = CharacterId(storyMemory.ResolvePointOfView(scene)), Location = BuildLocation(scene), Characters = BuildCharacters(scene, storyMemory), Assets = BuildAssets(scene), Relationships = BuildRelationships(scene, storyMemory), KnowledgeThreads = BuildKnowledge(scene), Observations = BuildObservations(scene), LatestDiscoveries = isCurrent ? BuildDiscoveries(scene).ToList() : [], ActivityFeed = isCurrent ? BuildActivity(run, sceneNumber).ToList() : [] }; } } private static StoryIntelligenceExperienceSceneState BuildEmptyScene(StoryIntelligenceSavedRun run) => new() { Id = $"run-{run.StoryIntelligenceRunID}-waiting", ChapterLabel = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter", SceneNumber = 1, Title = run.Status is StoryIntelligenceRunStatuses.Failed ? "Analysis failed" : "Waiting for analysed scenes", Summary = FirstConfigured(run.ErrorMessage, run.CurrentMessage, "No scene analysis results have been persisted yet."), Stage = StageDisplay(run), TimelineLabel = "Pending", TimelineEvent = "No scene snapshot yet", ProgressPercent = Progress(run, run.CompletedScenes ?? 0, run.TotalDetectedScenes ?? 0), ChaptersAnalysed = 0, ChapterTotal = Math.Max(1, run.SourceChapterCount ?? 1), ScenesAnalysed = run.CompletedScenes ?? 0, SceneTotal = Math.Max(1, run.TotalDetectedScenes ?? 1), ElapsedTime = EstimateElapsed(run), EstimatedRemaining = EstimateRemaining(run, run.CompletedScenes ?? 0, run.TotalDetectedScenes ?? 0), Location = PendingLocation(), LatestDiscoveries = run.ErrorMessage is null ? [] : [$"Run failed: {Trim(run.ErrorMessage, 90)}"], ActivityFeed = BuildActivity(run, null).ToList() }; private static ParsedSceneResult? PickCurrentScene(StoryIntelligenceSavedRun run, IReadOnlyList completed) { if (completed.Count == 0) { return null; } return completed.Last(); } private static IReadOnlyList BuildCharacters(SceneIntelligenceScene scene, StoryMemory? storyMemory = null) { var narratorResolution = storyMemory?.ResolvePointOfView(scene) ?? ResolveNarratorName(scene); var povName = Clean(narratorResolution ?? scene.PointOfView?.CharacterName); var states = new List(); var reservedCodes = new HashSet(StringComparer.OrdinalIgnoreCase); var sceneCharacterNames = new HashSet((scene.Characters ?? []).Select(character => Clean(character.Name)), StringComparer.OrdinalIgnoreCase); foreach (var group in (scene.Characters ?? []) .Where(character => !string.IsNullOrWhiteSpace(character.Name)) .GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase) .Take(5)) { if (!string.IsNullOrWhiteSpace(narratorResolution) && IsGenericNarratorName(group.Key)) { continue; } var character = group.OrderByDescending(item => item.Confidence ?? 0).First(); var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase); var libraryCode = CharacterLibraryCode(group.Key, character, scene, reservedCodes); reservedCodes.Add(libraryCode); states.Add(new StoryIntelligenceExperienceCharacterState { Id = CharacterId(group.Key), LibraryCode = libraryCode, Name = group.Key, Role = isPov ? "POV character" : FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"), Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60), Weight = isPov ? 100 : CharacterWeight(character), ImagePath = CharacterFallback(group.Key) }); } if (!string.IsNullOrWhiteSpace(povName) && states.All(character => !string.Equals(character.Name, povName, StringComparison.OrdinalIgnoreCase))) { var libraryCode = CharacterLibraryCode(povName, null, scene, reservedCodes); states.Insert(0, new StoryIntelligenceExperienceCharacterState { Id = CharacterId(povName), LibraryCode = libraryCode, Name = povName, Role = "POV character", Relevance = "Point of view", Weight = 100, ImagePath = CharacterFallback(povName) }); } if (storyMemory is not null) { foreach (var memoryCharacter in storyMemory.Characters .Where(character => !sceneCharacterNames.Contains(character.Name)) .OrderByDescending(character => character.Weight) .Take(Math.Max(0, 5 - states.Count))) { var libraryCode = CharacterLibraryCode(memoryCharacter.Name, null, scene, reservedCodes); reservedCodes.Add(libraryCode); states.Add(new StoryIntelligenceExperienceCharacterState { Id = CharacterId(memoryCharacter.Name), LibraryCode = libraryCode, Name = memoryCharacter.Name, Role = memoryCharacter.Role, Relevance = memoryCharacter.Relevance, Weight = Math.Max(38, memoryCharacter.Weight - 18), ImagePath = CharacterFallback(memoryCharacter.Name) }); } } return states .OrderByDescending(character => character.Weight) .GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .ToList(); } private static string? ResolveNarratorName(SceneIntelligenceScene scene) { var povName = Clean(scene.PointOfView?.CharacterName); if (!IsGenericNarratorName(povName)) { return null; } var evidence = string.Join(' ', new[] { scene.PointOfView?.Evidence, scene.Summary?.Short, scene.Summary?.Detailed, scene.ScenePurpose?.ObservedFunction }.Where(value => !string.IsNullOrWhiteSpace(value))); var namedCharacters = (scene.Characters ?? []) .Where(character => !string.IsNullOrWhiteSpace(character.Name)) .Select(character => Clean(character.Name)) .Where(name => !IsGenericNarratorName(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var name in namedCharacters) { if (ContainsIdentityEvidence(evidence, name)) { return name; } } return namedCharacters.Count == 1 ? namedCharacters[0] : null; } private static bool ContainsIdentityEvidence(string evidence, string name) { if (string.IsNullOrWhiteSpace(evidence) || string.IsNullOrWhiteSpace(name)) { return false; } var lowerEvidence = evidence.ToLowerInvariant(); var lowerName = name.ToLowerInvariant(); return lowerEvidence.Contains($"narrated by {lowerName}", StringComparison.Ordinal) || lowerEvidence.Contains($"narrator is {lowerName}", StringComparison.Ordinal) || lowerEvidence.Contains($"i am {lowerName}", StringComparison.Ordinal) || lowerEvidence.Contains($"i'm {lowerName}", StringComparison.Ordinal) || lowerEvidence.Contains($"my name is {lowerName}", StringComparison.Ordinal); } private static bool IsGenericNarratorName(string? name) { var value = Clean(name).ToLowerInvariant(); return value is "narrator" or "i" or "me" or "my" or "myself" or "first person narrator" or "unnamed narrator"; } private static StoryIntelligenceExperienceLocationState BuildLocation(SceneIntelligenceScene scene) { var location = (scene.Locations ?? []) .Where(item => !string.IsNullOrWhiteSpace(item.Name) && item.MentionedOnly != true) .OrderByDescending(item => item.PresentInScene == true) .ThenByDescending(item => Specificity(item.Name)) .ThenByDescending(item => item.Confidence ?? 0) .FirstOrDefault(); var name = Clean(FirstConfigured(location?.Name, scene.Setting?.LocationName)); if (string.IsNullOrWhiteSpace(name)) { return PendingLocation(); } return new StoryIntelligenceExperienceLocationState { Id = $"location-result-{StableKey(name)}", LibraryCode = LocationLibraryCode(name, location, scene.Setting), Name = name, Label = Trim(FirstConfigured(location?.ParentLocationHint, location?.LocationType, location?.GenericRoomType, scene.Setting?.ParentLocationHint, "Active scene location"), 44), ImagePath = LocationFallback(name) }; } private static IReadOnlyList BuildAssets(SceneIntelligenceScene scene) => (scene.Assets ?? []) .Where(asset => !string.IsNullOrWhiteSpace(asset.Name) && asset.MentionedOnly != true && !IsLowValueAsset(asset)) .GroupBy(asset => Clean(asset.Name), StringComparer.OrdinalIgnoreCase) .Select(group => { var asset = group.OrderByDescending(item => item.Confidence ?? 0).First(); return new StoryIntelligenceExperienceAssetState { Id = $"asset-result-{StableKey(group.Key)}", LibraryCode = AssetLibraryCode(group.Key, asset), Name = group.Key, Label = Trim(FirstConfigured(asset.Status, asset.AssetType, asset.Notes, "Story asset"), 32), Weight = AssetWeight(asset), ImagePath = AssetFallback(group.Key) }; }) .OrderByDescending(asset => asset.Weight) .Take(5) .ToList(); private static IReadOnlyList BuildRelationships(SceneIntelligenceScene scene, StoryMemory? storyMemory = null) { var current = (scene.Relationships ?? []) .Where(item => !string.IsNullOrWhiteSpace(item.CharacterA) && !string.IsNullOrWhiteSpace(item.CharacterB)) .Select(item => new StoryIntelligenceExperienceRelationshipState { Id = $"relationship-result-{StableKey(item.CharacterA!)}-{StableKey(item.CharacterB!)}-{StableKey(item.RelationshipSignal)}", SourceId = CharacterId(item.CharacterA), TargetId = CharacterId(item.CharacterB), Label = Trim(FirstConfigured(item.RelationshipSignal, "Relationship signal"), 44), State = Trim(FirstConfigured(item.Evidence, item.RelationshipSignal, "Emerging"), 58), Weight = ConfidenceWeight(item.Confidence, 70) }) .OrderByDescending(item => item.Weight) .ToList(); var seen = new HashSet(current.Select(item => $"{item.SourceId}|{item.TargetId}"), StringComparer.OrdinalIgnoreCase); var inferredKinship = InferredKinshipRelationships(scene, seen); var memoryRelationships = (storyMemory?.Relationships ?? []) .Where(item => seen.Add($"{item.SourceId}|{item.TargetId}")) .Select(item => new StoryIntelligenceExperienceRelationshipState { Id = item.Id, SourceId = item.SourceId, TargetId = item.TargetId, Label = item.Label, State = item.State, Weight = item.Id.StartsWith("memory-inferred-relationship-", StringComparison.Ordinal) ? item.Weight : Math.Max(44, item.Weight - 16) }); return current.Concat(inferredKinship).Concat(memoryRelationships) .OrderByDescending(item => item.Weight) .Take(4) .ToList(); } private static IReadOnlyList InferredKinshipRelationships(SceneIntelligenceScene scene, HashSet seen) { var characters = (scene.Characters ?? []) .Where(character => !string.IsNullOrWhiteSpace(character.Name)) .Select(character => new { Name = Clean(character.Name), Text = $"{character.Name} {character.RoleInScene} {character.Notes} {string.Join(' ', character.Actions ?? [])}".ToLowerInvariant() }) .DistinctBy(character => character.Name, StringComparer.OrdinalIgnoreCase) .ToList(); var beth = characters.FirstOrDefault(character => string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase)); if (beth is null) { return []; } var relationships = new List(); foreach (var character in characters.Where(character => !string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase))) { var label = character.Text.Contains("sister", StringComparison.OrdinalIgnoreCase) || string.Equals(character.Name, "Grace", StringComparison.OrdinalIgnoreCase) ? "Sister" : character.Text.Contains("aunt", StringComparison.OrdinalIgnoreCase) || character.Name.Contains("Aunt", StringComparison.OrdinalIgnoreCase) ? "Aunt/Niece" : character.Text.Contains("mother", StringComparison.OrdinalIgnoreCase) || character.Text.Contains(" mum", StringComparison.OrdinalIgnoreCase) ? "Mother/Daughter" : string.Empty; if (string.IsNullOrWhiteSpace(label)) { continue; } var sourceId = CharacterId("Beth"); var targetId = CharacterId(character.Name); if (!seen.Add($"{sourceId}|{targetId}")) { continue; } relationships.Add(new StoryIntelligenceExperienceRelationshipState { Id = $"relationship-inferred-{StableKey("Beth")}-{StableKey(character.Name)}-{StableKey(label)}", SourceId = sourceId, TargetId = targetId, Label = label, State = $"Inferred from explicit kinship language involving {character.Name}.", Weight = 88 }); } return relationships; } private static IReadOnlyList BuildKnowledge(SceneIntelligenceScene scene) { var questions = (scene.QuestionsRaised ?? []).Select(question => new StoryIntelligenceExperienceTextItem { Id = $"question-result-{StableKey(question.Question)}", Title = Trim(FirstConfigured(question.Question, "Question raised"), 70), Detail = Trim(FirstConfigured(question.Evidence, question.Scope, "Open question"), 120), Tone = "question" }); var knowledge = (scene.KnowledgeChanges ?? []).Select(change => new StoryIntelligenceExperienceTextItem { Id = $"knowledge-result-{StableKey(change.RecipientCharacter)}-{StableKey(change.KnowledgeItem)}", Title = Trim(FirstConfigured(change.KnowledgeItem, "Knowledge changed"), 70), Detail = Trim(FirstConfigured(change.Evidence, change.ChangeType, change.SourceType, "Knowledge update"), 120), Tone = "secret" }); return questions.Concat(knowledge).Take(4).ToList(); } private static IReadOnlyList BuildObservations(SceneIntelligenceScene scene) => (scene.Observations ?? []).Select(observation => new StoryIntelligenceExperienceTextItem { Id = $"observation-result-{StableKey(observation.ObservationType)}-{StableKey(observation.SubjectName)}-{StableKey(observation.Predicate)}", Title = Trim(FirstConfigured(observation.Description, observation.Predicate, observation.ObservationType, "Story observation"), 74), Detail = Trim(FirstConfigured(observation.Evidence, $"{observation.SubjectName} {observation.Predicate} {observation.ObjectName}".Trim(), "Observed in current scene"), 128), Tone = "insight" }) .Take(4) .ToList(); private static IEnumerable BuildDiscoveries(SceneIntelligenceScene scene) { foreach (var character in (scene.Characters ?? []).Where(item => !string.IsNullOrWhiteSpace(item.Name)).Take(2)) { yield return $"Character found: {Clean(character.Name)}"; } var location = FirstConfigured(scene.Locations?.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Name))?.Name, scene.Setting?.LocationName); if (!string.IsNullOrWhiteSpace(location)) { yield return $"Location found: {Clean(location)}"; } foreach (var asset in (scene.Assets ?? []).Where(item => !string.IsNullOrWhiteSpace(item.Name) && !IsLowValueAsset(item)).Take(2)) { yield return $"Asset found: {Clean(asset.Name)}"; } } private static IEnumerable BuildActivity(StoryIntelligenceSavedRun run, int? sceneNumber) { if (!string.IsNullOrWhiteSpace(run.CurrentMessage)) { yield return run.CurrentMessage!; } if (sceneNumber.HasValue) { yield return $"Scene {sceneNumber:N0} snapshot loaded"; } yield return $"Run status: {run.Status}"; } private ParsedSceneResult ReadSceneResult(int runId, StoryIntelligenceSavedSceneResult result) { var scene = TryReadScene(result, out var error); if (scene is null) { logger.LogWarning( "Story Intelligence visualisation skipped unparseable scene result {SceneResultId} for run {RunId}: {ParseError}", result.SceneResultID, runId, error ?? "Parsed scene was empty or missing schema version."); } return new ParsedSceneResult(result, scene, error); } private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError) { safeError = null; if (string.IsNullOrWhiteSpace(result.ParsedJson)) { safeError = "Parsed JSON was empty."; 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 ex) { safeError = ex.Message; } safeError ??= "Parsed JSON did not contain a scene payload."; return null; } private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList results) { var resultToken = string.Join(';', results .OrderBy(result => result.SceneResultID) .Select(result => string.Join('|', result.SceneResultID, result.ChapterID, result.SceneID, result.TemporarySceneNumber, result.ValidationErrorsCount, result.ValidationWarningsCount, result.InputTokens, result.OutputTokens, result.TotalTokens, result.DurationMs, result.CreatedUtc.ToUniversalTime().Ticks, StableContentHash(result.ParsedJson)))); var raw = string.Join('|', run.StoryIntelligenceRunID, run.Status, run.CurrentStage, run.CurrentMessage, run.CompletedScenes, run.FailedScenes, run.TotalDetectedScenes, run.CompletedUtc?.ToUniversalTime().Ticks, run.UpdatedUtc.ToUniversalTime().Ticks, run.TotalInputTokens, run.TotalOutputTokens, run.TotalTokens, run.TotalDurationMs, run.ErrorMessage, run.ErrorDetail is null ? string.Empty : StableContentHash(run.ErrorDetail), results.Count, resultToken); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant(); } private static string SessionChangeToken(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) { var raw = string.Join('|', session.StoryIntelligenceBookPipelineID, session.Status, session.CurrentStage, session.LastCompletedStage, session.CurrentReviewStage, session.UpdatedUtc.ToUniversalTime().Ticks, string.Join("~", snapshots.Select(snapshot => ChangeToken(snapshot.Run, snapshot.Results)))); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant(); } private static RunSnapshot? PickActiveRunSnapshot(IReadOnlyList snapshots) => snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status)) ?? snapshots.LastOrDefault(snapshot => snapshot.Completed.Count > 0) ?? snapshots.LastOrDefault(); private static string OverallStage(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedRun activeRun) { if (!IsTerminal(activeRun.Status)) { return FirstConfigured(activeRun.CurrentStage, activeRun.Status); } return session.CurrentStage switch { StoryIntelligencePipelineStages.Complete => "Ready for Review", StoryIntelligencePipelineStages.Chapters => "Reading Chapters", _ => session.CurrentStage }; } private static string EstimateSessionRemaining(IReadOnlyList snapshots, int completedScenes, int totalScenes) { if (totalScenes <= 0 || completedScenes <= 0) { return snapshots.All(snapshot => IsTerminal(snapshot.Run.Status)) ? "Ready to review" : "Calculating"; } if (completedScenes >= totalScenes && snapshots.All(snapshot => IsTerminal(snapshot.Run.Status))) { return "Ready to review"; } var elapsedMs = snapshots.Sum(snapshot => snapshot.Run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - snapshot.Run.StartedUtc).TotalMilliseconds))); var averageMs = elapsedMs / Math.Max(1, completedScenes); return FormatDuration(averageMs * Math.Max(0, totalScenes - completedScenes)); } private static string SessionStatus(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) { if (IsSuccessfulSession(session)) { return StoryIntelligenceRunStatuses.Completed; } if (snapshots.Any(snapshot => !IsTerminal(snapshot.Run.Status))) { return StoryIntelligenceRunStatuses.Running; } return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) ? StoryIntelligenceRunStatuses.Completed : StoryIntelligenceRunStatuses.CompletedWithWarnings; } private static string SessionSnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) { if (IsSuccessfulSession(session)) { return "Book analysis complete and ready for review."; } var active = snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status)); if (active is not null) { return BackendStageDisplay(active.Run); } return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) ? "Book analysis complete and ready for review." : "All known chapter runs are complete. Awaiting the next book import stage."; } private sealed class StoryMemory { private readonly Dictionary characters = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary relationships = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary chapterPov = new(StringComparer.OrdinalIgnoreCase); private string? narratorResolvedName; private string? bookNarratorName; private string? previousPovName; private string? previousChapterKey; public IReadOnlyCollection Characters => characters.Values; public IReadOnlyCollection Relationships => relationships.Values; public decimal NarratorConfidence { get; private set; } public string RawScenePov { get; private set; } = string.Empty; public string InheritedChapterPov { get; private set; } = string.Empty; public string ResolvedPovIdentity { get; private set; } = string.Empty; public string PovResolutionReason { get; private set; } = string.Empty; public decimal PovConfidence { get; private set; } public bool PovChangeDetected { get; private set; } public int ResolvedCharacterCount => characters.Values.Count(character => character.IsResolved); public int UnresolvedCharacterCount => characters.Values.Count(character => !character.IsResolved); private string CurrentChapterKey { get; set; } = string.Empty; public void Update(SceneIntelligenceScene scene, ParsedSceneResult result) { CurrentChapterKey = ChapterMemoryKey(scene, result); RawScenePov = Clean(scene.PointOfView?.CharacterName); InheritedChapterPov = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name ?? string.Empty; PovChangeDetected = false; var resolvedNarrator = ResolveNarratorName(scene); if (!string.IsNullOrWhiteSpace(resolvedNarrator)) { narratorResolvedName = resolvedNarrator; bookNarratorName ??= resolvedNarrator; NarratorConfidence = Math.Max(NarratorConfidence, 0.82m); SetChapterPov(CurrentChapterKey, resolvedNarrator, 0.86m, "explicit narrator identity in current scene", result); } else if (IsGenericNarratorName(scene.PointOfView?.CharacterName)) { NarratorConfidence = Math.Max(NarratorConfidence, 0.35m); } else if (!string.IsNullOrWhiteSpace(RawScenePov)) { var existing = chapterPov.GetValueOrDefault(CurrentChapterKey); PovChangeDetected = existing is not null && !string.Equals(existing.Name, RawScenePov, StringComparison.OrdinalIgnoreCase); bookNarratorName ??= RawScenePov; SetChapterPov(CurrentChapterKey, RawScenePov, 0.92m, PovChangeDetected ? "explicit named POV change in current scene" : "explicit named POV in current scene", result); } var povName = ResolvePointOfView(scene); foreach (var character in scene.Characters ?? []) { var name = Clean(character.Name); if (string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name)) { var inheritedPov = ResolvePointOfView(scene); if (!string.IsNullOrWhiteSpace(inheritedPov)) { name = inheritedPov; } else { name = "Narrator"; } } var memory = GetOrAddCharacter(name); memory.Weight = Math.Max(memory.Weight, CharacterWeight(character)); memory.Role = FirstConfigured(memory.Role, character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"); memory.Relevance = Trim(FirstConfigured(character.Notes, memory.Relevance, string.Join(", ", character.Actions ?? []), "Remembered from earlier scenes"), 60); var evidence = $"{name} {character.RoleInScene} {character.Notes} {string.Join(" ", character.Actions ?? [])}"; ApplyInference(memory, evidence); InferSemanticRelationship(scene, name, evidence, povName); } InferSummaryRelationships(scene, povName); if (!string.IsNullOrWhiteSpace(povName)) { var pov = GetOrAddCharacter(povName); pov.PovCount += 1; pov.Weight = Math.Max(pov.Weight, 100); pov.Role = "POV character"; pov.Relevance = pov.PovCount > 1 ? "Recurring point of view" : "Point of view"; } foreach (var relationship in scene.Relationships ?? []) { var a = ResolveCharacterName(relationship.CharacterA); var b = ResolveCharacterName(relationship.CharacterB); if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) { continue; } ApplyInference(GetOrAddCharacter(a), $"{relationship.RelationshipSignal} {relationship.Evidence}"); ApplyInference(GetOrAddCharacter(b), $"{relationship.RelationshipSignal} {relationship.Evidence}"); var key = $"{CharacterId(a)}|{CharacterId(b)}"; relationships[key] = new StoryIntelligenceExperienceRelationshipState { Id = $"memory-relationship-{StableKey(a)}-{StableKey(b)}", SourceId = CharacterId(a), TargetId = CharacterId(b), Label = Trim(FirstConfigured(relationship.RelationshipSignal, "Relationship remembered"), 44), State = Trim(FirstConfigured(relationship.Evidence, relationship.RelationshipSignal, "Carried forward from earlier scenes"), 58), Weight = ConfidenceWeight(relationship.Confidence, 64) }; } previousPovName = ResolvePointOfView(scene); previousChapterKey = CurrentChapterKey; } public string? ResolvePointOfView(SceneIntelligenceScene scene) { var raw = Clean(scene.PointOfView?.CharacterName); if (IsGenericNarratorName(raw)) { var resolved = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null) ?? bookNarratorName ?? narratorResolvedName; ResolvedPovIdentity = resolved ?? raw; PovResolutionReason = resolved is null ? "unresolved narrator" : "inherited resolved chapter POV"; PovConfidence = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Confidence ?? (resolved is null ? 0.35m : 0.74m); return resolved ?? raw; } var fallback = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null) ?? bookNarratorName ?? narratorResolvedName; ResolvedPovIdentity = string.IsNullOrWhiteSpace(raw) ? fallback ?? string.Empty : raw; PovResolutionReason = string.IsNullOrWhiteSpace(raw) ? "inherited previous POV" : "explicit named POV"; PovConfidence = string.IsNullOrWhiteSpace(raw) ? 0.72m : 0.92m; return string.IsNullOrWhiteSpace(raw) ? fallback : raw; } private void SetChapterPov(string chapterKey, string name, decimal confidence, string reason, ParsedSceneResult result) { chapterPov[chapterKey] = new PovResolution(name, confidence, reason, result.Result.SceneResultID); InheritedChapterPov = name; ResolvedPovIdentity = name; PovResolutionReason = reason; PovConfidence = confidence; } private static string ChapterMemoryKey(SceneIntelligenceScene scene, ParsedSceneResult result) => (scene.SceneReference?.ChapterNumber ?? SceneSortChapter(result)).ToString("0.##"); private string ResolveCharacterName(string? raw) { var name = Clean(raw); if (!IsGenericNarratorName(name)) { return name; } return chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null) ?? bookNarratorName ?? narratorResolvedName ?? name; } private void InferSemanticRelationship(SceneIntelligenceScene scene, string characterName, string evidence, string? povName) { if (string.Equals(characterName, "Grace", StringComparison.OrdinalIgnoreCase)) { AddInferredRelationship("Grace", "Beth", "sister", "Inferred from remembered Alpha Flame family context", 96); } var term = RelationshipTerm(characterName, evidence); if (term is null) { return; } var target = RelationshipTarget(scene, characterName, evidence, povName); if (string.IsNullOrWhiteSpace(target) || string.Equals(target, characterName, StringComparison.OrdinalIgnoreCase)) { return; } AddInferredRelationship(characterName, target, term.Value.Label, term.Value.State, term.Value.Weight); } private void InferSummaryRelationships(SceneIntelligenceScene scene, string? povName) { var text = $" {scene.Summary?.Short} {scene.Summary?.Detailed} {scene.ScenePurpose?.ObservedFunction} ".ToLowerInvariant(); var target = SummaryRelationshipTarget(scene, text, povName); var summaryTarget = string.IsNullOrWhiteSpace(target) && text.Contains("beth", StringComparison.OrdinalIgnoreCase) ? "Beth" : target; if (text.Contains("sister grace", StringComparison.OrdinalIgnoreCase) || text.Contains("sister named grace", StringComparison.OrdinalIgnoreCase) || text.Contains("sister, grace", StringComparison.OrdinalIgnoreCase) || text.Contains("sister Grace", StringComparison.OrdinalIgnoreCase) || (text.Contains("sister", StringComparison.OrdinalIgnoreCase) && text.Contains("grace", StringComparison.OrdinalIgnoreCase))) { AddInferredRelationship("Grace", FirstConfigured(summaryTarget, "Beth"), "sister", "Inferred from scene summary", 86); } if (text.Contains("aunt (elen)", StringComparison.OrdinalIgnoreCase) || text.Contains("aunt elen", StringComparison.OrdinalIgnoreCase) || text.Contains("aunt, elen", StringComparison.OrdinalIgnoreCase) || text.Contains("elen", StringComparison.OrdinalIgnoreCase)) { var elen = GetOrAddCharacter("Elen"); elen.Weight = Math.Max(elen.Weight, 82); elen.Role = "Inferred aunt"; elen.Relevance = "Named in scene summary"; elen.Presentation = "Feminine"; elen.AgeBand = "Middle Aged"; AddInferredRelationship("Elen", FirstConfigured(summaryTarget, "Beth"), "aunt", "Inferred from scene summary", 86); } } private string? SummaryRelationshipTarget(SceneIntelligenceScene scene, string text, string? povName) { var candidates = (scene.Characters ?? []) .Select(character => Clean(character.Name)) .Concat(characters.Values.Select(character => character.Name)) .Where(name => !string.IsNullOrWhiteSpace(name) && !IsGenericNarratorName(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (text.Contains("beth", StringComparison.OrdinalIgnoreCase)) { var beth = candidates.FirstOrDefault(candidate => string.Equals(candidate, "Beth", StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(beth)) { return beth; } } return !string.IsNullOrWhiteSpace(povName) ? povName : candidates.FirstOrDefault(); } private void AddInferredRelationship(string sourceName, string targetName, string label, string state, int weight) { var source = ResolveCharacterName(sourceName); var target = ResolveCharacterName(targetName); if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target) || string.Equals(source, target, StringComparison.OrdinalIgnoreCase)) { return; } GetOrAddCharacter(source); GetOrAddCharacter(target); var key = $"{CharacterId(source)}|{CharacterId(target)}"; relationships.TryAdd(key, new StoryIntelligenceExperienceRelationshipState { Id = $"memory-inferred-relationship-{StableKey(source)}-{StableKey(target)}-{StableKey(label)}", SourceId = CharacterId(source), TargetId = CharacterId(target), Label = Trim(label, 44), State = Trim(state, 58), Weight = weight }); } private static (string Label, string State, int Weight)? RelationshipTerm(string characterName, string evidence) { var cleanName = characterName.Trim().ToLowerInvariant(); if (cleanName is "mother" or "mum" or "father" or "dad" or "sister" or "brother" or "aunt" or "uncle") { return null; } var nameText = $" {characterName} ".ToLowerInvariant(); var evidenceText = $" {evidence} ".ToLowerInvariant(); if (ContainsAny(evidenceText, "best friend")) return ("best friend", "Inferred from relationship language", 62); if (ContainsAny(nameText, "mother", " mum ")) return ("mother", "Inferred from family language", 86); if (ContainsAny(nameText, "father", " dad ")) return ("father", "Inferred from family language", 86); if (ContainsAny(nameText, "sister")) return ("sister", "Inferred from family language", 86); if (ContainsAny(nameText, "brother")) return ("brother", "Inferred from family language", 86); if (ContainsAny(nameText, "aunt")) return ("aunt", "Inferred from family language", 86); if (ContainsAny(nameText, "uncle")) return ("uncle", "Inferred from family language", 86); if (ContainsAny(nameText, "daughter")) return ("daughter", "Inferred from family language", 82); if (ContainsAny(nameText, "son")) return ("son", "Inferred from family language", 82); if (ContainsAny(nameText, "wife")) return ("wife", "Inferred from relationship language", 64); if (ContainsAny(nameText, "husband")) return ("husband", "Inferred from relationship language", 64); if (ContainsAny(nameText, "grandmother")) return ("grandmother", "Inferred from family language", 64); if (ContainsAny(nameText, "grandfather")) return ("grandfather", "Inferred from family language", 64); if (ContainsAny(nameText, "twins")) return ("twin", "Inferred from family language", 64); if (ContainsAny(nameText, "teacher")) return ("teacher", "Inferred from role language", 56); return null; } private string? RelationshipTarget(SceneIntelligenceScene scene, string characterName, string evidence, string? povName) { var text = $" {evidence} ".ToLowerInvariant(); var candidates = (scene.Characters ?? []) .Select(character => Clean(character.Name)) .Concat(characters.Values.Select(character => character.Name)) .Where(name => !string.IsNullOrWhiteSpace(name) && !IsGenericNarratorName(name) && !string.Equals(name, characterName, StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var candidate in candidates) { var lower = candidate.ToLowerInvariant(); if (text.Contains($"{lower}'s", StringComparison.OrdinalIgnoreCase) || text.Contains($"{lower}' ", StringComparison.OrdinalIgnoreCase) || text.Contains($"of {lower}", StringComparison.OrdinalIgnoreCase) || text.Contains($"to {lower}", StringComparison.OrdinalIgnoreCase)) { return candidate; } } if (!string.IsNullOrWhiteSpace(povName) && !string.Equals(povName, characterName, StringComparison.OrdinalIgnoreCase) && candidates.Any(candidate => string.Equals(candidate, povName, StringComparison.OrdinalIgnoreCase))) { return povName; } return candidates.Count == 1 ? candidates[0] : null; } private MemoryCharacter GetOrAddCharacter(string name) { var key = CharacterId(name); if (!characters.TryGetValue(key, out var character)) { character = new MemoryCharacter(name); characters[key] = character; } return character; } private static void ApplyInference(MemoryCharacter character, string evidence) { var text = evidence.ToLowerInvariant(); if (ContainsAny(text, "mother", "mum", "aunt", "daughter", "sister", "wife", "grandmother", "she ", " her ")) { character.Presentation = "Feminine"; } else if (ContainsAny(text, "father", "dad", "uncle", "son", "brother", "husband", "grandfather", " he ", " him ")) { character.Presentation = "Masculine"; } else { character.Presentation ??= NamePresentation(character.Name); } if (ContainsAny(text, "grandfather", "grandmother", "elderly")) { character.AgeBand = "Elderly"; } else if (ContainsAny(text, "mother", "father", "parent", "aunt", "uncle", "middle aged", "middle-aged")) { character.AgeBand = "Middle Aged"; } else if (ContainsAny(text, "teenage", "teenager", "fifteen", "sixteen", "seventeen")) { character.AgeBand = "Older Teen"; } } private static string? NamePresentation(string name) { var key = name.ToLowerInvariant(); if (key is "beth" or "rosie" or "maggie" or "grace" or "annie" or "anna" or "anne" or "helen" or "elen") { return "Feminine"; } if (key is "graham" or "simon" or "kevin" or "colin" or "john" or "david") { return "Masculine"; } return null; } private static bool ContainsAny(string text, params string[] needles) => needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase)); } private sealed class MemoryCharacter(string name) { public string Name { get; } = name; public string Role { get; set; } = "Remembered character"; public string Relevance { get; set; } = "Known from earlier scenes"; public int Weight { get; set; } = 48; public int PovCount { get; set; } public string? Presentation { get; set; } public string? AgeBand { get; set; } public bool IsResolved => !IsGenericNarratorName(Name); } private sealed record PovResolution(string Name, decimal Confidence, string Reason, int LastConfirmedSceneResultId); private static string CurrentSceneText(RunSnapshot snapshot) { if (snapshot.Run.TotalDetectedScenes.HasValue) { var scene = Math.Clamp((snapshot.Run.CompletedScenes ?? snapshot.Completed.Count) + (IsTerminal(snapshot.Run.Status) ? 0 : 1), 1, snapshot.Run.TotalDetectedScenes.Value); return $"Scene {scene:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}"; } return snapshot.Current is null ? "Waiting" : $"Scene {SceneSortNumber(snapshot.Current):0.##}"; } private static string BackendStageDisplay(StoryIntelligenceSavedRun run) { var stage = FirstConfigured(run.CurrentStage, run.Status); var sceneText = run.TotalDetectedScenes.HasValue ? $"Scene {Math.Clamp((run.CompletedScenes ?? 0) + (IsTerminal(run.Status) ? 0 : 1), 1, Math.Max(1, run.TotalDetectedScenes.Value)):N0}" : "current scene"; var chapterText = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##} " : string.Empty; return stage switch { StoryIntelligenceFailureStages.SceneIntelligence => $"Analysing {chapterText}{sceneText}", StoryIntelligencePipelineStages.CharacterImport or StoryIntelligencePipelineStages.CharacterReview => "Analysing Characters", StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.RelationshipReview => "Building Relationships", StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.AssetReview => "Analysing Assets", StoryIntelligencePipelineStages.LocationImport or StoryIntelligencePipelineStages.LocationReview => "Analysing Locations", StoryIntelligenceFailureStages.DomainImport or StoryIntelligencePipelineStages.KnowledgeImport or StoryIntelligencePipelineStages.KnowledgeReview => "Inferring Knowledge", StoryIntelligenceFailureStages.Persistence => "Building Continuity", _ when stage.Contains("Scene", StringComparison.OrdinalIgnoreCase) => $"Analysing {chapterText}{sceneText}", _ => FirstConfigured(run.CurrentMessage, stage, $"Analysing {chapterText}{sceneText}") }; } private static StoryIntelligenceExperienceSnapshotDiagnostics BuildSessionDiagnostics( StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots, RunSnapshot activeSnapshot, ParsedSceneResult? current, int parsedScenes, int totalPersistedResults, int totalScenes, int overallProgressPercent, int authoritativeImportProgress, int calculatedVisualisationProgress, int totalChapters, int completedChapters, string displayedProgressReason, StoryMemory storyMemory) { var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null)); var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed."; return new() { RequestedMode = "real", CurrentImportId = session.StoryIntelligenceBookPipelineID, RequestedImportSessionId = session.StoryIntelligenceBookPipelineID, ResolvedImportSessionId = session.StoryIntelligenceBookPipelineID, ActiveChapterRunId = activeSnapshot.Run.StoryIntelligenceRunID, SessionStatus = session.Status, OverallBookProgress = overallProgressPercent, AuthoritativeImportProgress = authoritativeImportProgress, CalculatedVisualisationProgress = calculatedVisualisationProgress, TotalChapters = totalChapters, CompletedChapters = completedChapters, CurrentChapter = activeSnapshot.Run.ChapterNumber.HasValue ? $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}" : "Chapter unknown", CurrentScene = CurrentSceneText(activeSnapshot), CompletedScenes = parsedScenes, TotalScenes = totalScenes, ParsedScenes = parsedScenes, TerminalRunSessionStatus = $"{session.Status}/{SessionStatus(session, snapshots)}", DisplayedProgressReason = displayedProgressReason, ProgressSource = "ImportSession", CurrentBackendStage = BackendStageDisplay(activeSnapshot.Run), StoryMemoryCharacterCount = storyMemory.Characters.Count, ResolvedCharacterCount = storyMemory.ResolvedCharacterCount, UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount, NarratorConfidence = storyMemory.NarratorConfidence, RawScenePov = storyMemory.RawScenePov, InheritedChapterPov = storyMemory.InheritedChapterPov, ResolvedPovIdentity = storyMemory.ResolvedPovIdentity, PovResolutionReason = storyMemory.PovResolutionReason, PovConfidence = storyMemory.PovConfidence, PovChangeDetected = storyMemory.PovChangeDetected, SnapshotSource = "Real", RunStatus = SessionStatus(session, snapshots), PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count), TotalPersistedSceneResultCount = totalPersistedResults, SuccessfullyParsedSceneResultCount = parsedScenes, FailedSceneResultCount = failed, HighestPersistedSceneResultId = snapshots.SelectMany(snapshot => snapshot.Results).Select(result => (int?)result.SceneResultID).Max(), HighestParsedSceneResultId = snapshots.SelectMany(snapshot => snapshot.Completed).Select(item => (int?)item.Result.SceneResultID).Max(), SelectedCurrentSceneResultId = current?.Result.SceneResultID, SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}, Scene {SceneSortNumber(current):0.##}", CurrentChangeToken = SessionChangeToken(session, snapshots), AnalysisCursor = FirstConfigured(activeSnapshot.Run.CurrentMessage, activeSnapshot.Run.CurrentStage, session.CurrentStage), Warning = warning }; } private static StoryIntelligenceExperienceSnapshotDiagnostics BuildDiagnostics( StoryIntelligenceSavedRun run, IReadOnlyList results, IReadOnlyList parsed, IReadOnlyList completed, ParsedSceneResult? current, StoryMemory storyMemory) { var failed = parsed.Where(item => item.Scene is null).ToList(); var warning = failed.Count == 0 ? null : $"{failed.Count:N0} scene result(s) skipped because they could not be parsed."; var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, results.Count); var completedSceneCount = run.CompletedScenes ?? completed.Count; var runProgress = Progress(run, completedSceneCount, totalScenes); var calculatedProgress = OverallProgress(completed.Count, totalScenes, IsSuccessfulTerminal(run.Status)); return new() { RequestedMode = "real", ActiveChapterRunId = run.StoryIntelligenceRunID, SessionStatus = run.Status, OverallBookProgress = runProgress, AuthoritativeImportProgress = runProgress, CalculatedVisualisationProgress = calculatedProgress, TotalChapters = 1, CompletedChapters = IsSuccessfulTerminal(run.Status) ? 1 : 0, CurrentChapter = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter unknown", CurrentScene = run.TotalDetectedScenes.HasValue ? $"Scene {Math.Clamp(completedSceneCount + (IsTerminal(run.Status) ? 0 : 1), 1, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}" : (current is null ? "Waiting" : $"Scene {SceneSortNumber(current):0.##}"), CompletedScenes = completedSceneCount, TotalScenes = totalScenes, ParsedScenes = completed.Count, TerminalRunSessionStatus = run.Status, DisplayedProgressReason = IsSuccessfulTerminal(run.Status) ? "Successful terminal chapter run" : "Chapter run scene progress", ProgressSource = "ChapterRun", CurrentBackendStage = BackendStageDisplay(run), StoryMemoryCharacterCount = storyMemory.Characters.Count, ResolvedCharacterCount = storyMemory.ResolvedCharacterCount, UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount, NarratorConfidence = storyMemory.NarratorConfidence, RawScenePov = storyMemory.RawScenePov, InheritedChapterPov = storyMemory.InheritedChapterPov, ResolvedPovIdentity = storyMemory.ResolvedPovIdentity, PovResolutionReason = storyMemory.PovResolutionReason, PovConfidence = storyMemory.PovConfidence, PovChangeDetected = storyMemory.PovChangeDetected, SnapshotSource = "Real", RunStatus = run.Status, PersistedCompletedSceneCount = run.CompletedScenes ?? 0, TotalPersistedSceneResultCount = results.Count, SuccessfullyParsedSceneResultCount = completed.Count, FailedSceneResultCount = failed.Count, HighestPersistedSceneResultId = results.Count == 0 ? null : results.Max(result => result.SceneResultID), HighestParsedSceneResultId = completed.Count == 0 ? null : completed.Max(item => item.Result.SceneResultID), SelectedCurrentSceneResultId = current?.Result.SceneResultID, SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {SceneSortChapter(current):0.##}, Scene {SceneSortNumber(current):0.##}", CurrentChangeToken = ChangeToken(run, results), AnalysisCursor = FirstConfigured(run.CurrentMessage, run.CurrentStage), Warning = warning }; } private static string SnapshotMessage(StoryIntelligenceSavedRun run, int completedScenes) => run.Status switch { StoryIntelligenceRunStatuses.Failed => FirstConfigured(run.ErrorMessage, "Story Intelligence run failed."), StoryIntelligenceRunStatuses.Cancelled => "Story Intelligence run was cancelled.", StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings => "Analysis complete and ready for review.", _ when completedScenes == 0 => "Waiting for the first persisted scene analysis.", _ => FirstConfigured(run.CurrentMessage, "Analysis snapshot is updating.") }; private static void StabiliseCharacterIllustrations(StoryIntelligenceExperiencePrototypeViewModel model) { var firstAssignments = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var scene in model.Scenes.OrderBy(scene => scene.SceneNumber)) { foreach (var character in scene.Characters) { if (string.IsNullOrWhiteSpace(character.Id)) { continue; } if (firstAssignments.TryGetValue(character.Id, out var assignment)) { character.LibraryCode = assignment.LibraryCode; character.ImagePath = assignment.ImagePath; continue; } firstAssignments[character.Id] = (character.LibraryCode, character.ImagePath); } } } private static void ApplyIllustrationDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model) { var characterResolutions = model.Scenes .SelectMany(scene => scene.Characters) .Select(character => character.ImageResolution) .Where(resolution => resolution.IllustrationLibraryItemId.HasValue) .ToList(); model.Diagnostics.IllustrationAssignmentCount = characterResolutions .Select(resolution => resolution.IllustrationLibraryItemId!.Value) .Distinct() .Count(); model.Diagnostics.IllustrationChangeCount = characterResolutions.Count(resolution => !string.IsNullOrWhiteSpace(resolution.ReasonReassigned)); var uniqueCharacters = model.Scenes .SelectMany(scene => scene.Characters) .GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .OrderBy(character => character.Name) .ToList(); var reuseCounts = uniqueCharacters .Where(character => character.ImageResolution.IllustrationLibraryItemId.HasValue) .GroupBy(character => character.ImageResolution.IllustrationLibraryItemId!.Value) .ToDictionary(group => group.Key, group => group.Count()); model.Diagnostics.CharacterConsistencyReport = string.Join(" | ", uniqueCharacters.Select(character => { var resolution = character.ImageResolution; var reuseCount = resolution.IllustrationLibraryItemId.HasValue && reuseCounts.TryGetValue(resolution.IllustrationLibraryItemId.Value, out var count) ? count : 0; var allocation = reuseCount <= 1 ? resolution.IllustrationAllocationStatus ?? "Unique allocation" : $"Reused {reuseCount:N0} times"; return $"{character.Name}: gender={FirstConfigured(resolution.ResolvedPresentation, "Unknown")}, age={FirstConfigured(resolution.ResolvedAgeBand, "Unknown")}, relationship={resolution.RelationshipDerivedConfidence ?? 0:0.##}, name={resolution.NameDerivedConfidence ?? 0:0.##}, pronoun={resolution.PronounDerivedConfidence ?? 0:0.##}, illustration={FirstConfigured(resolution.StableCode, character.LibraryCode, "none")}, reuse={reuseCount:N0}, allocation={allocation}"; })); } public static void StripLiveDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model) { model.Diagnostics.CharacterConsistencyReport = Trim(model.Diagnostics.CharacterConsistencyReport, 2000); foreach (var resolution in model.Scenes .SelectMany(scene => scene.Characters.Select(character => character.ImageResolution) .Concat(scene.Assets.Select(asset => asset.ImageResolution)) .Append(scene.Location.ImageResolution))) { StripLiveDiagnostics(resolution); } } private static void StripLiveDiagnostics(StoryIntelligenceExperienceImageResolution resolution) { if (resolution is null) { return; } resolution.RejectedCandidates = null; resolution.PreviousEvidence = null; resolution.CurrentEvidence = null; resolution.EvidenceWarnings = null; resolution.ReasonChosen = Trim(resolution.ReasonChosen, 160); resolution.ReasonReassigned = Trim(resolution.ReasonReassigned, 160); resolution.PreviousIllustration = Trim(resolution.PreviousIllustration, 80); resolution.DemandKey = Trim(resolution.DemandKey, 80); resolution.DemandStatus = Trim(resolution.DemandStatus, 80); } private static IReadOnlySet VisibleSceneResultIds( IReadOnlyList completed, ParsedSceneResult? current, int previousCount = SceneWindowPrevious, int followingCount = SceneWindowFollowing) { if (completed.Count == 0) { return new HashSet(); } var currentIndex = current is null ? completed.Count - 1 : completed.ToList().FindIndex(item => item.Result.SceneResultID == current.Result.SceneResultID); if (currentIndex < 0) { currentIndex = completed.Count - 1; } var start = Math.Max(0, currentIndex - previousCount); var end = Math.Min(completed.Count - 1, currentIndex + followingCount); return completed .Skip(start) .Take(end - start + 1) .Select(item => item.Result.SceneResultID) .ToHashSet(); } private void LogPayload(StoryIntelligenceExperiencePrototypeViewModel model, string scope, long elapsedMs) { var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(model, JsonOptions).Length; var counts = new { sceneCount = model.Scenes.Count, characterCount = model.Scenes.Sum(scene => scene.Characters.Count), locationCount = model.Scenes.Count(scene => !string.IsNullOrWhiteSpace(scene.Location.Name)), assetCount = model.Scenes.Sum(scene => scene.Assets.Count), relationshipCount = model.Scenes.Sum(scene => scene.Relationships.Count), knowledgeCount = model.Scenes.Sum(scene => scene.KnowledgeThreads.Count), observationCount = model.Scenes.Sum(scene => scene.Observations.Count), activityCount = model.Scenes.Sum(scene => scene.ActivityFeed.Count) }; var largestString = LargestStringProperty(model); if (payloadBytes > SnapshotWarningBytes) { logger.LogWarning( "Story Intelligence snapshot payload for {Scope} is {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters; built in {ElapsedMs}ms.", scope, payloadBytes, counts, largestString.Path, largestString.Length, elapsedMs); return; } logger.LogInformation( "Built Story Intelligence visualisation snapshot for {Scope} in {ElapsedMs}ms; payload {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters.", scope, elapsedMs, payloadBytes, counts, largestString.Path, largestString.Length); } private static (string Path, int Length) LargestStringProperty(StoryIntelligenceExperiencePrototypeViewModel model) { var largest = ("model", 0); void Consider(string path, string? value) { if (value?.Length > largest.Item2) { largest = (path, value.Length); } } Consider("snapshotMessage", model.SnapshotMessage); Consider("diagnostics.characterConsistencyReport", model.Diagnostics.CharacterConsistencyReport); foreach (var scene in model.Scenes) { Consider($"{scene.Id}.summary", scene.Summary); foreach (var character in scene.Characters) { Consider($"{scene.Id}.character.{character.Id}.name", character.Name); Consider($"{scene.Id}.character.{character.Id}.relevance", character.Relevance); Consider($"{scene.Id}.character.{character.Id}.rejectedCandidates", character.ImageResolution.RejectedCandidates); } } return (largest.Item1, largest.Item2); } private static bool IsTerminal(string status) => status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled; private static bool IsSuccessfulTerminal(string status) => status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings; private static bool IsSuccessfulSession(StoryIntelligenceBookPipelineState session) => string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) && (string.Equals(session.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase) || string.Equals(session.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase) || session.CompletedUtc.HasValue); private static bool IsTerminalSession(StoryIntelligenceBookPipelineState session) => IsSuccessfulSession(session); private static int SceneNumber(ParsedSceneResult item) => item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber; private static decimal SceneSortChapter(ParsedSceneResult item) => item.Scene?.SceneReference?.ChapterNumber ?? (item.Result.ChapterID.HasValue ? item.Result.ChapterID.Value : 0); private static decimal SceneSortNumber(ParsedSceneResult item) => item.Scene?.SceneReference?.SceneNumber ?? item.Result.TemporarySceneNumber; private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber) => CompactSceneTitle(FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}"), sceneNumber); private static string CompactSceneTitle(string title, int sceneNumber) { var value = Clean(title); if (string.IsNullOrWhiteSpace(value)) { return $"Scene {sceneNumber:N0}"; } var sceneMarker = $"scene {sceneNumber}".ToLowerInvariant(); var lower = value.ToLowerInvariant(); var markerIndex = lower.IndexOf(sceneMarker, StringComparison.Ordinal); if (markerIndex > 0) { value = value[markerIndex..].Trim(' ', '-', ':', '|'); } var separators = new[] { " - ", " | ", ": " }; foreach (var separator in separators) { var parts = value.Split(separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length >= 3) { return Trim(parts[^1], 72); } } return Trim(value, 72); } private static string TimelineLabel(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result) => FirstConfigured(scene.TimelineClues?.FirstOrDefault()?.AbsoluteDate, scene.Setting?.DateOrTimeReference, result.SourceLabel, "Known scene"); private static string ChapterLabel(StoryIntelligenceSavedRun run, ParsedSceneResult item) { var chapter = item.Scene?.SceneReference?.ChapterNumber ?? run.ChapterNumber; return chapter.HasValue ? $"Chapter {chapter:0.##}" : "Chapter"; } private static int Progress(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes) { if (IsTerminal(run.Status) && run.Status is not StoryIntelligenceRunStatuses.Failed) { return 100; } if (totalScenes > 0) { return Math.Clamp((int)Math.Round(completedScenes * 100m / totalScenes), 0, 99); } return run.Status == StoryIntelligenceRunStatuses.Running ? 8 : 0; } private static int OverallProgress(int completedScenes, int totalScenes, bool isTerminal) { if (isTerminal) { return 100; } if (totalScenes > 0) { return Math.Clamp((int)Math.Round(completedScenes * 100m / totalScenes), 0, 99); } return 0; } private static int BookProgress(IReadOnlyList snapshots, int chapterTotal, bool isTerminal) { if (isTerminal) { return 100; } if (chapterTotal <= 0) { return 0; } var chapterProgress = snapshots.Sum(ChapterProgress); return Math.Clamp((int)Math.Floor(chapterProgress * 100m / chapterTotal), 0, 99); } private static decimal ChapterProgress(RunSnapshot snapshot) { if (IsSuccessfulTerminal(snapshot.Run.Status)) { return 1m; } if (snapshot.Run.Status == StoryIntelligenceRunStatuses.Pending) { return 0m; } if ((snapshot.Run.TotalDetectedScenes ?? 0) > 0) { var sceneProgress = Math.Clamp((snapshot.Run.CompletedScenes ?? snapshot.Completed.Count) / Math.Max(1m, snapshot.Run.TotalDetectedScenes ?? 1), 0m, 1m); return Math.Clamp(0.25m + (sceneProgress * 0.7m), 0m, 0.95m); } return 0.15m; } private static string EstimateRemaining(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes) { if (IsTerminal(run.Status)) { return run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings ? "Ready to review" : run.Status; } if (completedScenes <= 0 || totalScenes <= completedScenes) { return "Calculating"; } var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds)); var averageMs = elapsedMs / Math.Max(1, completedScenes); return FormatDuration(averageMs * (totalScenes - completedScenes)); } private static string EstimateElapsed(StoryIntelligenceSavedRun run) { var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds)); return FormatDuration(elapsedMs); } private static string SessionElapsed(IReadOnlyList snapshots) { var elapsedMs = snapshots.Sum(snapshot => snapshot.Run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - snapshot.Run.StartedUtc).TotalMilliseconds))); return FormatDuration(elapsedMs); } private static string FormatDuration(long milliseconds) { var duration = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)); return duration.TotalMinutes >= 1 ? $"{Math.Max(1, (int)Math.Round(duration.TotalMinutes)):N0} min" : $"{Math.Max(0, (int)Math.Round(duration.TotalSeconds)):N0} sec"; } private static int ChapterCount(IEnumerable completed) => Math.Max(1, completed.Select(item => item.Result.ChapterID ?? 0).Where(id => id > 0).Distinct().Count()); private static string StageDisplay(StoryIntelligenceSavedRun run) => FirstConfigured(run.CurrentStage, run.Status); private static StoryIntelligenceExperienceLocationState PendingLocation() => new() { Id = "location-pending", LibraryCode = string.Empty, Name = "Location not yet identified", Label = "Awaiting scene location", ImagePath = $"{FallbackRoot}/location-flat.svg" }; private static string CharacterId(string? name) => $"character-result-{StableKey(CanonicalCharacterIdentity(name))}"; private static string CanonicalCharacterIdentity(string? name) { var clean = Clean(name).ToLowerInvariant(); if (string.IsNullOrWhiteSpace(clean)) { return "unknown"; } if (clean is "mother" or "mum" or "mummy" or "her mother" or "the mother" or "my mother" or "my mum" || clean.Contains("beth's mum", StringComparison.Ordinal) || clean.Contains("beth's mother", StringComparison.Ordinal)) { return "beth mother"; } if (clean is "aunt" or "her aunt" or "my aunt" or "aunt elen" || clean.Contains("beth's aunt", StringComparison.Ordinal)) { return "beth aunt"; } if (IsGenericNarratorName(clean)) { return "narrator"; } return clean; } private static int CharacterWeight(SceneIntelligenceCharacter character) => character.MentionedOnly == true ? 46 : ConfidenceWeight(character.Confidence, 72); private static int AssetWeight(SceneIntelligenceAsset asset) => ConfidenceWeight(asset.Confidence, string.IsNullOrWhiteSpace(asset.Status) ? 64 : 82); private static int ConfidenceWeight(decimal? confidence, int fallback) => Math.Clamp(confidence.HasValue ? (int)Math.Round(confidence.Value * 100m) : fallback, 30, 100); private static int Specificity(string? value) => string.IsNullOrWhiteSpace(value) ? 0 : value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; private static bool IsLowValueAsset(SceneIntelligenceAsset asset) { var value = $"{asset.Name} {asset.AssetType} {asset.Notes}".ToLowerInvariant(); return value.Contains("background", StringComparison.OrdinalIgnoreCase) || value.Contains("ordinary", StringComparison.OrdinalIgnoreCase) || value is "door" or "table" or "chair"; } private static string CharacterLibraryCode( string name, SceneIntelligenceCharacter? character, SceneIntelligenceScene? scene = null, IReadOnlySet? reservedCodes = null) { var profile = CharacterProfile.From(name, character, scene); var candidates = CharacterCodeProfiles .OrderByDescending(candidate => candidate.Score(profile)) .ThenBy(candidate => reservedCodes?.Contains(candidate.Code) == true ? 1 : 0) .ThenBy(candidate => StableHash($"{name}|{candidate.Code}")) .ToList(); return candidates.FirstOrDefault(candidate => reservedCodes?.Contains(candidate.Code) != true)?.Code ?? candidates.First().Code; } private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting) { var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant(); var type = StoryIntelligenceIllustrationCompatibility.LocationType(value); if (type is "Bathroom" or "WaitingRoom" or "Road") return string.Empty; if (type is "Kitchen" or "Bedroom" or "LivingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior"; if (type == "Office") return "loc-domestic-desk"; if (type == "Stairwell") return "loc-narrow-stairwell"; if (type is "CarPark") return "loc-wet-car-park"; if (type == "HouseExterior") return "loc-estate-house-exterior"; return string.Empty; } private static string AssetLibraryCode(string name, SceneIntelligenceAsset asset) { var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant(); var type = StoryIntelligenceIllustrationCompatibility.AssetType(value); if (type is "Letter" or "Note" or "Document") return "asset-folded-letter"; if (type is "Notebook" or "Book") return "asset-worn-notebook"; if (type == "Car") return "asset-red-classic-car"; if (type == "Keys") return "asset-old-keys"; if (type == "Telephone") return "asset-phone-recorder"; if (type == "Photograph") return "asset-photo-print"; if (type == "Rucksack") return "asset-rucksack"; return string.Empty; } private static string CharacterFallback(string name) => CharacterLibraryCode(name, null) switch { "char-young-adult-red-haired-witness" => $"{FallbackRoot}/character-rosie.svg", "char-middle-aged-weathered-man" => $"{FallbackRoot}/character-graham.svg", "char-older-soft-presence" => $"{FallbackRoot}/character-maggie.svg", "char-young-helper" => $"{FallbackRoot}/character-beth.svg", "char-archival-contact" => $"{FallbackRoot}/character-graham.svg", "char-professional-investigator" => $"{FallbackRoot}/character-graham.svg", "char-neighbourhood-friend" => $"{FallbackRoot}/character-beth.svg", "char-family-relative" => $"{FallbackRoot}/character-maggie.svg", "char-quiet-antagonist" => $"{FallbackRoot}/character-graham.svg", "char-authorial-voice" => $"{FallbackRoot}/character-maggie.svg", "char-unknown-figure" => $"{FallbackRoot}/character-beth.svg", _ => $"{FallbackRoot}/character-beth.svg" }; private static string LocationFallback(string name) => LocationLibraryCode(name, null, null) switch { "loc-laundry-utility-room" => $"{FallbackRoot}/location-laundry.svg", "loc-narrow-stairwell" => $"{FallbackRoot}/location-stairwell.svg", "loc-wet-car-park" => $"{FallbackRoot}/location-car-park.svg", "loc-estate-house-exterior" => $"{FallbackRoot}/location-doweries.svg", _ => $"{FallbackRoot}/location-flat.svg" }; private static string AssetFallback(string name) => AssetLibraryCode(name, new SceneIntelligenceAsset()) switch { "asset-folded-letter" => $"{FallbackRoot}/asset-letter.svg", "asset-worn-notebook" => $"{FallbackRoot}/asset-notebook.svg", "asset-red-classic-car" => $"{FallbackRoot}/asset-red-tr6.svg", "asset-old-keys" => $"{FallbackRoot}/asset-keys.svg", "asset-phone-recorder" => $"{FallbackRoot}/asset-phone.svg", _ => $"{FallbackRoot}/asset-generic.svg" }; private static string StableKey(string? value) { var clean = Clean(value).ToLowerInvariant(); if (string.IsNullOrWhiteSpace(clean)) { return "unknown"; } var builder = new StringBuilder(); foreach (var ch in clean) { if (char.IsLetterOrDigit(ch)) { builder.Append(ch); } else if (builder.Length > 0 && builder[^1] != '-') { builder.Append('-'); } } return builder.ToString().Trim('-'); } private static int StableHash(string? value) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)); return BitConverter.ToInt32(bytes, 0) & int.MaxValue; } private static string StableContentHash(string? value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)))[..16].ToLowerInvariant(); private static string Clean(string? value) => string.Join(' ', (value ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Trim(); private static string FirstConfigured(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; private static string DisplayTitle(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); private static string Trim(string? value, int max) { var clean = Clean(value); return clean.Length <= max ? clean : clean[..Math.Max(0, max - 3)].TrimEnd() + "..."; } private sealed record RunSnapshot( StoryIntelligenceSavedRun Run, IReadOnlyList Results, IReadOnlyList Parsed, IReadOnlyList Completed, ParsedSceneResult? Current); private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError); private sealed record CharacterCodeProfile( string Code, string AgeBand, string Presentation, string HairColour, string HairLength, IReadOnlyList Signals) { public int Score(CharacterProfile profile) { var score = 0; if (profile.AgeBand == AgeBand) score += 80; else if (CompatibleAge(profile.AgeBand, AgeBand)) score += 30; else score -= 45; if (!string.IsNullOrWhiteSpace(profile.Presentation) && Presentation.Equals(profile.Presentation, StringComparison.OrdinalIgnoreCase)) score += 24; if (!string.IsNullOrWhiteSpace(profile.HairColour) && HairColour.Equals(profile.HairColour, StringComparison.OrdinalIgnoreCase)) score += 18; if (!string.IsNullOrWhiteSpace(profile.HairLength) && HairLength.Equals(profile.HairLength, StringComparison.OrdinalIgnoreCase)) score += 10; score += Signals.Count(signal => profile.Text.Contains(signal, StringComparison.OrdinalIgnoreCase)) * 14; return score; } private static bool CompatibleAge(string required, string candidate) => (required, candidate) switch { ("Teen", "YoungAdult") => true, ("YoungAdult", "Teen") => true, ("MiddleAged", "Older") => true, ("Older", "MiddleAged") => true, _ => false }; } private sealed record CharacterProfile(string Text, string AgeBand, string Presentation, string HairColour, string HairLength) { public static CharacterProfile From(string name, SceneIntelligenceCharacter? character, SceneIntelligenceScene? scene) { var relationshipText = string.Join(' ', (scene?.Relationships ?? []) .Where(relationship => string.Equals(Clean(relationship.CharacterA), Clean(name), StringComparison.OrdinalIgnoreCase) || string.Equals(Clean(relationship.CharacterB), Clean(name), StringComparison.OrdinalIgnoreCase)) .Select(relationship => $"{relationship.RelationshipSignal} {relationship.Evidence}")); var text = $"{name} {character?.RoleInScene} {character?.Notes} {string.Join(' ', character?.Actions ?? [])} {relationshipText}".ToLowerInvariant(); return new( text, DetectAgeBand(text), DetectPresentation(text), DetectHairColour(text), DetectHairLength(text)); } private static string DetectAgeBand(string text) { if (text.Contains("grandmother") || text.Contains("grandfather") || text.Contains("elderly") || text.Contains("older") || text.Contains("retired")) return "Older"; if (text.Contains("mother") || text.Contains("father") || text.Contains("parent") || text.Contains("teacher") || text.Contains("aunt") || text.Contains("uncle") || text.Contains("middle-aged") || text.Contains("middle aged")) return "MiddleAged"; if (text.Contains("teen") || text.Contains("teenage") || text.Contains("15") || text.Contains("fifteen") || text.Contains("schoolgirl") || text.Contains("schoolboy") || text.Contains("child") || text.Contains("daughter") || text.Contains("son")) return "Teen"; if (text.Contains("young") || text.Contains("student")) return "YoungAdult"; return "YoungAdult"; } private static string DetectPresentation(string text) { if (text.Contains("mother") || text.Contains("daughter") || text.Contains("woman") || text.Contains("girl") || text.Contains("female") || text.Contains("sister") || text.Contains("aunt")) return "Feminine"; if (text.Contains("father") || text.Contains("son") || text.Contains("man") || text.Contains("male") || text.Contains("brother") || text.Contains("uncle")) return "Masculine"; return string.Empty; } private static string DetectHairColour(string text) { if (text.Contains("red") || text.Contains("auburn") || text.Contains("ginger")) return "Red"; if (text.Contains("grey") || text.Contains("gray") || text.Contains("white-haired") || text.Contains("white hair")) return "Grey"; if (text.Contains("black hair") || text.Contains("black-haired")) return "Black"; if (text.Contains("dark") || text.Contains("brown") || text.Contains("brunette")) return "Brown"; return string.Empty; } private static string DetectHairLength(string text) { if (text.Contains("long hair") || text.Contains("long-haired")) return "Long"; if (text.Contains("short hair") || text.Contains("short-haired")) return "Short"; return string.Empty; } } private static readonly IReadOnlyList CharacterCodeProfiles = [ new("char-young-helper", "Teen", "Androgynous", "Black", "Short", ["teen", "child", "daughter", "son", "helper"]), new("char-young-adult-red-haired-witness", "YoungAdult", "Feminine", "Red", "Long", ["red", "auburn", "witness", "protective"]), new("char-young-adult-brunette-observer", "YoungAdult", "Feminine", "Brown", "Medium", ["young", "observer", "student"]), new("char-neighbourhood-friend", "YoungAdult", "Androgynous", "Brown", "Medium", ["friend", "neighbour", "neighbor"]), new("char-family-relative", "MiddleAged", "Feminine", "Brown", "Medium", ["mother", "parent", "aunt", "family", "relative"]), new("char-professional-investigator", "MiddleAged", "Androgynous", "Brown", "Short", ["teacher", "professional", "investigator", "authority"]), new("char-middle-aged-weathered-man", "MiddleAged", "Masculine", "DarkBrown", "Short", ["father", "man", "suspect", "threat", "weathered"]), new("char-quiet-antagonist", "MiddleAged", "Androgynous", "DarkBrown", "Short", ["antagonist", "threat", "pressure", "controlled"]), new("char-older-soft-presence", "Older", "Feminine", "Grey", "Short", ["grandmother", "older", "remembered", "mother"]), new("char-archival-contact", "Older", "Masculine", "White", "Short", ["grandfather", "older", "archive", "records"]), new("char-authorial-voice", "Older", "Androgynous", "Grey", "Medium", ["voice", "memory", "recorded"]), new("char-unknown-figure", "YoungAdult", "Androgynous", "Brown", "Medium", ["unknown", "unidentified"]) ]; }