diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index c5ec402..1fc9510 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -244,6 +244,7 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence terminal failures remain user visible", StoryIntelligenceTerminalFailuresRemainUserVisible), ("Story Intelligence global banner remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps), ("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots), + ("Story Intelligence live global progress publishes authoritative scene aggregates", StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates), ("Story Intelligence active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip), ("Story Intelligence book actions do not open review centre while running", StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning) }; @@ -422,6 +423,41 @@ static void StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots() Assert(viewModel.Contains("public DateTime? UpdatedUtc", StringComparison.Ordinal), "Aggregate payload should expose a timestamp for stale-snapshot checks."); } +static void StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates() +{ + var root = PlotLineRoot(); + var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); + var notifier = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceProgressNotifier.cs")); + var globalService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceGlobalProgressService.cs")); + var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js")); + var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml")); + + var sceneSuccessBlock = ExtractBetween( + runner, + "completedScenes++;", + "catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))"); + Assert(sceneSuccessBlock.IndexOf("await repository.UpdateProgressAsync", StringComparison.Ordinal) + < sceneSuccessBlock.IndexOf("\"Scene analysis completed\"", StringComparison.Ordinal), "Successful scene completion should persist the incremented run counters before publishing live progress."); + Assert(sceneSuccessBlock.Contains("completedScenes: completedScenes", StringComparison.Ordinal), "Successful scene completion should persist the authoritative completed-scene count."); + + var sceneFailureBlock = ExtractBetween( + runner, + "failedScenes++;", + "logger.LogWarning(ex, \"Story Intelligence scene failed"); + Assert(sceneFailureBlock.IndexOf("await repository.UpdateProgressAsync", StringComparison.Ordinal) + < sceneFailureBlock.IndexOf("\"Scene analysis failed\"", StringComparison.Ordinal), "Terminal scene failure should persist failed-scene counters before publishing live progress."); + Assert(sceneFailureBlock.Contains("failedScenes: failedScenes", StringComparison.Ordinal), "Terminal scene failure should persist the authoritative failed-scene count."); + + Assert(globalService.Contains("Task GetForUserAsync(int userId)", StringComparison.Ordinal), "Background publishing should be able to retrieve the same user-scoped aggregate used by page load."); + Assert(notifier.Contains("GetForUserAsync(progress.UserID)", StringComparison.Ordinal), "Live progress publish should fetch the authoritative aggregate for the run owner."); + Assert(notifier.Contains("StoryIntelligenceGlobalProgressChanged", StringComparison.Ordinal), "Live progress publish should send a compact aggregate SignalR event."); + Assert(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\", render)", StringComparison.Ordinal), "The global banner should render authoritative aggregate payloads directly."); + Assert(script.Contains("connection.onreconnected(refresh)", StringComparison.Ordinal), "Reconnect should rejoin the user group and immediately catch up from persisted aggregate state."); + Assert(script.Contains("updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "Equal-timestamp out-of-order snapshots should not regress completed/failed scene totals."); + Assert(layout.Contains("data-completed-scenes", StringComparison.Ordinal), "Initial server render should seed completed count for monotonic live comparisons."); + Assert(layout.Contains("data-failed-scenes", StringComparison.Ordinal), "Initial server render should seed terminal failed count for monotonic live comparisons."); +} + static void StoryIntelligenceRecoveredProviderFailuresStayOperational() { var requestCount = 0; diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 47bc436..47ee3d4 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -365,6 +365,20 @@ public sealed class PersistedStoryIntelligenceRunner( DateTime.UtcNow); completedScenes++; + if (!validation.IsValid || validation.Warnings.Count > 0) + { + failedScenes += validation.Errors.Count > 0 ? 1 : 0; + } + await repository.UpdateProgressAsync( + run.StoryIntelligenceRunID, + completedScenes: completedScenes, + failedScenes: failedScenes, + totalInputTokens: totals.InputTokens, + totalOutputTokens: totals.OutputTokens, + totalTokens: totals.TotalTokens, + totalDurationMs: stopwatch.ElapsedMilliseconds, + estimatedCostUSD: totals.EstimatedCostUSD, + estimatedCostGBP: totals.EstimatedCostGBP); await PublishAsync( run, "Scene analysis completed", @@ -381,15 +395,21 @@ public sealed class PersistedStoryIntelligenceRunner( currentSceneDisplayTitle: block.DisplayTitle, latestSceneSummary: block.CanonicalSummary, recentDiscoveries: BuildDiscoveries(sceneAttempt.Parsed)); - if (!validation.IsValid || validation.Warnings.Count > 0) - { - failedScenes += validation.Errors.Count > 0 ? 1 : 0; - } } catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex)) { failedScenes++; await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message, ex as AiJsonParseException); + await repository.UpdateProgressAsync( + run.StoryIntelligenceRunID, + completedScenes: completedScenes, + failedScenes: failedScenes, + totalInputTokens: totals.InputTokens, + totalOutputTokens: totals.OutputTokens, + totalTokens: totals.TotalTokens, + totalDurationMs: stopwatch.ElapsedMilliseconds, + estimatedCostUSD: totals.EstimatedCostUSD, + estimatedCostGBP: totals.EstimatedCostGBP); await PublishAsync( run, "Scene analysis failed", @@ -408,17 +428,6 @@ public sealed class PersistedStoryIntelligenceRunner( errorMessage: ex.Message); logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber); } - - await repository.UpdateProgressAsync( - run.StoryIntelligenceRunID, - completedScenes: completedScenes, - failedScenes: failedScenes, - totalInputTokens: totals.InputTokens, - totalOutputTokens: totals.OutputTokens, - totalTokens: totals.TotalTokens, - totalDurationMs: stopwatch.ElapsedMilliseconds, - estimatedCostUSD: totals.EstimatedCostUSD, - estimatedCostGBP: totals.EstimatedCostGBP); } var finalStatus = failedScenes > 0 || hasWarnings diff --git a/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs b/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs index d3716bd..e6a6d69 100644 --- a/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs +++ b/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs @@ -6,6 +6,7 @@ namespace PlotLine.Services; public interface IStoryIntelligenceGlobalProgressService { Task GetForCurrentUserAsync(); + Task GetForUserAsync(int userId); } public sealed class StoryIntelligenceGlobalProgressService( @@ -19,6 +20,11 @@ public sealed class StoryIntelligenceGlobalProgressService( return new StoryIntelligenceGlobalProgressViewModel(); } + return await GetForUserAsync(userId); + } + + public async Task GetForUserAsync(int userId) + { var summaries = await runs.ListActiveBookRunSummariesForUserAsync(userId); return new StoryIntelligenceGlobalProgressViewModel { diff --git a/PlotLine/Services/StoryIntelligenceProgressNotifier.cs b/PlotLine/Services/StoryIntelligenceProgressNotifier.cs index fa6ed8a..75ca2a5 100644 --- a/PlotLine/Services/StoryIntelligenceProgressNotifier.cs +++ b/PlotLine/Services/StoryIntelligenceProgressNotifier.cs @@ -9,9 +9,18 @@ public interface IStoryIntelligenceProgressNotifier Task PublishAsync(StoryIntelligenceRunProgressEvent progress); } -public sealed class StoryIntelligenceProgressNotifier(IHubContext hub) : IStoryIntelligenceProgressNotifier +public sealed class StoryIntelligenceProgressNotifier( + IHubContext hub, + IStoryIntelligenceGlobalProgressService globalProgress) : IStoryIntelligenceProgressNotifier { - public Task PublishAsync(StoryIntelligenceRunProgressEvent progress) - => hub.Clients.Group(StoryIntelligenceHub.UserGroup(progress.UserID)) + public async Task PublishAsync(StoryIntelligenceRunProgressEvent progress) + { + var userGroup = StoryIntelligenceHub.UserGroup(progress.UserID); + await hub.Clients.Group(userGroup) .SendAsync("StoryIntelligenceRunProgressChanged", progress); + + var aggregate = await globalProgress.GetForUserAsync(progress.UserID); + await hub.Clients.Group(userGroup) + .SendAsync("StoryIntelligenceGlobalProgressChanged", aggregate); + } } diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index 755874c..fb95c40 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -236,6 +236,8 @@ data-story-intelligence-global-strip data-progress-percent="@storyIntelligenceProgress.ProgressPercent" data-total-expected-scenes="@storyIntelligenceProgress.TotalExpectedScenes" + data-completed-scenes="@storyIntelligenceProgress.CompletedScenes" + data-failed-scenes="@storyIntelligenceProgress.FailedScenes" data-active-books="@storyIntelligenceProgress.ActiveBookCount" data-story-intelligence-updated-utc="@progressUpdatedUtc" data-book-id="@primaryBook?.BookID" diff --git a/PlotLine/wwwroot/js/story-intelligence-global-progress.js b/PlotLine/wwwroot/js/story-intelligence-global-progress.js index b9003d6..342bfb7 100644 --- a/PlotLine/wwwroot/js/story-intelligence-global-progress.js +++ b/PlotLine/wwwroot/js/story-intelligence-global-progress.js @@ -13,6 +13,9 @@ const parsed = Number.parseInt(value || "", 10); return Number.isInteger(parsed) ? parsed : null; }; + let lastSnapshotScore = + (readNumber(strip.dataset.completedScenes) || 0) + + (readNumber(strip.dataset.failedScenes) || 0); const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0)); const estimateRemaining = (elapsedMs, completed, failed, total, status) => { @@ -46,25 +49,32 @@ } const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0]; - const updatedRaw = progress.updatedUtc || progress.UpdatedUtc || primary.updatedUtc || primary.UpdatedUtc || ""; - const updatedTime = Date.parse(updatedRaw); - if (Number.isFinite(updatedTime) && updatedTime < lastSnapshotTime) { - return; - } - - if (Number.isFinite(updatedTime)) { - lastSnapshotTime = updatedTime; - strip.dataset.storyIntelligenceUpdatedUtc = new Date(updatedTime).toISOString(); - } - - strip.hidden = false; - strip.dataset.connection = "live"; const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length; const status = progress.status || progress.Status || primary.status || primary.Status || ""; const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes) || 0; const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0; const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0; const active = readNumber(progress.activeScenes ?? progress.ActiveScenes) || 0; + const updatedRaw = progress.updatedUtc || progress.UpdatedUtc || primary.updatedUtc || primary.UpdatedUtc || ""; + const updatedTime = Date.parse(updatedRaw); + const snapshotScore = completed + failed; + if (Number.isFinite(updatedTime)) { + if (updatedTime < lastSnapshotTime || (updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore)) { + return; + } + + lastSnapshotTime = updatedTime; + strip.dataset.storyIntelligenceUpdatedUtc = new Date(updatedTime).toISOString(); + } else if (snapshotScore < lastSnapshotScore) { + return; + } + + lastSnapshotScore = Math.max(lastSnapshotScore, snapshotScore); + strip.dataset.completedScenes = String(completed); + strip.dataset.failedScenes = String(failed); + + strip.hidden = false; + strip.dataset.connection = "live"; const elapsedMs = readNumber(primary.totalDurationMs ?? primary.TotalDurationMs) || 0; const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0; @@ -115,6 +125,8 @@ .build(); connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh); + connection.on("StoryIntelligenceGlobalProgressChanged", render); + connection.onreconnected(refresh); connection.start() .then(refresh) .catch(() => {