Phase 21P.1 - Synchronisation Hotfix

This commit is contained in:
Nick Beckley 2026-07-14 21:14:02 +00:00
parent 63f427066d
commit fd37df65c6
11 changed files with 235 additions and 36 deletions

View File

@ -53,6 +53,7 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage), ("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage),
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes), ("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity), ("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity),
("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
@ -635,6 +636,23 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation mode."); Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation mode.");
} }
static void Phase21PUsesOneAuthoritativeCurrentVisualisationScene()
{
var root = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..");
var viewModel = File.ReadAllText(Path.Combine(root, "PlotLine", "ViewModels", "StoryIntelligenceExperiencePrototypeViewModels.cs"));
var snapshotService = File.ReadAllText(Path.Combine(root, "PlotLine", "Services", "StoryIntelligenceVisualisationSnapshotService.cs"));
var browser = File.ReadAllText(Path.Combine(root, "PlotLine", "wwwroot", "js", "story-intelligence-experience-prototype.js"));
var view = File.ReadAllText(Path.Combine(root, "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml"));
Assert(viewModel.Contains("public bool IsCurrent", StringComparison.Ordinal), "Scene snapshots must expose an authoritative current-scene marker.");
Assert(snapshotService.Contains("IsCurrent = isCurrent", StringComparison.Ordinal), "Snapshot builder must mark the selected current scene.");
Assert(snapshotService.Contains("followingCount: 0", StringComparison.Ordinal), "Real import snapshots must not include following scenes that can pull the browser ahead.");
Assert(snapshotService.Contains("Story Intelligence snapshot synchronisation", StringComparison.Ordinal), "Snapshot builder must log synchronisation evidence.");
Assert(browser.Contains("scene?.isCurrent", StringComparison.Ordinal), "Browser must choose the current scene from the authoritative marker.");
Assert(browser.Contains("Story Intelligence browser synchronisation", StringComparison.Ordinal), "Browser must log synchronisation evidence.");
Assert(view.Contains("Scenes = Model.Scenes", StringComparison.Ordinal), "Initial real page payload must include the same scene collection as polled snapshots.");
}
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false) static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false)
=> new( => new(
ageBand, ageBand,

View File

@ -8,7 +8,8 @@ namespace PlotLine.Controllers;
[Route("api/story-intelligence")] [Route("api/story-intelligence")]
public sealed class StoryIntelligenceVisualisationController( public sealed class StoryIntelligenceVisualisationController(
ICurrentUserService currentUser, ICurrentUserService currentUser,
IStoryIntelligenceVisualisationSnapshotService snapshots) : ControllerBase IStoryIntelligenceVisualisationSnapshotService snapshots,
ILogger<StoryIntelligenceVisualisationController> logger) : ControllerBase
{ {
[HttpGet("runs/{runId:int}/visualisation-snapshot")] [HttpGet("runs/{runId:int}/visualisation-snapshot")]
public async Task<IActionResult> GetSnapshot(int runId) public async Task<IActionResult> GetSnapshot(int runId)
@ -19,6 +20,18 @@ public sealed class StoryIntelligenceVisualisationController(
} }
var snapshot = await snapshots.BuildSnapshotAsync(runId, userId); var snapshot = await snapshots.BuildSnapshotAsync(runId, userId);
if (snapshot is not null)
{
var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault();
logger.LogInformation(
"Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.",
snapshot.ImportSessionId,
snapshot.RunId,
current?.ChapterLabel,
current?.SceneNumber,
snapshot.Diagnostics.SelectedCurrentSceneResultId,
snapshot.ChangeToken);
}
return snapshot is null ? NotFound() : Ok(snapshot); return snapshot is null ? NotFound() : Ok(snapshot);
} }
@ -31,6 +44,18 @@ public sealed class StoryIntelligenceVisualisationController(
} }
var snapshot = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId); var snapshot = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId);
if (snapshot is not null)
{
var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault();
logger.LogInformation(
"Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.",
snapshot.ImportSessionId,
snapshot.RunId,
current?.ChapterLabel,
current?.SceneNumber,
snapshot.Diagnostics.SelectedCurrentSceneResultId,
snapshot.ChangeToken);
}
return snapshot is null ? NotFound() : Ok(snapshot); return snapshot is null ? NotFound() : Ok(snapshot);
} }
} }

