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 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 scenes = BuildSceneStates(run, parsed, completed, current, storyMemory: storyMemory).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); } logger.LogInformation("Built Story Intelligence visualisation snapshot for run {RunId} in {ElapsedMs}ms.", 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 overallProgressPercent = BookProgress(runSnapshots, bookChapterTotal, isTerminal: false); var completedChapterRuns = runSnapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); var isTerminal = runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status)); var isSuccessfullyComplete = runSnapshots.All(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); overallProgressPercent = BookProgress(runSnapshots, bookChapterTotal, isSuccessfullyComplete); var overallElapsed = SessionElapsed(runSnapshots); var overallRemaining = 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(); 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) .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, 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); logger.LogInformation("Built Story Intelligence import session visualisation snapshot for session {ImportSessionId} in {ElapsedMs}ms.", 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) { 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; 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).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 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(memoryRelationships) .OrderByDescending(item => item.Weight) .Take(4) .ToList(); } 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 (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) { 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, 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, CurrentChapter = activeSnapshot.Run.ChapterNumber.HasValue ? $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}" : "Chapter unknown", CurrentScene = CurrentSceneText(activeSnapshot), CompletedScenes = parsedScenes, TotalScenes = totalScenes, 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."; return new() { RequestedMode = "real", ActiveChapterRunId = run.StoryIntelligenceRunID, SessionStatus = run.Status, OverallBookProgress = Progress(run, run.CompletedScenes ?? completed.Count, Math.Max(run.TotalDetectedScenes ?? 0, results.Count)), CurrentChapter = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter unknown", CurrentScene = run.TotalDetectedScenes.HasValue ? $"Scene {Math.Clamp((run.CompletedScenes ?? completed.Count) + (IsTerminal(run.Status) ? 0 : 1), 1, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}" : (current is null ? "Waiting" : $"Scene {SceneSortNumber(current):0.##}"), CompletedScenes = run.CompletedScenes ?? completed.Count, TotalScenes = Math.Max(run.TotalDetectedScenes ?? 0, results.Count), 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}"; })); } 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 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 = "loc-domestic-desk", Name = "Location not yet identified", Label = "Awaiting scene location", ImagePath = $"{FallbackRoot}/location-flat.svg" }; private static string CharacterId(string? name) => $"character-result-{StableKey(name)}"; 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(); if (value.Contains("bathroom") || value.Contains("toilet") || value.Contains("washroom")) return "loc-laundry-utility-room"; if (value.Contains("kitchen") || value.Contains("desk") || value.Contains("office") || value.Contains("study") || value.Contains("library")) return "loc-domestic-desk"; if (value.Contains("waiting room") || value.Contains("bedroom") || value.Contains("sitting room") || value.Contains("living room")) return "loc-small-flat-interior"; if (value.Contains("laundry") || value.Contains("utility")) return "loc-laundry-utility-room"; if (value.Contains("stair")) return "loc-narrow-stairwell"; if (value.Contains("car park") || value.Contains("parking")) return "loc-wet-car-park"; if (value.Contains("flat") || value.Contains("apartment")) return "loc-small-flat-interior"; if (value.Contains("house") || value.Contains("estate")) return "loc-estate-house-exterior"; return "loc-domestic-desk"; } private static string AssetLibraryCode(string name, SceneIntelligenceAsset asset) { var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant(); if (value.Contains("letter")) return "asset-folded-letter"; if (value.Contains("notebook") || value.Contains("diary")) return "asset-worn-notebook"; if (value.Contains("car") || value.Contains("vehicle")) return "asset-red-classic-car"; if (value.Contains("key")) return "asset-old-keys"; if (value.Contains("phone") || value.Contains("record")) return "asset-phone-recorder"; if (value.Contains("photo")) return "asset-photo-print"; if (value.Contains("map")) return "asset-map-pin"; return "asset-locked-box"; } 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-letter.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"]) ]; }