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> ListRunOptionsAsync(int userId); } public sealed class StoryIntelligenceVisualisationSnapshotService( IStoryIntelligenceResultRepository repository, IIllustrationLibraryService illustrationLibrary, 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 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)) .OrderBy(result => result.TemporarySceneNumber) .ThenBy(result => result.SceneResultID) .ToList(); var parsed = results .Select(result => new ParsedSceneResult(result, TryReadScene(result))) .ToList(); var completed = parsed .Where(item => item.Scene is not null && item.Result.ValidationErrorsCount == 0) .ToList(); var current = PickCurrentScene(run, completed); var scenes = BuildSceneStates(run, parsed, completed, current).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), ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"), BookTitle = DisplayTitle(run.BookTitle, "Untitled book"), Scenes = scenes }; await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); logger.LogInformation("Built Story Intelligence visualisation snapshot for run {RunId} in {ElapsedMs}ms.", runId, stopwatch.ElapsedMilliseconds); return model; } private static IEnumerable BuildSceneStates( StoryIntelligenceSavedRun run, IReadOnlyList allResults, IReadOnlyList completed, ParsedSceneResult? current) { var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count); var completedScenes = run.CompletedScenes ?? completed.Count; foreach (var item in completed) { var scene = item.Scene!; var sceneNumber = SceneNumber(item); var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID; yield return new StoryIntelligenceExperienceSceneState { Id = $"scene-result-{item.Result.SceneResultID}", SceneNumber = sceneNumber, Title = SceneTitle(scene, item.Result, sceneNumber), Summary = Trim(FirstConfigured(scene.Summary?.Short, scene.Summary?.Detailed, run.CurrentMessage, "Scene analysed."), 180), Stage = StageDisplay(run), TimelineLabel = TimelineLabel(scene, item.Result), TimelineEvent = Trim(FirstConfigured(scene.Summary?.Short, scene.ScenePurpose?.ObservedFunction, "Scene analysed"), 42), ProgressPercent = Progress(run, completedScenes, totalScenes), ChaptersAnalysed = ChapterCount(completed), ChapterTotal = Math.Max(1, run.SourceChapterCount ?? ChapterCount(completed)), ScenesAnalysed = completedScenes, SceneTotal = Math.Max(1, totalScenes), EstimatedRemaining = EstimateRemaining(run, completedScenes, totalScenes), PovCharacterId = CharacterId(scene.PointOfView?.CharacterName), Location = BuildLocation(scene), Characters = BuildCharacters(scene), Assets = BuildAssets(scene), Relationships = BuildRelationships(scene), 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", 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), 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.LastOrDefault(item => SceneNumber(item) <= (run.CompletedScenes ?? int.MaxValue)) ?? completed.Last(); } private static IReadOnlyList BuildCharacters(SceneIntelligenceScene scene) { var povName = Clean(scene.PointOfView?.CharacterName); var states = new List(); foreach (var group in (scene.Characters ?? []) .Where(character => !string.IsNullOrWhiteSpace(character.Name)) .GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase) .Take(5)) { var character = group.OrderByDescending(item => item.Confidence ?? 0).First(); var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase); states.Add(new StoryIntelligenceExperienceCharacterState { Id = CharacterId(group.Key), LibraryCode = CharacterLibraryCode(group.Key, character), 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))) { states.Insert(0, new StoryIntelligenceExperienceCharacterState { Id = CharacterId(povName), LibraryCode = CharacterLibraryCode(povName, null), Name = povName, Role = "POV character", Relevance = "Point of view", Weight = 100, ImagePath = CharacterFallback(povName) }); } return states.OrderByDescending(character => character.Weight).ToList(); } 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) => (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) .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 static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result) { if (string.IsNullOrWhiteSpace(result.ParsedJson)) { return null; } try { var direct = JsonSerializer.Deserialize(result.ParsedJson, JsonOptions); if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion)) { return direct; } using var document = JsonDocument.Parse(result.ParsedJson); if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene)) { return parsedScene.Deserialize(JsonOptions); } } catch (JsonException) { } return null; } private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList results) { var raw = $"{run.StoryIntelligenceRunID}|{run.Status}|{run.UpdatedUtc:O}|{results.Count}|{results.Select(item => item.SceneResultID).DefaultIfEmpty().Max()}"; return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant(); } 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 bool IsTerminal(string status) => status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled; private static int SceneNumber(ParsedSceneResult item) => item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber; private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber) => FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}"); private static string TimelineLabel(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result) => FirstConfigured(scene.TimelineClues?.FirstOrDefault()?.AbsoluteDate, scene.Setting?.DateOrTimeReference, result.SourceLabel, "Known scene"); 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 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 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) { var value = $"{name} {character?.RoleInScene} {character?.Notes}".ToLowerInvariant(); if (value.Contains("red") || value.Contains("auburn") || value.Contains("witness")) return "char-young-adult-red-haired-witness"; if (value.Contains("older") || value.Contains("mother") || value.Contains("remember")) return "char-older-soft-presence"; if (value.Contains("suspect") || value.Contains("threat") || value.Contains("man")) return "char-middle-aged-weathered-man"; return "char-young-adult-brunette-observer"; } private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting) { var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant(); 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", _ => $"{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 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 ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene); }