Phase 21P.1 - Synchronisation Hotfix
This commit is contained in:
parent
63f427066d
commit
fd37df65c6
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user