From 0cc25c570c8411eb55877243e9cacdb538b046a1 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 28 Aug 2026 19:47:05 +0000 Subject: [PATCH] Fix story intelligence progress totals --- PlotLine.Tests/Program.cs | 74 +++++++++- PlotLine/Hubs/StoryIntelligenceHub.cs | 10 +- .../StoryIntelligencePersistenceModels.cs | 4 + .../StoryIntelligenceGlobalProgressService.cs | 4 +- ...e26F_StoryIntelligenceWholeRunProgress.sql | 128 ++++++++++++++++++ ...oryIntelligenceGlobalProgressViewModels.cs | 39 ++++-- PlotLine/Views/Shared/_Layout.cshtml | 7 +- .../js/story-intelligence-global-progress.js | 77 +++++++---- 8 files changed, 301 insertions(+), 42 deletions(-) create mode 100644 PlotLine/Sql/170_Phase26F_StoryIntelligenceWholeRunProgress.sql diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index c5cb33b..acd4c92 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -231,7 +231,8 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard), ("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering), ("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls), - ("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution) + ("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution), + ("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals) }; foreach (var test in tests) @@ -270,6 +271,77 @@ static void StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution() Assert(openAiClient.Contains("RetryConditionHeaderValue", StringComparison.Ordinal), "OpenAI retry should honour Retry-After metadata."); } +static void StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals() +{ + var root = PlotLineRoot(); + var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/StoryIntelligenceGlobalProgressViewModels.cs")); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceGlobalProgressService.cs")); + var hub = File.ReadAllText(Path.Combine(root, "Hubs/StoryIntelligenceHub.cs")); + var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml")); + var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/170_Phase26F_StoryIntelligenceWholeRunProgress.sql")); + + var book = new StoryIntelligenceGlobalProgressBookViewModel + { + BookTitle = "Test Book", + PendingRunCount = 2, + RunningRunCount = 3, + TotalExpectedScenes = 47, + CompletedScenes = 5, + FailedScenes = 0, + ActiveScenes = 15, + TotalDurationMs = TimeSpan.FromMinutes(2).Ticks / TimeSpan.TicksPerMillisecond, + Status = StoryIntelligenceRunStatuses.Running + }; + var model = new StoryIntelligenceGlobalProgressViewModel { Books = [book] }; + + Assert(model.TotalExpectedScenes == 47, "Global progress denominator should be the expected whole-run scene total."); + Assert(model.TotalDetectedScenes == 47, "Legacy detected-scenes accessor should mirror the expected scene total for display."); + Assert(model.CompletedScenes == 5, "Completed count should count successful completed scene analyses."); + Assert(model.ActiveScenes == 15, "Active scene count should remain distinct from the denominator."); + Assert(model.ProgressPercent == 11, "5 of 47 scenes should round to 11%, not 33% from a 15-scene active subset."); + Assert(book.SummaryText.Contains("5/47", StringComparison.Ordinal), "Book summary should display 5/47."); + + var finalisingBook = new StoryIntelligenceGlobalProgressBookViewModel + { + BookTitle = "Test Book", + PendingRunCount = 0, + RunningRunCount = 1, + TotalExpectedScenes = 47, + CompletedScenes = 47, + FailedScenes = 0, + ActiveScenes = 0, + TotalDurationMs = TimeSpan.FromMinutes(8).Ticks / TimeSpan.TicksPerMillisecond, + Status = "Finalising" + }; + Assert(finalisingBook.ProgressPercent == 100, "Scene progress can be visually full during finalisation."); + Assert(finalisingBook.EstimatedRemaining == "Finalising", "All scene analyses complete should show finalising until pipeline state is persisted."); + + var failedBook = new StoryIntelligenceGlobalProgressBookViewModel + { + BookTitle = "Test Book", + PendingRunCount = 0, + RunningRunCount = 0, + TotalExpectedScenes = 47, + CompletedScenes = 46, + FailedScenes = 1, + ActiveScenes = 0, + TotalDurationMs = TimeSpan.FromMinutes(8).Ticks / TimeSpan.TicksPerMillisecond, + Status = StoryIntelligenceRunStatuses.CompletedWithWarnings + }; + Assert(failedBook.ProgressPercent == 98, "46 success plus 1 failed must not display as 47 successful scenes."); + Assert(failedBook.EstimatedRemaining == "Finalising", "Terminal failures should be excluded from ETA while remaining visible."); + + Assert(service.Contains("TotalExpectedScenes = summary.TotalExpectedScenes", StringComparison.Ordinal), "Global progress service should map total expected scenes."); + Assert(hub.Contains("WatchGlobalStoryIntelligenceProgress", StringComparison.Ordinal), "SignalR should expose the aggregate progress calculation."); + Assert(layout.Contains("TotalExpectedScenes", StringComparison.Ordinal), "Initial server-rendered strip should use total expected scenes."); + Assert(script.Contains("WatchGlobalStoryIntelligenceProgress", StringComparison.Ordinal), "SignalR updates should refresh aggregate progress instead of trusting chapter totals."); + Assert(!script.Contains("progress.totalDetectedScenes", StringComparison.Ordinal), "Client should not use per-run TotalDetectedScenes as the denominator."); + Assert(migration.Contains("CanonicalSceneScope", StringComparison.Ordinal), "Progress migration should derive expected scope from canonical scenes."); + Assert(migration.Contains("ActiveScenes", StringComparison.Ordinal), "Progress migration should keep active scene count distinct."); + Assert(migration.Contains("N'Finalising'", StringComparison.Ordinal), "Progress migration should expose a finalising state."); +} + static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume() { var root = PlotLineRoot(); diff --git a/PlotLine/Hubs/StoryIntelligenceHub.cs b/PlotLine/Hubs/StoryIntelligenceHub.cs index 340f219..2dcfe91 100644 --- a/PlotLine/Hubs/StoryIntelligenceHub.cs +++ b/PlotLine/Hubs/StoryIntelligenceHub.cs @@ -13,7 +13,8 @@ namespace PlotLine.Hubs; public sealed class StoryIntelligenceHub( IStoryIntelligenceService storyIntelligence, IStoryIntelligenceResultRepository persistedRuns, - IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub + IOnboardingStoryIntelligenceService onboardingStoryIntelligence, + IStoryIntelligenceGlobalProgressService globalProgress) : Hub { public override async Task OnConnectedAsync() { @@ -58,6 +59,13 @@ public sealed class StoryIntelligenceHub( return await onboardingStoryIntelligence.GetProgressAsync(batchId); } + public async Task WatchGlobalStoryIntelligenceProgress() + { + var userId = RequireUserId(); + await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId)); + return await globalProgress.GetForCurrentUserAsync(); + } + public static string UserGroup(int userId) => $"story-intelligence:{userId}"; private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, string? latestSceneSummary) diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 6c5f82e..3d57e8d 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -354,9 +354,13 @@ public sealed class StoryIntelligenceActiveBookRunSummary public int RunningRunCount { get; init; } public int CompletedRunCount { get; init; } public int FailedRunCount { get; init; } + public int TotalExpectedScenes { get; init; } public int TotalDetectedScenes { get; init; } public int CompletedScenes { get; init; } public int FailedScenes { get; init; } + public int ActiveScenes { get; init; } + public int ProgressPercent { get; init; } + public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending; public long TotalDurationMs { get; init; } public DateTime FirstCreatedUtc { get; init; } public DateTime UpdatedUtc { get; init; } diff --git a/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs b/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs index ee4ee72..d3716bd 100644 --- a/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs +++ b/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs @@ -32,9 +32,11 @@ public sealed class StoryIntelligenceGlobalProgressService( RunningRunCount = summary.RunningRunCount, CompletedRunCount = summary.CompletedRunCount, FailedRunCount = summary.FailedRunCount, - TotalDetectedScenes = summary.TotalDetectedScenes, + TotalExpectedScenes = summary.TotalExpectedScenes, CompletedScenes = summary.CompletedScenes, FailedScenes = summary.FailedScenes, + ActiveScenes = summary.ActiveScenes, + Status = summary.Status, TotalDurationMs = summary.TotalDurationMs, UpdatedUtc = summary.UpdatedUtc }) diff --git a/PlotLine/Sql/170_Phase26F_StoryIntelligenceWholeRunProgress.sql b/PlotLine/Sql/170_Phase26F_StoryIntelligenceWholeRunProgress.sql new file mode 100644 index 0000000..8c0382a --- /dev/null +++ b/PlotLine/Sql/170_Phase26F_StoryIntelligenceWholeRunProgress.sql @@ -0,0 +1,128 @@ +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + ;WITH ActiveScopes AS + ( + SELECT + r.UserID, + r.ProjectID, + r.BookID, + LatestCreatedUtc = MAX(r.CreatedUtc) + FROM dbo.StoryIntelligenceRuns r + WHERE r.UserID = @UserID + AND r.Status IN (N'Pending', N'Running') + GROUP BY r.UserID, r.ProjectID, r.BookID + ), + ScopedRuns AS + ( + SELECT r.* + FROM dbo.StoryIntelligenceRuns r + INNER JOIN ActiveScopes scope + ON scope.UserID = r.UserID + AND ISNULL(scope.ProjectID, -1) = ISNULL(r.ProjectID, -1) + AND ISNULL(scope.BookID, -1) = ISNULL(r.BookID, -1) + AND r.CreatedUtc >= DATEADD(minute, -30, scope.LatestCreatedUtc) + WHERE r.Status IN (N'Pending', N'Running', N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled') + ), + CanonicalSceneScope AS + ( + SELECT DISTINCT + r.UserID, + r.ProjectID, + r.BookID, + s.SceneID + FROM ScopedRuns r + INNER JOIN dbo.Chapters c + ON c.ChapterID = r.ChapterID + AND c.IsArchived = 0 + INNER JOIN dbo.Scenes s + ON s.ChapterID = c.ChapterID + AND s.IsArchived = 0 + ), + RunSummary AS + ( + SELECT + r.UserID, + r.ProjectID, + r.BookID, + COUNT(1) AS RunCount, + SUM(CASE WHEN r.Status = N'Pending' THEN 1 ELSE 0 END) AS PendingRunCount, + SUM(CASE WHEN r.Status = N'Running' THEN 1 ELSE 0 END) AS RunningRunCount, + SUM(CASE WHEN r.Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END) AS CompletedRunCount, + SUM(CASE WHEN r.Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) AS FailedRunCount, + SUM(ISNULL(r.TotalDetectedScenes, 0)) AS TotalDetectedScenes, + SUM(ISNULL(r.CompletedScenes, 0)) AS CompletedScenes, + SUM(ISNULL(r.FailedScenes, 0)) AS FailedScenes, + SUM(CASE + WHEN r.Status = N'Running' + THEN CASE + WHEN ISNULL(r.TotalDetectedScenes, 0) - ISNULL(r.CompletedScenes, 0) - ISNULL(r.FailedScenes, 0) > 0 + THEN ISNULL(r.TotalDetectedScenes, 0) - ISNULL(r.CompletedScenes, 0) - ISNULL(r.FailedScenes, 0) + ELSE 0 + END + ELSE 0 + END) AS ActiveScenes, + SUM(ISNULL(r.TotalDurationMs, 0)) AS TotalDurationMs, + MIN(r.CreatedUtc) AS FirstCreatedUtc, + MAX(r.UpdatedUtc) AS UpdatedUtc + FROM ScopedRuns r + GROUP BY r.UserID, r.ProjectID, r.BookID + ), + ExpectedSceneSummary AS + ( + SELECT + scope.UserID, + scope.ProjectID, + scope.BookID, + COUNT(scope.SceneID) AS TotalExpectedScenes + FROM CanonicalSceneScope scope + GROUP BY scope.UserID, scope.ProjectID, scope.BookID + ) + SELECT + summary.UserID, + summary.ProjectID, + p.ProjectName AS ProjectTitle, + summary.BookID, + b.BookTitle, + b.Subtitle AS BookSubtitle, + summary.RunCount, + summary.PendingRunCount, + summary.RunningRunCount, + summary.CompletedRunCount, + summary.FailedRunCount, + COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) AS TotalExpectedScenes, + summary.TotalDetectedScenes, + summary.CompletedScenes, + summary.FailedScenes, + summary.ActiveScenes, + CASE + WHEN COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) <= 0 THEN 0 + ELSE CONVERT(int, ROUND(summary.CompletedScenes * 100.0 / COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 1), 0)) + END AS ProgressPercent, + CASE + WHEN summary.RunningRunCount > 0 + AND COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) > 0 + AND summary.CompletedScenes + summary.FailedScenes >= COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) + THEN N'Finalising' + WHEN summary.FailedRunCount > 0 OR summary.FailedScenes > 0 THEN N'CompletedWithWarnings' + WHEN summary.RunningRunCount > 0 THEN N'Running' + WHEN summary.PendingRunCount > 0 THEN N'Pending' + ELSE N'Completed' + END AS Status, + summary.TotalDurationMs, + summary.FirstCreatedUtc, + summary.UpdatedUtc + FROM RunSummary summary + LEFT JOIN ExpectedSceneSummary expected + ON expected.UserID = summary.UserID + AND ISNULL(expected.ProjectID, -1) = ISNULL(summary.ProjectID, -1) + AND ISNULL(expected.BookID, -1) = ISNULL(summary.BookID, -1) + LEFT JOIN dbo.Projects p ON p.ProjectID = summary.ProjectID + LEFT JOIN dbo.Books b ON b.BookID = summary.BookID + WHERE summary.PendingRunCount > 0 OR summary.RunningRunCount > 0 + ORDER BY summary.UpdatedUtc DESC, summary.FirstCreatedUtc; +END; +GO diff --git a/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs index 7851487..0b27b65 100644 --- a/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs @@ -1,3 +1,5 @@ +using PlotLine.Models; + namespace PlotLine.ViewModels; public sealed class StoryIntelligenceGlobalProgressViewModel @@ -6,12 +8,21 @@ public sealed class StoryIntelligenceGlobalProgressViewModel public bool HasActiveWork => Books.Any(book => book.HasActiveRuns); public int ActiveBookCount => Books.Count(book => book.HasActiveRuns); public int ActiveRunCount => Books.Sum(book => book.PendingRunCount + book.RunningRunCount); - public int TotalDetectedScenes => Books.Sum(book => book.TotalDetectedScenes); + public int TotalExpectedScenes => Books.Sum(book => book.TotalExpectedScenes); + public int TotalDetectedScenes => TotalExpectedScenes; public int CompletedScenes => Books.Sum(book => book.CompletedScenes); public int FailedScenes => Books.Sum(book => book.FailedScenes); - public int ProgressPercent => TotalDetectedScenes <= 0 + public int ActiveScenes => Books.Sum(book => book.ActiveScenes); + public int ProgressPercent => TotalExpectedScenes <= 0 ? 0 - : Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalDetectedScenes)), 0, 100); + : Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalExpectedScenes)), 0, 100); + public string Status => Books.Any(book => book.Status == "Finalising") + ? "Finalising" + : Books.Any(book => book.HasFailures) + ? StoryIntelligenceRunStatuses.CompletedWithWarnings + : Books.Any(book => book.HasActiveRuns) + ? StoryIntelligenceRunStatuses.Running + : StoryIntelligenceRunStatuses.Completed; public string SummaryText => ActiveBookCount switch { 0 => string.Empty, @@ -30,34 +41,40 @@ public sealed class StoryIntelligenceGlobalProgressBookViewModel public int RunningRunCount { get; init; } public int CompletedRunCount { get; init; } public int FailedRunCount { get; init; } - public int TotalDetectedScenes { get; init; } + public int TotalExpectedScenes { get; init; } + public int TotalDetectedScenes => TotalExpectedScenes; public int CompletedScenes { get; init; } public int FailedScenes { get; init; } + public int ActiveScenes { get; init; } + public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending; public long TotalDurationMs { get; init; } public DateTime UpdatedUtc { get; init; } public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0; public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0; - public int ProgressPercent => TotalDetectedScenes <= 0 + public int ProgressPercent => TotalExpectedScenes <= 0 ? 0 - : Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalDetectedScenes)), 0, 100); - public string SummaryText => $"{BookTitle}: {CompletedScenes:N0}/{Math.Max(TotalDetectedScenes, CompletedScenes):N0} scenes"; - public string EstimatedRemaining => EstimateRemaining(TotalDurationMs, CompletedScenes, TotalDetectedScenes); + : Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalExpectedScenes)), 0, 100); + public string SummaryText => $"{BookTitle}: {CompletedScenes:N0}/{Math.Max(TotalExpectedScenes, CompletedScenes):N0} scenes"; + public string EstimatedRemaining => Status == "Finalising" + ? "Finalising" + : EstimateRemaining(TotalDurationMs, CompletedScenes, FailedScenes, TotalExpectedScenes); - private static string EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes) + private static string EstimateRemaining(long elapsedMs, int completedScenes, int failedScenes, int totalScenes) { if (totalScenes <= 0 || completedScenes <= 0) { return "Calculating"; } - if (completedScenes >= totalScenes) + var remainingScenes = Math.Max(0, totalScenes - completedScenes - failedScenes); + if (remainingScenes == 0) { return "Finalising"; } var averageSceneMs = elapsedMs / Math.Max(1, completedScenes); - var remaining = TimeSpan.FromMilliseconds(averageSceneMs * (totalScenes - completedScenes)); + var remaining = TimeSpan.FromMilliseconds(averageSceneMs * remainingScenes); if (remaining.TotalMinutes >= 1) { return $"{Math.Max(1, (int)Math.Round(remaining.TotalMinutes)):N0} min"; diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index cdbb55c..cb7e143 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -234,6 +234,7 @@