Fix live Story Intelligence progress updates

This commit is contained in:
Nick Beckley 2026-08-28 22:37:07 +00:00
parent 9c2a3215c5
commit a28495865a
6 changed files with 105 additions and 31 deletions

View File

@ -244,6 +244,7 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence terminal failures remain user visible", StoryIntelligenceTerminalFailuresRemainUserVisible), ("Story Intelligence terminal failures remain user visible", StoryIntelligenceTerminalFailuresRemainUserVisible),
("Story Intelligence global banner remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps), ("Story Intelligence global banner remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps),
("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots), ("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 active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip),
("Story Intelligence book actions do not open review centre while running", StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning) ("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."); 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<StoryIntelligenceGlobalProgressViewModel> 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() static void StoryIntelligenceRecoveredProviderFailuresStayOperational()
{ {
var requestCount = 0; var requestCount = 0;

View File

@ -365,6 +365,20 @@ public sealed class PersistedStoryIntelligenceRunner(
DateTime.UtcNow); DateTime.UtcNow);
completedScenes++; 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( await PublishAsync(
run, run,
"Scene analysis completed", "Scene analysis completed",
@ -381,15 +395,21 @@ public sealed class PersistedStoryIntelligenceRunner(
currentSceneDisplayTitle: block.DisplayTitle, currentSceneDisplayTitle: block.DisplayTitle,
latestSceneSummary: block.CanonicalSummary, latestSceneSummary: block.CanonicalSummary,
recentDiscoveries: BuildDiscoveries(sceneAttempt.Parsed)); 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)) catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))
{ {
failedScenes++; failedScenes++;
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message, ex as AiJsonParseException); 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( await PublishAsync(
run, run,
"Scene analysis failed", "Scene analysis failed",
@ -408,17 +428,6 @@ public sealed class PersistedStoryIntelligenceRunner(
errorMessage: ex.Message); errorMessage: ex.Message);
logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber); 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 var finalStatus = failedScenes > 0 || hasWarnings

View File

@ -6,6 +6,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceGlobalProgressService public interface IStoryIntelligenceGlobalProgressService
{ {
Task<StoryIntelligenceGlobalProgressViewModel> GetForCurrentUserAsync(); Task<StoryIntelligenceGlobalProgressViewModel> GetForCurrentUserAsync();
Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId);
} }
public sealed class StoryIntelligenceGlobalProgressService( public sealed class StoryIntelligenceGlobalProgressService(
@ -19,6 +20,11 @@ public sealed class StoryIntelligenceGlobalProgressService(
return new StoryIntelligenceGlobalProgressViewModel(); return new StoryIntelligenceGlobalProgressViewModel();
} }
return await GetForUserAsync(userId);
}
public async Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)
{
var summaries = await runs.ListActiveBookRunSummariesForUserAsync(userId); var summaries = await runs.ListActiveBookRunSummariesForUserAsync(userId);
return new StoryIntelligenceGlobalProgressViewModel return new StoryIntelligenceGlobalProgressViewModel
{ {

View File

@ -9,9 +9,18 @@ public interface IStoryIntelligenceProgressNotifier
Task PublishAsync(StoryIntelligenceRunProgressEvent progress); Task PublishAsync(StoryIntelligenceRunProgressEvent progress);
} }
public sealed class StoryIntelligenceProgressNotifier(IHubContext<StoryIntelligenceHub> hub) : IStoryIntelligenceProgressNotifier public sealed class StoryIntelligenceProgressNotifier(
IHubContext<StoryIntelligenceHub> hub,
IStoryIntelligenceGlobalProgressService globalProgress) : IStoryIntelligenceProgressNotifier
{ {
public Task PublishAsync(StoryIntelligenceRunProgressEvent progress) public async Task PublishAsync(StoryIntelligenceRunProgressEvent progress)
=> hub.Clients.Group(StoryIntelligenceHub.UserGroup(progress.UserID)) {
var userGroup = StoryIntelligenceHub.UserGroup(progress.UserID);
await hub.Clients.Group(userGroup)
.SendAsync("StoryIntelligenceRunProgressChanged", progress); .SendAsync("StoryIntelligenceRunProgressChanged", progress);
var aggregate = await globalProgress.GetForUserAsync(progress.UserID);
await hub.Clients.Group(userGroup)
.SendAsync("StoryIntelligenceGlobalProgressChanged", aggregate);
}
} }

View File

@ -236,6 +236,8 @@
data-story-intelligence-global-strip data-story-intelligence-global-strip
data-progress-percent="@storyIntelligenceProgress.ProgressPercent" data-progress-percent="@storyIntelligenceProgress.ProgressPercent"
data-total-expected-scenes="@storyIntelligenceProgress.TotalExpectedScenes" data-total-expected-scenes="@storyIntelligenceProgress.TotalExpectedScenes"
data-completed-scenes="@storyIntelligenceProgress.CompletedScenes"
data-failed-scenes="@storyIntelligenceProgress.FailedScenes"
data-active-books="@storyIntelligenceProgress.ActiveBookCount" data-active-books="@storyIntelligenceProgress.ActiveBookCount"
data-story-intelligence-updated-utc="@progressUpdatedUtc" data-story-intelligence-updated-utc="@progressUpdatedUtc"
data-book-id="@primaryBook?.BookID" data-book-id="@primaryBook?.BookID"

View File

@ -13,6 +13,9 @@
const parsed = Number.parseInt(value || "", 10); const parsed = Number.parseInt(value || "", 10);
return Number.isInteger(parsed) ? parsed : null; 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 formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0));
const estimateRemaining = (elapsedMs, completed, failed, total, status) => { const estimateRemaining = (elapsedMs, completed, failed, total, status) => {
@ -46,25 +49,32 @@
} }
const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0]; 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 activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length;
const status = progress.status || progress.Status || primary.status || primary.Status || ""; const status = progress.status || progress.Status || primary.status || primary.Status || "";
const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes) || 0; const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes) || 0;
const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0; const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0;
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0; const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0;
const active = readNumber(progress.activeScenes ?? progress.ActiveScenes) || 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 elapsedMs = readNumber(primary.totalDurationMs ?? primary.TotalDurationMs) || 0;
const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0; const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0;
@ -115,6 +125,8 @@
.build(); .build();
connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh); connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh);
connection.on("StoryIntelligenceGlobalProgressChanged", render);
connection.onreconnected(refresh);
connection.start() connection.start()
.then(refresh) .then(refresh)
.catch(() => { .catch(() => {