View File

@ -253,6 +253,7 @@ public sealed class OnboardingStoryIntelligenceService(
SourceWordCount = run.SourceWordCount, SourceWordCount = run.SourceWordCount,
TotalTokens = run.TotalTokens, TotalTokens = run.TotalTokens,
TotalDurationMs = run.TotalDurationMs, TotalDurationMs = run.TotalDurationMs,
UpdatedUtc = run.UpdatedUtc,
EstimatedCostUSD = run.EstimatedCostUSD, EstimatedCostUSD = run.EstimatedCostUSD,
EstimatedCostGBP = run.EstimatedCostGBP, EstimatedCostGBP = run.EstimatedCostGBP,
FailureStage = run.FailureStage, FailureStage = run.FailureStage,

View File

@ -13,6 +13,7 @@ public interface IPersistedStoryIntelligenceRunner
public sealed class PersistedStoryIntelligenceRunner( public sealed class PersistedStoryIntelligenceRunner(
IStoryIntelligenceResultRepository repository, IStoryIntelligenceResultRepository repository,
IStoryIntelligencePipelineRepository pipelines,
IStoryPromptRepository prompts, IStoryPromptRepository prompts,
IStoryPromptVersionService versions, IStoryPromptVersionService versions,
IStoryPromptBuilder scenePromptBuilder, IStoryPromptBuilder scenePromptBuilder,
@ -304,7 +305,7 @@ public sealed class PersistedStoryIntelligenceRunner(
hasWarnings = hasWarnings || validation.Warnings.Count > 0; hasWarnings = hasWarnings || validation.Warnings.Count > 0;
await repository.SaveSceneResultAsync( var sceneResultId = await repository.SaveSceneResultAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
chapterResultId, chapterResultId,
new StoryIntelligenceSceneResultSaveRequest new StoryIntelligenceSceneResultSaveRequest
@ -329,6 +330,14 @@ public sealed class PersistedStoryIntelligenceRunner(
TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens), TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens),
DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds) DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds)
}); });
var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null;
logger.LogInformation(
"Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status analysed.",
importSessionId,
run.StoryIntelligenceRunID,
run.ChapterNumber,
block.TemporarySceneNumber,
sceneResultId);
completedScenes++; completedScenes++;
await PublishAsync( await PublishAsync(
@ -721,7 +730,7 @@ public sealed class PersistedStoryIntelligenceRunner(
string message, string message,
AiJsonParseException? parseException = null) AiJsonParseException? parseException = null)
{ {
await repository.SaveSceneResultAsync( var sceneResultId = await repository.SaveSceneResultAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
chapterResultId, chapterResultId,
new StoryIntelligenceSceneResultSaveRequest new StoryIntelligenceSceneResultSaveRequest
@ -752,6 +761,14 @@ public sealed class PersistedStoryIntelligenceRunner(
TotalTokens = SumTokens(parseException?.InputTokens, parseException?.OutputTokens), TotalTokens = SumTokens(parseException?.InputTokens, parseException?.OutputTokens),
DurationMs = parseException is null ? null : Convert.ToInt64(parseException.Duration.TotalMilliseconds) DurationMs = parseException is null ? null : Convert.ToInt64(parseException.Duration.TotalMilliseconds)
}); });
var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null;
logger.LogInformation(
"Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status failed.",
importSessionId,
run.StoryIntelligenceRunID,
run.ChapterNumber,
block.TemporarySceneNumber,
sceneResultId);
} }
private StoryIntelligenceStageModels ResolveStageModels(StoryIntelligenceQueuedRun run) private StoryIntelligenceStageModels ResolveStageModels(StoryIntelligenceQueuedRun run)

View File

