Fix live Story Intelligence progress updates
This commit is contained in:
parent
9c2a3215c5
commit
a28495865a
@ -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<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()
|
||||
{
|
||||
var requestCount = 0;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -6,6 +6,7 @@ namespace PlotLine.Services;
|
||||
public interface IStoryIntelligenceGlobalProgressService
|
||||
{
|
||||
Task<StoryIntelligenceGlobalProgressViewModel> GetForCurrentUserAsync();
|
||||
Task<StoryIntelligenceGlobalProgressViewModel> 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<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)
|
||||
{
|
||||
var summaries = await runs.ListActiveBookRunSummariesForUserAsync(userId);
|
||||
return new StoryIntelligenceGlobalProgressViewModel
|
||||
{
|
||||
|
||||
@ -9,9 +9,18 @@ public interface IStoryIntelligenceProgressNotifier
|
||||
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)
|
||||
=> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user