Phase 21H - Story Intelligence Live Import Stability

This commit is contained in:
Nick Beckley 2026-07-12 19:16:12 +00:00
parent 2b7a4b599d
commit fd85473258
5 changed files with 255 additions and 25 deletions

View File

@ -156,6 +156,7 @@ public static class StoryIntelligenceExperiencePrototypeData
=> new()
{
Id = id,
ChapterLabel = $"Chapter {Math.Max(1, chapters)}",
SceneNumber = number,
Title = title,
Summary = summary,
@ -167,6 +168,7 @@ public static class StoryIntelligenceExperiencePrototypeData
ChapterTotal = 5,
ScenesAnalysed = number,
SceneTotal = sceneTotal,
ElapsedTime = $"{Math.Max(1, number * 2)}m",
EstimatedRemaining = remaining,
PovCharacterId = pov,
Location = location,

View File

@ -135,6 +135,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
Scenes = scenes
};
StabiliseCharacterIllustrations(model);
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
logger.LogInformation("Built Story Intelligence visualisation snapshot for run {RunId} in {ElapsedMs}ms.", runId, stopwatch.ElapsedMilliseconds);
return model;
@ -183,10 +184,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var sourceChapterCount = (await sources.ListChaptersAsync(session.BookID)).Count;
var bookChapterTotal = Math.Max(1, Math.Max(sourceChapterCount, runSnapshots.Count));
var overallProgressPercent = BookProgress(runSnapshots, bookChapterTotal, isTerminal: false);
var completedChapterRuns = runSnapshots.Count(snapshot => IsTerminal(snapshot.Run.Status));
var isTerminal = runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status))
&& string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase);
overallProgressPercent = BookProgress(runSnapshots, bookChapterTotal, isTerminal);
var completedChapterRuns = runSnapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status));
var isTerminal = runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status));
var isSuccessfullyComplete = runSnapshots.All(snapshot => IsSuccessfulTerminal(snapshot.Run.Status));
overallProgressPercent = BookProgress(runSnapshots, bookChapterTotal, isSuccessfullyComplete);
var overallElapsed = SessionElapsed(runSnapshots);
var overallRemaining = isTerminal ? "Ready to review" : EstimateSessionRemaining(runSnapshots, parsedScenes, totalScenes);
var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run);
var scenes = new List<StoryIntelligenceExperienceSceneState>();
var nextSceneNumber = 1;
foreach (var snapshot in runSnapshots)
@ -202,8 +206,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
overallChaptersAnalysed: completedChapterRuns,
overallChapterTotal: bookChapterTotal,
overallProgressPercent: overallProgressPercent,
overallStage: OverallStage(session, activeSnapshot.Run),
overallRemaining: EstimateSessionRemaining(runSnapshots, parsedScenes, totalScenes),
overallStage: overallStage,
overallElapsed: overallElapsed,
overallRemaining: overallRemaining,
continuousSceneNumbering: true,
overallTerminal: isTerminal)
.ToList();
@ -226,12 +231,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ChangeToken = SessionChangeToken(session, runSnapshots),
IsTerminal = isTerminal,
SnapshotMessage = SessionSnapshotMessage(session, runSnapshots),
Diagnostics = BuildSessionDiagnostics(session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults, overallProgressPercent),
Diagnostics = BuildSessionDiagnostics(session, runSnapshots, activeSnapshot, current, parsedScenes, totalPersistedResults, totalScenes, overallProgressPercent),
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
Scenes = scenes
};
StabiliseCharacterIllustrations(model);
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
logger.LogInformation("Built Story Intelligence import session visualisation snapshot for session {ImportSessionId} in {ElapsedMs}ms.", importSessionId, stopwatch.ElapsedMilliseconds);
return model;
@ -249,6 +255,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
int? overallChapterTotal = null,
int? overallProgressPercent = null,
string? overallStage = null,
string? overallElapsed = null,
string? overallRemaining = null,
bool continuousSceneNumbering = false,
bool overallTerminal = false)
@ -265,6 +272,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
yield return new StoryIntelligenceExperienceSceneState
{
Id = $"scene-result-{item.Result.SceneResultID}",
ChapterLabel = ChapterLabel(run, item),
SceneNumber = sceneNumber,
Title = SceneTitle(scene, item.Result, sceneNumber),
Summary = Trim(FirstConfigured(scene.Summary?.Short, scene.Summary?.Detailed, run.CurrentMessage, "Scene analysed."), 180),
@ -279,8 +287,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ChapterTotal = Math.Max(1, overallChapterTotal ?? run.SourceChapterCount ?? ChapterCount(completed)),
ScenesAnalysed = overallCompletedScenes ?? completedScenes,
SceneTotal = Math.Max(1, overallTotalScenes ?? totalScenes),
ElapsedTime = overallElapsed ?? EstimateElapsed(run),
EstimatedRemaining = overallRemaining ?? EstimateRemaining(run, completedScenes, totalScenes),
PovCharacterId = CharacterId(scene.PointOfView?.CharacterName),
PovCharacterId = CharacterId(ResolveNarratorName(scene) ?? scene.PointOfView?.CharacterName),
Location = BuildLocation(scene),
Characters = BuildCharacters(scene),
Assets = BuildAssets(scene),
@ -297,6 +306,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
=> new()
{
Id = $"run-{run.StoryIntelligenceRunID}-waiting",
ChapterLabel = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter",
SceneNumber = 1,
Title = run.Status is StoryIntelligenceRunStatuses.Failed ? "Analysis failed" : "Waiting for analysed scenes",
Summary = FirstConfigured(run.ErrorMessage, run.CurrentMessage, "No scene analysis results have been persisted yet."),
@ -308,6 +318,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ChapterTotal = Math.Max(1, run.SourceChapterCount ?? 1),
ScenesAnalysed = run.CompletedScenes ?? 0,
SceneTotal = Math.Max(1, run.TotalDetectedScenes ?? 1),
ElapsedTime = EstimateElapsed(run),
EstimatedRemaining = EstimateRemaining(run, run.CompletedScenes ?? 0, run.TotalDetectedScenes ?? 0),
Location = PendingLocation(),
LatestDiscoveries = run.ErrorMessage is null ? [] : [$"Run failed: {Trim(run.ErrorMessage, 90)}"],
@ -326,7 +337,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene)
{
var povName = Clean(scene.PointOfView?.CharacterName);
var narratorResolution = ResolveNarratorName(scene);
var povName = Clean(narratorResolution ?? scene.PointOfView?.CharacterName);
var states = new List<StoryIntelligenceExperienceCharacterState>();
var reservedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in (scene.Characters ?? [])
@ -334,6 +346,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
.GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase)
.Take(5))
{
if (!string.IsNullOrWhiteSpace(narratorResolution) && IsGenericNarratorName(group.Key))
{
continue;
}
var character = group.OrderByDescending(item => item.Confidence ?? 0).First();
var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase);
var libraryCode = CharacterLibraryCode(group.Key, character, scene, reservedCodes);
@ -368,6 +385,62 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
return states.OrderByDescending(character => character.Weight).ToList();
}
private static string? ResolveNarratorName(SceneIntelligenceScene scene)
{
var povName = Clean(scene.PointOfView?.CharacterName);
if (!IsGenericNarratorName(povName))
{
return null;
}
var evidence = string.Join(' ', new[]
{
scene.PointOfView?.Evidence,
scene.Summary?.Short,
scene.Summary?.Detailed,
scene.ScenePurpose?.ObservedFunction
}.Where(value => !string.IsNullOrWhiteSpace(value)));
var namedCharacters = (scene.Characters ?? [])
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
.Select(character => Clean(character.Name))
.Where(name => !IsGenericNarratorName(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var name in namedCharacters)
{
if (ContainsIdentityEvidence(evidence, name))
{
return name;
}
}
return namedCharacters.Count == 1 ? namedCharacters[0] : null;
}
private static bool ContainsIdentityEvidence(string evidence, string name)
{
if (string.IsNullOrWhiteSpace(evidence) || string.IsNullOrWhiteSpace(name))
{
return false;
}
var lowerEvidence = evidence.ToLowerInvariant();
var lowerName = name.ToLowerInvariant();
return lowerEvidence.Contains($"narrated by {lowerName}", StringComparison.Ordinal)
|| lowerEvidence.Contains($"narrator is {lowerName}", StringComparison.Ordinal)
|| lowerEvidence.Contains($"i am {lowerName}", StringComparison.Ordinal)
|| lowerEvidence.Contains($"i'm {lowerName}", StringComparison.Ordinal)
|| lowerEvidence.Contains($"my name is {lowerName}", StringComparison.Ordinal);
}
private static bool IsGenericNarratorName(string? name)
{
var value = Clean(name).ToLowerInvariant();
return value is "narrator" or "i" or "me" or "my" or "myself" or "first person narrator" or "unnamed narrator";
}
private static StoryIntelligenceExperienceLocationState BuildLocation(SceneIntelligenceScene scene)
{
var location = (scene.Locations ?? [])
@ -646,7 +719,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var active = snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status));
if (active is not null)
{
return FirstConfigured(active.Run.CurrentMessage, $"Analysing chapter {active.Run.ChapterNumber:0.##}.");
return BackendStageDisplay(active.Run);
}
return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
@ -658,13 +731,34 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{
if (snapshot.Run.TotalDetectedScenes.HasValue)
{
var completed = Math.Clamp(snapshot.Run.CompletedScenes ?? snapshot.Completed.Count, 0, snapshot.Run.TotalDetectedScenes.Value);
return $"Scene {completed:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}";
var scene = Math.Clamp((snapshot.Run.CompletedScenes ?? snapshot.Completed.Count) + (IsTerminal(snapshot.Run.Status) ? 0 : 1), 1, snapshot.Run.TotalDetectedScenes.Value);
return $"Scene {scene:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}";
}
return snapshot.Current is null ? "Waiting" : $"Scene {SceneSortNumber(snapshot.Current):0.##}";
}
private static string BackendStageDisplay(StoryIntelligenceSavedRun run)
{
var stage = FirstConfigured(run.CurrentStage, run.Status);
var sceneText = run.TotalDetectedScenes.HasValue
? $"Scene {Math.Clamp((run.CompletedScenes ?? 0) + (IsTerminal(run.Status) ? 0 : 1), 1, Math.Max(1, run.TotalDetectedScenes.Value)):N0}"
: "current scene";
var chapterText = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##} " : string.Empty;
return stage switch
{
StoryIntelligenceFailureStages.SceneIntelligence => $"Analysing {chapterText}{sceneText}",
StoryIntelligencePipelineStages.CharacterImport or StoryIntelligencePipelineStages.CharacterReview => "Analysing Characters",
StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.RelationshipReview => "Building Relationships",
StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.AssetReview => "Analysing Assets",
StoryIntelligencePipelineStages.LocationImport or StoryIntelligencePipelineStages.LocationReview => "Analysing Locations",
StoryIntelligenceFailureStages.DomainImport or StoryIntelligencePipelineStages.KnowledgeImport or StoryIntelligencePipelineStages.KnowledgeReview => "Inferring Knowledge",
StoryIntelligenceFailureStages.Persistence => "Building Continuity",
_ when stage.Contains("Scene", StringComparison.OrdinalIgnoreCase) => $"Analysing {chapterText}{sceneText}",
_ => FirstConfigured(run.CurrentMessage, stage, $"Analysing {chapterText}{sceneText}")
};
}
private static StoryIntelligenceExperienceSnapshotDiagnostics BuildSessionDiagnostics(
StoryIntelligenceBookPipelineState session,
IReadOnlyList<RunSnapshot> snapshots,
@ -672,6 +766,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ParsedSceneResult? current,
int parsedScenes,
int totalPersistedResults,
int totalScenes,
int overallProgressPercent)
{
var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null));
@ -679,6 +774,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
return new()
{
RequestedMode = "real",
CurrentImportId = session.StoryIntelligenceBookPipelineID,
RequestedImportSessionId = session.StoryIntelligenceBookPipelineID,
ResolvedImportSessionId = session.StoryIntelligenceBookPipelineID,
ActiveChapterRunId = activeSnapshot.Run.StoryIntelligenceRunID,
@ -686,6 +782,10 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
OverallBookProgress = overallProgressPercent,
CurrentChapter = activeSnapshot.Run.ChapterNumber.HasValue ? $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}" : "Chapter unknown",
CurrentScene = CurrentSceneText(activeSnapshot),
CompletedScenes = parsedScenes,
TotalScenes = totalScenes,
ProgressSource = "ImportSession",
CurrentBackendStage = BackendStageDisplay(activeSnapshot.Run),
SnapshotSource = "Real",
RunStatus = SessionStatus(session, snapshots),
PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count),
@ -722,8 +822,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
OverallBookProgress = Progress(run, run.CompletedScenes ?? completed.Count, Math.Max(run.TotalDetectedScenes ?? 0, results.Count)),
CurrentChapter = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter unknown",
CurrentScene = run.TotalDetectedScenes.HasValue
? $"Scene {Math.Clamp(run.CompletedScenes ?? completed.Count, 0, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}"
? $"Scene {Math.Clamp((run.CompletedScenes ?? completed.Count) + (IsTerminal(run.Status) ? 0 : 1), 1, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}"
: (current is null ? "Waiting" : $"Scene {SceneSortNumber(current):0.##}"),
CompletedScenes = run.CompletedScenes ?? completed.Count,
TotalScenes = Math.Max(run.TotalDetectedScenes ?? 0, results.Count),
ProgressSource = "ChapterRun",
CurrentBackendStage = BackendStageDisplay(run),
SnapshotSource = "Real",
RunStatus = run.Status,
PersistedCompletedSceneCount = run.CompletedScenes ?? 0,
@ -750,9 +854,36 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
_ => FirstConfigured(run.CurrentMessage, "Analysis snapshot is updating.")
};
private static void StabiliseCharacterIllustrations(StoryIntelligenceExperiencePrototypeViewModel model)
{
var firstAssignments = new Dictionary<string, (string LibraryCode, string ImagePath)>(StringComparer.OrdinalIgnoreCase);
foreach (var scene in model.Scenes.OrderBy(scene => scene.SceneNumber))
{
foreach (var character in scene.Characters)
{
if (string.IsNullOrWhiteSpace(character.Id))
{
continue;
}
if (firstAssignments.TryGetValue(character.Id, out var assignment))
{
character.LibraryCode = assignment.LibraryCode;
character.ImagePath = assignment.ImagePath;
continue;
}
firstAssignments[character.Id] = (character.LibraryCode, character.ImagePath);
}
}
}
private static bool IsTerminal(string status)
=> status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled;
private static bool IsSuccessfulTerminal(string status)
=> status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings;
private static int SceneNumber(ParsedSceneResult item)
=> item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber;
@ -764,11 +895,46 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
=> item.Scene?.SceneReference?.SceneNumber ?? item.Result.TemporarySceneNumber;
private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber)
=> FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}");
=> CompactSceneTitle(FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}"), sceneNumber);
private static string CompactSceneTitle(string title, int sceneNumber)
{
var value = Clean(title);
if (string.IsNullOrWhiteSpace(value))
{
return $"Scene {sceneNumber:N0}";
}
var sceneMarker = $"scene {sceneNumber}".ToLowerInvariant();
var lower = value.ToLowerInvariant();
var markerIndex = lower.IndexOf(sceneMarker, StringComparison.Ordinal);
if (markerIndex > 0)
{
value = value[markerIndex..].Trim(' ', '-', ':', '|');
}
var separators = new[] { " - ", " | ", ": " };
foreach (var separator in separators)
{
var parts = value.Split(separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 3)
{
return Trim(parts[^1], 72);
}
}
return Trim(value, 72);
}
private static string TimelineLabel(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result)
=> FirstConfigured(scene.TimelineClues?.FirstOrDefault()?.AbsoluteDate, scene.Setting?.DateOrTimeReference, result.SourceLabel, "Known scene");
private static string ChapterLabel(StoryIntelligenceSavedRun run, ParsedSceneResult item)
{
var chapter = item.Scene?.SceneReference?.ChapterNumber ?? run.ChapterNumber;
return chapter.HasValue ? $"Chapter {chapter:0.##}" : "Chapter";
}
private static int Progress(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes)
{
if (IsTerminal(run.Status) && run.Status is not StoryIntelligenceRunStatuses.Failed)
@ -817,7 +983,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private static decimal ChapterProgress(RunSnapshot snapshot)
{
if (IsTerminal(snapshot.Run.Status))
if (IsSuccessfulTerminal(snapshot.Run.Status))
{
return 1m;
}
@ -853,6 +1019,19 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
return FormatDuration(averageMs * (totalScenes - completedScenes));
}
private static string EstimateElapsed(StoryIntelligenceSavedRun run)
{
var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
return FormatDuration(elapsedMs);
}
private static string SessionElapsed(IReadOnlyList<RunSnapshot> snapshots)
{
var elapsedMs = snapshots.Sum(snapshot => snapshot.Run.TotalDurationMs
?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - snapshot.Run.StartedUtc).TotalMilliseconds)));
return FormatDuration(elapsedMs);
}
private static string FormatDuration(long milliseconds)
{
var duration = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds));