@ -188,7 +188,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
} }
var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last(); var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last();
var current = activeSnapshot.Current ?? runSnapshots.LastOrDefault(snapshot => snapshot.Completed.Count > 0)?.Current; var current = PickAuthoritativeCurrentScene(runSnapshots);
var currentSnapshot = OwnerOf(runSnapshots, current) ?? activeSnapshot;
var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count); var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count);
var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.Count); var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.Count);
var totalScenes = Math.Max( var totalScenes = Math.Max(
@ -215,8 +216,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run); var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run);
var scenes = new List<StoryIntelligenceExperienceSceneState>(); var scenes = new List<StoryIntelligenceExperienceSceneState>();
var storyMemory = new StoryMemory(); var storyMemory = new StoryMemory();
var allCompleted = runSnapshots.SelectMany(snapshot => snapshot.Completed).ToList(); var allCompleted = runSnapshots
var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, SceneWindowFollowing); .SelectMany(snapshot => snapshot.Completed)
.OrderBy(item => item.Result.CreatedUtc)
.ThenBy(item => item.Result.SceneResultID)
.ToList();
var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0);
foreach (var snapshot in runSnapshots) foreach (var snapshot in runSnapshots)
{ {
var runScenes = BuildSceneStates( var runScenes = BuildSceneStates(
@ -248,7 +253,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{ {
Mode = "real", Mode = "real",
ImportSessionId = session.StoryIntelligenceBookPipelineID, ImportSessionId = session.StoryIntelligenceBookPipelineID,
RunId = activeSnapshot.Run.StoryIntelligenceRunID, RunId = currentSnapshot.Run.StoryIntelligenceRunID,
BookId = session.BookID, BookId = session.BookID,
RunStatus = SessionStatus(session, runSnapshots), RunStatus = SessionStatus(session, runSnapshots),
GeneratedUtc = DateTime.UtcNow, GeneratedUtc = DateTime.UtcNow,
@ -258,7 +263,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
Diagnostics = BuildSessionDiagnostics( Diagnostics = BuildSessionDiagnostics(
session, session,
runSnapshots, runSnapshots,
activeSnapshot, currentSnapshot,
current, current,
parsedScenes, parsedScenes,
totalPersistedResults, totalPersistedResults,
@ -280,6 +285,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model); await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
ApplyIllustrationDiagnostics(model); ApplyIllustrationDiagnostics(model);
StripLiveDiagnostics(model); StripLiveDiagnostics(model);
LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken);
LogPayload(model, $"import session {importSessionId}", stopwatch.ElapsedMilliseconds); LogPayload(model, $"import session {importSessionId}", stopwatch.ElapsedMilliseconds);
return model; return model;
} }
@ -322,6 +328,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
yield return new StoryIntelligenceExperienceSceneState yield return new StoryIntelligenceExperienceSceneState
{ {
Id = $"scene-result-{item.Result.SceneResultID}", Id = $"scene-result-{item.Result.SceneResultID}",
IsCurrent = isCurrent,
ChapterLabel = ChapterLabel(run, item), ChapterLabel = ChapterLabel(run, item),
SceneNumber = sceneNumber, SceneNumber = sceneNumber,
Title = SceneTitle(scene, item.Result, sceneNumber), Title = SceneTitle(scene, item.Result, sceneNumber),
@ -382,9 +389,24 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
return null; return null;
} }
return completed.Last(); return completed
.OrderBy(item => item.Result.CreatedUtc)
.ThenBy(item => item.Result.SceneResultID)
.Last();
} }
private static ParsedSceneResult? PickAuthoritativeCurrentScene(IReadOnlyList<RunSnapshot> snapshots)
=> snapshots
.SelectMany(snapshot => snapshot.Completed)
.OrderBy(item => item.Result.CreatedUtc)
.ThenBy(item => item.Result.SceneResultID)
.LastOrDefault();
private static RunSnapshot? OwnerOf(IReadOnlyList<RunSnapshot> snapshots, ParsedSceneResult? scene)
=> scene is null
? null
: snapshots.FirstOrDefault(snapshot => snapshot.Run.StoryIntelligenceRunID == scene.Result.StoryIntelligenceRunID);
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene, StoryMemory? storyMemory = null) private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene, StoryMemory? storyMemory = null)
{ {
var narratorResolution = storyMemory?.ResolvePointOfView(scene) ?? ResolveNarratorName(scene); var narratorResolution = storyMemory?.ResolvePointOfView(scene) ?? ResolveNarratorName(scene);
@ -817,8 +839,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
} }
private static RunSnapshot? PickActiveRunSnapshot(IReadOnlyList<RunSnapshot> snapshots) private static RunSnapshot? PickActiveRunSnapshot(IReadOnlyList<RunSnapshot> snapshots)
=> snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status)) => snapshots
?? snapshots.LastOrDefault(snapshot => snapshot.Completed.Count > 0) .Where(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Running, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(snapshot => snapshot.Run.UpdatedUtc)
.ThenByDescending(snapshot => snapshot.Run.StoryIntelligenceRunID)
.FirstOrDefault()
?? snapshots
.Where(snapshot => !IsTerminal(snapshot.Run.Status))
.OrderBy(snapshot => snapshot.Run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(snapshot => snapshot.Run.StoryIntelligenceRunID)
.FirstOrDefault()
?? snapshots
.Where(snapshot => snapshot.Completed.Count > 0)
.OrderByDescending(snapshot => snapshot.Run.UpdatedUtc)
.ThenByDescending(snapshot => snapshot.Run.StoryIntelligenceRunID)
.FirstOrDefault()
?? snapshots.LastOrDefault(); ?? snapshots.LastOrDefault();
private static string OverallStage(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedRun activeRun) private static string OverallStage(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedRun activeRun)
@ -1290,7 +1325,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{ {
if (snapshot.Run.TotalDetectedScenes.HasValue) if (snapshot.Run.TotalDetectedScenes.HasValue)
{ {
var scene = Math.Clamp((snapshot.Run.CompletedScenes ?? snapshot.Completed.Count) + (IsTerminal(snapshot.Run.Status) ? 0 : 1), 1, snapshot.Run.TotalDetectedScenes.Value); var scene = Math.Clamp(snapshot.Run.CompletedScenes ?? snapshot.Completed.Count, 0, snapshot.Run.TotalDetectedScenes.Value);
return $"Scene {scene:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}"; return $"Scene {scene:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}";
} }
@ -1613,6 +1648,26 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
largestString.Length); largestString.Length);
} }
private void LogSnapshotSynchronisation(
int importSessionId,
RunSnapshot activeSnapshot,
ParsedSceneResult? current,
IReadOnlyList<StoryIntelligenceExperienceSceneState> scenes,
string changeToken)
{
var displayed = scenes.FirstOrDefault(scene => scene.IsCurrent) ?? scenes.LastOrDefault();
logger.LogInformation(
"Story Intelligence snapshot synchronisation: import session {ImportSessionId}; active run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; selected scene result {SceneResultId}; displayed scene id {DisplayedSceneId}; change token {ChangeToken}; payload scenes {SceneIds}.",
importSessionId,
activeSnapshot.Run.StoryIntelligenceRunID,
activeSnapshot.Run.ChapterNumber,
current is null ? null : SceneSortNumber(current),
current?.Result.SceneResultID,
displayed?.Id,
changeToken,
string.Join(",", scenes.Select(scene => scene.Id)));
}
private static (string Path, int Length) LargestStringProperty(StoryIntelligenceExperiencePrototypeViewModel model) private static (string Path, int Length) LargestStringProperty(StoryIntelligenceExperiencePrototypeViewModel model)
{ {
var largest = ("model", 0); var largest = ("model", 0);

View File

@ -531,6 +531,7 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel
public int? SourceWordCount { get; init; } public int? SourceWordCount { get; init; }
public int? TotalTokens { get; init; } public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; } public long? TotalDurationMs { get; init; }
public DateTime? UpdatedUtc { get; init; }
public decimal? EstimatedCostUSD { get; init; } public decimal? EstimatedCostUSD { get; init; }
public decimal? EstimatedCostGBP { get; init; } public decimal? EstimatedCostGBP { get; init; }
public string? FailureStage { get; init; } public string? FailureStage { get; init; }

View File

@ -80,6 +80,7 @@ public sealed class StoryIntelligenceExperienceRunOption
public sealed class StoryIntelligenceExperienceSceneState public sealed class StoryIntelligenceExperienceSceneState
{ {
public string Id { get; init; } = string.Empty; public string Id { get; init; } = string.Empty;
public bool IsCurrent { get; init; }
public string ChapterLabel { get; init; } = string.Empty; public string ChapterLabel { get; init; } = string.Empty;
public int SceneNumber { get; init; } public int SceneNumber { get; init; }
public string Title { get; init; } = string.Empty; public string Title { get; init; } = string.Empty;

View File

@ -31,7 +31,9 @@
ProjectName = Model.ProjectName, ProjectName = Model.ProjectName,
BookTitle = Model.BookTitle, BookTitle = Model.BookTitle,
SnapshotMessage = Model.SnapshotMessage, SnapshotMessage = Model.SnapshotMessage,
Diagnostics = Model.Diagnostics Diagnostics = Model.Diagnostics,
Scenes = Model.Scenes,
ChangeToken = Model.ChangeToken
} }
: Model; : Model;
} }
@ -54,8 +56,8 @@
data-book-id="@Model.BookId" data-book-id="@Model.BookId"
data-import-session-id="@Model.ImportSessionId" data-import-session-id="@Model.ImportSessionId"
data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)" data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)"
data-change-token="@(Model.Mode == "real" ? string.Empty : Model.ChangeToken)" data-change-token="@Model.ChangeToken"
data-terminal="@((Model.Mode != "real" && Model.IsTerminal).ToString().ToLowerInvariant())"> data-terminal="@Model.IsTerminal.ToString().ToLowerInvariant()">
<header class="story-exp-header" aria-label="Story Intelligence status"> <header class="story-exp-header" aria-label="Story Intelligence status">
<div class="story-exp-brand"> <div class="story-exp-brand">
<strong>PlotDirector</strong> <strong>PlotDirector</strong>

