From 2b7a4b599dc1c4f9bff281356663dec00931a1dd Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 12 Jul 2026 19:00:38 +0000 Subject: [PATCH] Phase 21H.1 - Fix Active Import Session Selection --- PlotLine/Controllers/DevelopmentController.cs | 72 ++++++++--- ...toryIntelligenceExperiencePrototypeData.cs | 6 + ...ntelligenceVisualisationSnapshotService.cs | 114 +++++++++++++++++- ...telligenceExperiencePrototypeViewModels.cs | 9 ++ .../StoryIntelligenceExperience.cshtml | 61 +++++++++- ...tory-intelligence-experience-prototype.css | 17 +++ ...story-intelligence-experience-prototype.js | 19 +-- 7 files changed, 268 insertions(+), 30 deletions(-) diff --git a/PlotLine/Controllers/DevelopmentController.cs b/PlotLine/Controllers/DevelopmentController.cs index 552e488..486a818 100644 --- a/PlotLine/Controllers/DevelopmentController.cs +++ b/PlotLine/Controllers/DevelopmentController.cs @@ -21,43 +21,81 @@ public sealed class DevelopmentController( return NotFound(); } - if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && importSessionId.HasValue && currentUser.UserId is int userId) + var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim(); + var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase); + + if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId) { var realModel = await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId); if (realModel is null) { - return NotFound(); + return View(ErrorModel( + requestedMode, + importSessionId, + $"Import Session {importSessionId.Value:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet.")); } return View(realModel); } - if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && runId.HasValue && currentUser.UserId is int legacyUserId) + if (!simulationRequested && runId.HasValue && currentUser.UserId is int legacyUserId) { var realModel = await snapshots.BuildSnapshotAsync(runId.Value, legacyUserId); if (realModel is null) { - return NotFound(); + return View(ErrorModel( + requestedMode, + null, + $"Run {runId.Value:N0} could not be found for the current user. Live visualisation URLs should use importSessionId for book imports.")); } return View(realModel); } - var model = StoryIntelligenceExperiencePrototypeData.Build(); - await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); - if (!string.Equals(mode, "simulation", StringComparison.OrdinalIgnoreCase) && currentUser.UserId is int selectorUserId) + if (simulationRequested) { - model = new StoryIntelligenceExperiencePrototypeViewModel - { - Mode = "selector", - AvailableRuns = await snapshots.ListImportSessionOptionsAsync(selectorUserId), - ProjectName = model.ProjectName, - BookTitle = model.BookTitle, - Scenes = model.Scenes, - SnapshotMessage = "Choose a Story Intelligence import session, or open simulation mode." - }; + var model = StoryIntelligenceExperiencePrototypeData.Build(); + await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); + return View(model); } - return View(model); + if (currentUser.UserId is int selectorUserId) + { + var sessions = await snapshots.ListImportSessionOptionsAsync(selectorUserId); + return View(new StoryIntelligenceExperiencePrototypeViewModel + { + Mode = "selector", + AvailableRuns = sessions, + ProjectName = "Development", + BookTitle = "Story Intelligence", + SnapshotMessage = sessions.Count == 0 + ? "No persisted Story Intelligence import sessions were found for the current user. Start a real import, then refresh this page." + : "Choose a Story Intelligence import session, or explicitly open simulation mode.", + Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics + { + RequestedMode = requestedMode, + RequestedImportSessionId = importSessionId, + SnapshotSource = "Real" + } + }); + } + + return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in.")); } + + private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message) + => new() + { + Mode = "error", + ProjectName = "Development", + BookTitle = "Story Intelligence", + SnapshotMessage = message, + Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics + { + RequestedMode = requestedMode, + RequestedImportSessionId = importSessionId, + SnapshotSource = "Real", + Warning = message + } + }; } diff --git a/PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs b/PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs index b5c8852..8a731bb 100644 --- a/PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs +++ b/PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs @@ -9,6 +9,12 @@ public static class StoryIntelligenceExperiencePrototypeData public static StoryIntelligenceExperiencePrototypeViewModel Build() => new() { + Mode = "simulation", + Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics + { + RequestedMode = "simulation", + SnapshotSource = "Simulation" + }, ProjectName = "Alpha Flame", BookTitle = "The Doweries Manuscript", Scenes = diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 0175c6a..22b2f17 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -19,6 +19,7 @@ public interface IStoryIntelligenceVisualisationSnapshotService public sealed class StoryIntelligenceVisualisationSnapshotService( IStoryIntelligenceResultRepository repository, IStoryIntelligencePipelineRepository pipelines, + IStoryIntelligenceSourceRepository sources, IIllustrationLibraryService illustrationLibrary, ILogger logger) : IStoryIntelligenceVisualisationSnapshotService { @@ -48,6 +49,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( public async Task> ListImportSessionOptionsAsync(int userId) { + await EnsureActiveImportSessionsAsync(userId); var sessions = await pipelines.ListForUserAsync(userId); return sessions .OrderByDescending(session => session.UpdatedUtc) @@ -63,6 +65,34 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( .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(); @@ -150,9 +180,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( 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 => IsTerminal(snapshot.Run.Status)); var isTerminal = runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status)) && string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase); + overallProgressPercent = BookProgress(runSnapshots, bookChapterTotal, isTerminal); var scenes = new List(); var nextSceneNumber = 1; foreach (var snapshot in runSnapshots) @@ -166,7 +200,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( overallCompletedScenes: parsedScenes, overallTotalScenes: totalScenes, overallChaptersAnalysed: completedChapterRuns, - overallChapterTotal: runs.Count, + overallChapterTotal: bookChapterTotal, + overallProgressPercent: overallProgressPercent, overallStage: OverallStage(session, activeSnapshot.Run), overallRemaining: EstimateSessionRemaining(runSnapshots, parsedScenes, totalScenes), continuousSceneNumbering: true, @@ -191,7 +226,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( ChangeToken = SessionChangeToken(session, runSnapshots), IsTerminal = isTerminal, SnapshotMessage = SessionSnapshotMessage(session, runSnapshots), - Diagnostics = BuildSessionDiagnostics(session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults), + Diagnostics = BuildSessionDiagnostics(session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults, overallProgressPercent), ProjectName = DisplayTitle(session.ProjectName, "Untitled project"), BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"), Scenes = scenes @@ -212,6 +247,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( int? overallTotalScenes = null, int? overallChaptersAnalysed = null, int? overallChapterTotal = null, + int? overallProgressPercent = null, string? overallStage = null, string? overallRemaining = null, bool continuousSceneNumbering = false, @@ -235,9 +271,10 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( Stage = overallStage ?? StageDisplay(run), TimelineLabel = TimelineLabel(scene, item.Result), TimelineEvent = Trim(FirstConfigured(scene.Summary?.Short, scene.ScenePurpose?.ObservedFunction, "Scene analysed"), 42), - ProgressPercent = overallTotalScenes.HasValue + ProgressPercent = overallProgressPercent + ?? (overallTotalScenes.HasValue ? OverallProgress(overallCompletedScenes ?? completedScenes, overallTotalScenes.Value, overallTerminal) - : Progress(run, completedScenes, totalScenes), + : Progress(run, completedScenes, totalScenes)), ChaptersAnalysed = overallChaptersAnalysed ?? ChapterCount(completed), ChapterTotal = Math.Max(1, overallChapterTotal ?? run.SourceChapterCount ?? ChapterCount(completed)), ScenesAnalysed = overallCompletedScenes ?? completedScenes, @@ -617,18 +654,39 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( : "All known chapter runs are complete. Awaiting the next book import stage."; } + private static string CurrentSceneText(RunSnapshot snapshot) + { + if (snapshot.Run.TotalDetectedScenes.HasValue) + { + var completed = Math.Clamp(snapshot.Run.CompletedScenes ?? snapshot.Completed.Count, 0, snapshot.Run.TotalDetectedScenes.Value); + return $"Scene {completed:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}"; + } + + return snapshot.Current is null ? "Waiting" : $"Scene {SceneSortNumber(snapshot.Current):0.##}"; + } + private static StoryIntelligenceExperienceSnapshotDiagnostics BuildSessionDiagnostics( StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots, RunSnapshot activeSnapshot, ParsedSceneResult? current, int parsedScenes, - int totalPersistedResults) + int totalPersistedResults, + int overallProgressPercent) { 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", + 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), + SnapshotSource = "Real", RunStatus = SessionStatus(session, snapshots), PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count), TotalPersistedSceneResultCount = totalPersistedResults, @@ -658,6 +716,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( 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, 0, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}" + : (current is null ? "Waiting" : $"Scene {SceneSortNumber(current):0.##}"), + SnapshotSource = "Real", RunStatus = run.Status, PersistedCompletedSceneCount = run.CompletedScenes ?? 0, TotalPersistedSceneResultCount = results.Count, @@ -732,6 +799,43 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( 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 (IsTerminal(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)) diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index 005989a..904bc33 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -19,6 +19,15 @@ public sealed class StoryIntelligenceExperiencePrototypeViewModel public sealed class StoryIntelligenceExperienceSnapshotDiagnostics { + public string RequestedMode { get; init; } = string.Empty; + public int? RequestedImportSessionId { get; init; } + public int? ResolvedImportSessionId { get; init; } + public int? ActiveChapterRunId { get; init; } + public string SessionStatus { get; init; } = string.Empty; + public int OverallBookProgress { get; init; } + public string CurrentChapter { get; init; } = string.Empty; + public string CurrentScene { get; init; } = string.Empty; + public string SnapshotSource { get; init; } = string.Empty; public string RunStatus { get; init; } = string.Empty; public int PersistedCompletedSceneCount { get; init; } public int TotalPersistedSceneResultCount { get; init; } diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index 34380c4..c2956a6 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -3,6 +3,21 @@ @{ Layout = null; var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + var statusLabel = Model.Mode switch + { + "simulation" => "Simulation", + "real" when Model.IsTerminal => "Analysis Complete", + "real" => "Live Analysis", + "error" => "Import Session Error", + _ => "Select Import Session" + }; + var sourceLabel = Model.Mode switch + { + "simulation" => "Simulation only", + "real" => "Real snapshot", + "error" => "No live snapshot", + _ => "Choose a session" + }; } @@ -31,6 +46,7 @@
    + @statusLabel @Model.ProjectName @Model.BookTitle Preparing @@ -50,6 +66,13 @@ } + @if (!string.IsNullOrWhiteSpace(Model.SnapshotMessage)) + { + + } +