View File

@ -20,6 +20,7 @@ public sealed class StoryIntelligenceExperiencePrototypeViewModel
public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
{
public string RequestedMode { get; init; } = string.Empty;
public int? CurrentImportId { get; init; }
public int? RequestedImportSessionId { get; init; }
public int? ResolvedImportSessionId { get; init; }
public int? ActiveChapterRunId { get; init; }
@ -27,6 +28,10 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
public int OverallBookProgress { get; init; }
public string CurrentChapter { get; init; } = string.Empty;
public string CurrentScene { get; init; } = string.Empty;
public int CompletedScenes { get; init; }
public int TotalScenes { get; init; }
public string ProgressSource { get; init; } = string.Empty;
public string CurrentBackendStage { get; init; } = string.Empty;
public string SnapshotSource { get; init; } = string.Empty;
public string RunStatus { get; init; } = string.Empty;
public int PersistedCompletedSceneCount { get; init; }
@ -54,6 +59,7 @@ public sealed class StoryIntelligenceExperienceRunOption
public sealed class StoryIntelligenceExperienceSceneState
{
public string Id { get; init; } = string.Empty;
public string ChapterLabel { get; init; } = string.Empty;
public int SceneNumber { get; init; }
public string Title { get; init; } = string.Empty;
public string Summary { get; init; } = string.Empty;
@ -65,6 +71,7 @@ public sealed class StoryIntelligenceExperienceSceneState
public int ChapterTotal { get; init; }
public int ScenesAnalysed { get; init; }
public int SceneTotal { get; init; }
public string ElapsedTime { get; init; } = string.Empty;
public string EstimatedRemaining { get; init; } = string.Empty;
public string PovCharacterId { get; init; } = string.Empty;
public StoryIntelligenceExperienceLocationState Location { get; init; } = new();

View File

@ -46,9 +46,9 @@
</div>
<ol class="story-exp-pipeline" data-analysis-stages aria-label="Analysis stages"></ol>
<div class="story-exp-context">
<span>@statusLabel</span>
<span>@Model.ProjectName</span>
<span>@Model.BookTitle</span>
<span data-experience-status>@statusLabel</span>
<span data-project-name>@Model.ProjectName</span>
<span data-book-title>@Model.BookTitle</span>
<span data-stage-label>Preparing</span>
<span>Scene <strong data-current-scene>1</strong></span>
</div>
@ -68,7 +68,7 @@
@if (!string.IsNullOrWhiteSpace(Model.SnapshotMessage))
{
<aside class="story-exp-status-message" role="status">
<aside class="story-exp-status-message" role="status" data-snapshot-message>
@Model.SnapshotMessage
</aside>
}
@ -185,6 +185,10 @@
<details class="story-exp-diagnostics" data-diagnostics>
<summary>Diagnostics</summary>
<dl>
<div>
<dt>Current Import ID</dt>
<dd data-diagnostic-key="currentImportId">@Model.Diagnostics.CurrentImportId</dd>
</div>
<div>
<dt>Requested mode</dt>
<dd data-diagnostic-key="requestedMode">@Model.Diagnostics.RequestedMode</dd>
@ -217,6 +221,30 @@
<dt>Current scene</dt>
<dd data-diagnostic-key="currentScene">@Model.Diagnostics.CurrentScene</dd>
</div>
<div>
<dt>Completed scenes</dt>
<dd data-diagnostic-key="completedScenes">@Model.Diagnostics.CompletedScenes</dd>
</div>
<div>
<dt>Total scenes</dt>
<dd data-diagnostic-key="totalScenes">@Model.Diagnostics.TotalScenes</dd>
</div>
<div>
<dt>Progress source</dt>
<dd data-diagnostic-key="progressSource">@Model.Diagnostics.ProgressSource</dd>
</div>
<div>
<dt>Current backend stage</dt>
<dd data-diagnostic-key="currentBackendStage">@Model.Diagnostics.CurrentBackendStage</dd>
</div>
<div>
<dt>Current polling interval</dt>
<dd data-diagnostic-key="currentPollingInterval">Initial</dd>
</div>
<div>
<dt>Latest snapshot age</dt>
<dd data-diagnostic-key="latestSnapshotAge">Initial</dd>
</div>
<div>
<dt>Snapshot source</dt>
<dd data-diagnostic-key="snapshotSource">@Model.Diagnostics.SnapshotSource</dd>

View File

@ -52,6 +52,9 @@
const dom = {
stageLabel: root.querySelector("[data-stage-label]"),
projectName: root.querySelector("[data-project-name]"),
bookTitle: root.querySelector("[data-book-title]"),
snapshotMessage: root.querySelector("[data-snapshot-message]"),
stageRail: root.querySelector("[data-analysis-stages]"),
currentScene: root.querySelector("[data-current-scene]"),
progressPercent: root.querySelector("[data-progress-percent]"),
@ -141,12 +144,13 @@
state.terminal = !!model.isTerminal;
state.index = realMode ? Math.max(0, scenes.length - 1) : Math.min(state.index, Math.max(0, scenes.length - 1));
if (model.projectName) {
const project = root.querySelector(".story-exp-context span:first-child");
text(project, model.projectName);
text(dom.projectName, model.projectName);
}
if (model.bookTitle) {
const book = root.querySelector(".story-exp-context span:nth-child(2)");
text(book, model.bookTitle);
text(dom.bookTitle, model.bookTitle);
}
if (model.snapshotMessage) {
text(dom.snapshotMessage, model.snapshotMessage);
}
transitionToNextState(state.index, operations);
if (state.terminal) stopPolling("terminal model received");
@ -156,7 +160,7 @@
text(dom.stageLabel, scene.stage);
text(dom.currentScene, scene.sceneNumber);
animateNumber(dom.progressPercent, scene.progressPercent, "%");
animateNumber(dom.elapsedTime, Math.max(1, Math.round(scene.progressPercent / 9)), "m");
text(dom.elapsedTime, scene.elapsedTime || "Calculating");
text(dom.estimatedRemaining, scene.estimatedRemaining);
text(dom.chapterCount, `${scene.chaptersAnalysed} of ${scene.chapterTotal}`);
text(dom.sceneCount, `${scene.scenesAnalysed} of ${scene.sceneTotal}`);
@ -410,7 +414,7 @@
}
card.classList.toggle("is-current", item.id === scene.id);
card.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber);
text(card.querySelector("span"), `Scene ${item.sceneNumber}`);
text(card.querySelector("span"), `${item.chapterLabel || "Chapter"}Scene ${item.sceneNumber}`);
text(card.querySelector("strong"), compactPhrase(item.title, item.id === scene.id ? 42 : 28));
text(card.querySelector("small"), item.timelineEvent);
dom.ribbon.append(card);
@ -1267,10 +1271,20 @@
if (key === "consecutiveFailedPolls") return state.consecutiveFailedPolls;
if (key === "pollingActive") return state.pollTimer ? "active" : "stopped";
if (key === "pollingStoppedReason") return state.pollingStoppedReason || "none";
if (key === "currentPollingInterval") return realMode ? `${document.hidden ? 15000 : 7000}ms` : `${state.intervalMs}ms`;
if (key === "latestSnapshotAge") return latestSnapshotAge();
const value = diagnostics[key];
return value === null || value === undefined || value === "" ? "none" : value;
}
function latestSnapshotAge() {
if (!model.generatedUtc) return "unknown";
const generated = Date.parse(model.generatedUtc);
if (Number.isNaN(generated)) return "unknown";
const seconds = Math.max(0, Math.round((Date.now() - generated) / 1000));
return seconds < 60 ? `${seconds}s` : `${Math.round(seconds / 60)}m`;
}
function animateNumber(node, nextValue, suffix) {
if (!node) return;
const previousValue = Number.parseInt(node.textContent, 10);