View File

@ -113,6 +113,10 @@
<div data-story-run-id="@chapter.RunID" <div data-story-run-id="@chapter.RunID"
data-story-chapter-title="@chapter.ChapterTitle" data-story-chapter-title="@chapter.ChapterTitle"
data-story-raw-status="@chapter.Status" data-story-raw-status="@chapter.Status"
data-story-chapter-number="@chapter.ChapterNumber"
data-story-current-stage="@(chapter.CurrentStage ?? string.Empty)"
data-story-current-scene-number="@(chapter.CompletedScenes ?? 0)"
data-story-updated-utc="@(chapter.UpdatedUtc?.ToUniversalTime().ToString("O") ?? string.Empty)"
data-story-source-word-count="@(chapter.SourceWordCount ?? 0)" data-story-source-word-count="@(chapter.SourceWordCount ?? 0)"
data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)"> data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)">
<span data-story-run-status>@FriendlyStatus(chapter.Status)</span> <span data-story-run-status>@FriendlyStatus(chapter.Status)</span>

View File

@ -133,7 +133,8 @@
return; return;
} }
state.index = realMode ? Math.max(0, scenes.length - 1) : Math.min(state.index, Math.max(0, scenes.length - 1)); state.index = realMode ? authoritativeSceneIndex(scenes) : Math.min(state.index, Math.max(0, scenes.length - 1));
logSnapshotSynchronisation("snapshot-applied", model, loadSceneState(state.index));
if (model.projectName) { if (model.projectName) {
text(dom.projectName, model.projectName); text(dom.projectName, model.projectName);
} }
@ -1219,7 +1220,25 @@
function activeScene(snapshot) { function activeScene(snapshot) {
const snapshotScenes = snapshot?.scenes || []; const snapshotScenes = snapshot?.scenes || [];
return snapshotScenes.length ? snapshotScenes[snapshotScenes.length - 1] : null; return snapshotScenes.find((scene) => scene?.isCurrent) || (snapshotScenes.length ? snapshotScenes[snapshotScenes.length - 1] : null);
}
function authoritativeSceneIndex(snapshotScenes) {
const index = (snapshotScenes || []).findIndex((scene) => scene?.isCurrent);
return index >= 0 ? index : Math.max(0, (snapshotScenes || []).length - 1);
}
function logSnapshotSynchronisation(eventName, snapshot, displayedScene) {
if (!realMode || !window.console?.info) return;
const authoritative = activeScene(snapshot);
console.info("Story Intelligence browser synchronisation", {
event: eventName,
changeToken: snapshot?.changeToken || "",
snapshotSceneId: authoritative?.id || "",
displayedSceneId: displayedScene?.id || "",
snapshotSceneNumber: authoritative?.sceneNumber || "",
displayedSceneNumber: displayedScene?.sceneNumber || ""
});
} }
function keyedById(items) { function keyedById(items) {
@ -1414,7 +1433,7 @@
} }
if (scenes.length) { if (scenes.length) {
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0); transitionToNextState(realMode ? authoritativeSceneIndex(scenes) : 0);
startTimer(); startTimer();
} else { } else {
updateDiagnostics(); updateDiagnostics();

View File

@ -243,6 +243,73 @@
} }
}; };
const parseNumber = (value) => Number.parseFloat(String(value || "").replace(/,/g, "")) || 0;
const parseTime = (value) => {
const parsed = Date.parse(value || "");
return Number.isNaN(parsed) ? 0 : parsed;
};
const isActiveStatus = (status) => status === "Pending" || status === "Running";
const authoritativeChapter = (chapters) => {
const active = chapters
.filter((chapter) => isActiveStatus(chapter.dataset.storyRawStatus || "")
&& parseNumber(chapter.dataset.storyCurrentSceneNumber) > 0)
.sort((left, right) => {
const byUpdated = parseTime(right.dataset.storyUpdatedUtc) - parseTime(left.dataset.storyUpdatedUtc);
if (byUpdated !== 0) return byUpdated;
return parseNumber(right.dataset.storyChapterNumber) - parseNumber(left.dataset.storyChapterNumber);
});
if (active.length > 0) return active[0];
const completed = chapters
.filter((chapter) => {
const status = chapter.dataset.storyRawStatus || "";
return status === "Completed" || status === "CompletedWithWarnings";
})
.sort((left, right) => {
const byUpdated = parseTime(right.dataset.storyUpdatedUtc) - parseTime(left.dataset.storyUpdatedUtc);
if (byUpdated !== 0) return byUpdated;
return parseNumber(right.dataset.storyChapterNumber) - parseNumber(left.dataset.storyChapterNumber);
});
return completed[0] || chapters[0] || null;
};
const applyAuthoritativeCursor = (root) => {
const chapters = [...root.querySelectorAll("[data-story-run-id]")];
const chapter = authoritativeChapter(chapters);
if (!chapter) return;
const status = chapter.dataset.storyRawStatus || "Pending";
const stage = chapter.dataset.storyCurrentStage || status;
const currentSceneNumber = chapter.dataset.storyCurrentSceneNumber || "";
const total = chapter.querySelector("[data-story-run-total]")?.textContent || "";
const completed = chapter.querySelector("[data-story-run-completed]")?.textContent || "";
const message = chapter.querySelector("[data-story-run-message]")?.textContent || "";
root.querySelectorAll("[data-story-current-chapter]").forEach((node) => {
node.textContent = chapter.dataset.storyChapterTitle || "Current chapter";
});
root.querySelectorAll("[data-story-current-stage]").forEach((node) => {
node.textContent = friendlyStage(stage, status);
});
root.querySelectorAll("[data-story-current-scene]").forEach((node) => {
node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting";
});
const totalNumber = Number.parseInt(total.replace(/,/g, "") || "0", 10) || 0;
const completedNumber = Number.parseInt(completed.replace(/,/g, "") || "0", 10) || 0;
const currentSceneDisplay = currentSceneNumber ? Number.parseInt(currentSceneNumber, 10) || completedNumber : completedNumber;
root.querySelectorAll("[data-story-chapter-progress-text]").forEach((node) => {
node.textContent = totalNumber > 0
? `Scene ${numberText(Math.min(Math.max(currentSceneDisplay, 0), totalNumber))} of ${numberText(totalNumber)}`
: "Finding scenes...";
});
root.querySelectorAll("[data-story-current-message]").forEach((node) => {
node.textContent = message;
});
};
const recalculateOnboardingTotals = (root) => { const recalculateOnboardingTotals = (root) => {
const hadActiveRuns = root.dataset.storyIntelligenceHasActiveRuns === "true"; const hadActiveRuns = root.dataset.storyIntelligenceHasActiveRuns === "true";
const chapters = [...root.querySelectorAll("[data-story-run-id]")]; const chapters = [...root.querySelectorAll("[data-story-run-id]")];
@ -306,6 +373,7 @@
root.querySelectorAll("[data-story-remaining]").forEach((node) => { root.querySelectorAll("[data-story-remaining]").forEach((node) => {
node.textContent = calculateRemaining(chapters, active); node.textContent = calculateRemaining(chapters, active);
}); });
applyAuthoritativeCursor(root);
if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") { if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") {
root.dataset.reloadScheduled = "true"; root.dataset.reloadScheduled = "true";
@ -335,6 +403,7 @@
const totalDurationMs = field(state, "totalDurationMs", "TotalDurationMs"); const totalDurationMs = field(state, "totalDurationMs", "TotalDurationMs");
const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec"; const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec";
const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber"); const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber");
const updatedUtc = field(state, "updatedUtc", "UpdatedUtc");
const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber); const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber);
const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary"); const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary");
const discoveries = field(state, "recentDiscoveries", "RecentDiscoveries") || []; const discoveries = field(state, "recentDiscoveries", "RecentDiscoveries") || [];
@ -344,6 +413,11 @@
node.textContent = statusLabel(state); node.textContent = statusLabel(state);
}); });
chapter.dataset.storyRawStatus = status; chapter.dataset.storyRawStatus = status;
chapter.dataset.storyCurrentStage = stage;
chapter.dataset.storyCurrentSceneNumber = completed || "0";
if (updatedUtc) {
chapter.dataset.storyUpdatedUtc = updatedUtc;
}
if (totalDurationMs !== null) { if (totalDurationMs !== null) {
chapter.dataset.storyDurationMs = String(totalDurationMs); chapter.dataset.storyDurationMs = String(totalDurationMs);
} }
@ -392,30 +466,12 @@
} }
}); });
root.querySelectorAll("[data-story-current-chapter]").forEach((node) => {
node.textContent = chapter.dataset.storyChapterTitle || "Current chapter";
});
root.querySelectorAll("[data-story-current-stage]").forEach((node) => {
node.textContent = friendlyStage(stage, status);
});
root.querySelectorAll("[data-story-current-scene]").forEach((node) => {
node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting";
});
const totalNumber = Number.parseInt(total || "0", 10) || 0; const totalNumber = Number.parseInt(total || "0", 10) || 0;
const completedNumber = Number.parseInt(completed || "0", 10) || 0; const completedNumber = Number.parseInt(completed || "0", 10) || 0;
const currentSceneDisplay = currentSceneNumber ? Number.parseInt(currentSceneNumber, 10) || completedNumber : completedNumber;
root.querySelectorAll("[data-story-chapter-progress-text]").forEach((node) => {
node.textContent = totalNumber > 0
? `Scene ${numberText(Math.min(Math.max(currentSceneDisplay, 0), totalNumber))} of ${numberText(totalNumber)}`
: "Finding scenes...";
});
root.querySelectorAll("[data-story-chapter-progress-bar]").forEach((node) => { root.querySelectorAll("[data-story-chapter-progress-bar]").forEach((node) => {
const percent = totalNumber > 0 ? Math.floor((Math.min(completedNumber, totalNumber) * 100) / totalNumber) : 0; const percent = totalNumber > 0 ? Math.floor((Math.min(completedNumber, totalNumber) * 100) / totalNumber) : 0;
node.style.width = `${Math.max(0, Math.min(100, percent))}%`; node.style.width = `${Math.max(0, Math.min(100, percent))}%`;
}); });
root.querySelectorAll("[data-story-current-message]").forEach((node) => {
node.textContent = displayMessage;
});
root.querySelectorAll("[data-story-elapsed]").forEach((node) => { root.querySelectorAll("[data-story-elapsed]").forEach((node) => {
node.textContent = elapsed; node.textContent = elapsed;
}); });