Fix Story Intelligence progress scene context

This commit is contained in:
Nick Beckley 2026-08-27 20:11:24 +00:00
parent a2cf6a96a8
commit ed4ad31466
9 changed files with 251 additions and 21 deletions

View File

@ -84,6 +84,7 @@ var tests = new (string Name, Action Test)[]
("Scan review separates known matches from unresolved names", ScanReviewSeparatesKnownMatchesFromUnresolvedNames), ("Scan review separates known matches from unresolved names", ScanReviewSeparatesKnownMatchesFromUnresolvedNames),
("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart), ("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart),
("Story Intelligence canonical mode bypasses duplicate boundary analysis", StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis), ("Story Intelligence canonical mode bypasses duplicate boundary analysis", StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis),
("Story Intelligence progress uses canonical scene summaries", StoryIntelligenceProgressUsesCanonicalSceneSummaries),
("Story Intelligence downstream imports prefer canonical SceneIDs", StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds), ("Story Intelligence downstream imports prefer canonical SceneIDs", StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds),
("Story Intelligence scene prompt no longer asks for duplicate factual summary", StoryIntelligenceScenePromptNoLongerRequestsDuplicateSummary), ("Story Intelligence scene prompt no longer asks for duplicate factual summary", StoryIntelligenceScenePromptNoLongerRequestsDuplicateSummary),
("Story Intelligence review uses canonical structural summaries and counts", StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts), ("Story Intelligence review uses canonical structural summaries and counts", StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts),
@ -2013,7 +2014,7 @@ static void StoryIntelligenceUsesPersistedSceneSourceAfterRestart()
Assert(onboardingStory.Contains("durableSource = await sources.GetChapterSourceAsync", StringComparison.Ordinal), "Onboarding Story Intelligence should read durable chapter text from scene sources."); Assert(onboardingStory.Contains("durableSource = await sources.GetChapterSourceAsync", StringComparison.Ordinal), "Onboarding Story Intelligence should read durable chapter text from scene sources.");
Assert(onboardingStory.Contains("CoreImportComplete", StringComparison.Ordinal), "Optional onboarding Story Intelligence should require core import completion."); Assert(onboardingStory.Contains("CoreImportComplete", StringComparison.Ordinal), "Optional onboarding Story Intelligence should require core import completion.");
Assert(existingQueue.Contains("sources.GetChapterSourceAsync", StringComparison.Ordinal), "Later Story Intelligence queueing should start from persisted chapter/scene source, not onboarding DTOs."); Assert(existingQueue.Contains("sources.GetChapterSourceAsync", StringComparison.Ordinal), "Later Story Intelligence queueing should start from persisted chapter/scene source, not onboarding DTOs.");
Assert(runner.Contains("FirstNonBlank(source.StructuralSummary, scene.StructuralSummary)", StringComparison.Ordinal), "Scene Intelligence context should receive structural summaries from canonical scene sources."); Assert(runner.Contains("FirstNonBlank(scene.StructuralSummary, scene.Summary, source.StructuralSummary)", StringComparison.Ordinal), "Scene Intelligence context should prefer canonical scene summaries before source-row fallbacks.");
} }
static void StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis() static void StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis()
@ -2038,6 +2039,31 @@ static void StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis()
Assert(progressJs.Contains("Loading scenes...", StringComparison.Ordinal), "Live canonical progress should load scenes rather than claim to find them."); Assert(progressJs.Contains("Loading scenes...", StringComparison.Ordinal), "Live canonical progress should load scenes rather than claim to find them.");
} }
static void StoryIntelligenceProgressUsesCanonicalSceneSummaries()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/StoryIntelligenceHub.cs"));
var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml"));
var progressJs = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-progress.js"));
var css = File.ReadAllText(Path.Combine(root, "wwwroot/css/onboarding.css"));
Assert(onboardingStory.Contains("CurrentCanonicalScene(run, canonicalScenes)", StringComparison.Ordinal), "Progress model should resolve the current canonical scene for each active run.");
Assert(onboardingStory.Contains("CanonicalSceneSummary(currentScene)", StringComparison.Ordinal), "Progress model should use canonical scene summaries.");
Assert(onboardingStory.Contains("FirstConfigured(scene?.StructuralSummary, scene?.Summary)", StringComparison.Ordinal), "Canonical progress summary should prefer Scenes.StructuralSummary before Scenes.Summary.");
Assert(runner.Contains("latestSceneSummary: block.CanonicalSummary", StringComparison.Ordinal), "Live progress events should send the canonical scene summary.");
Assert(runner.Contains("CurrentSceneID = currentSceneId", StringComparison.Ordinal), "Live progress events should carry canonical SceneID.");
Assert(hub.Contains("CurrentCanonicalScene(run, canonicalScenes)", StringComparison.Ordinal), "SignalR reconnect snapshots should also resolve canonical scenes.");
Assert(progressView.Contains("data-story-latest-scene-title", StringComparison.Ordinal), "Progress UI should render the current canonical scene title.");
Assert(progressView.Contains("data-story-current-scene-id", StringComparison.Ordinal), "Rendered chapter state should carry canonical SceneID.");
Assert(!progressView.Contains("Scene summaries will appear here", StringComparison.Ordinal), "Progress UI should not imply Story Intelligence is generating summaries.");
Assert(!progressView.Contains("with summaries, context", StringComparison.Ordinal), "Progress hero should not describe summaries as Story Intelligence work.");
Assert(progressJs.Contains("currentSceneDisplayTitle", StringComparison.Ordinal), "Live browser updates should carry the current canonical scene title.");
Assert(css.Contains("white-space: normal;", StringComparison.Ordinal), "Progress info alert should allow wrapping.");
Assert(!runner.Contains("latestSceneSummary: SceneSummaryText(sceneAttempt.Parsed)", StringComparison.Ordinal), "Live progress should not use the Scene Intelligence parsed summary as the manuscript summary.");
}
static void StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds() static void StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -43,8 +43,12 @@ public sealed class StoryIntelligenceHub(
return null; return null;
} }
var sceneResults = await persistedRuns.ListSceneResultsAsync(runId); var canonicalScenes = run.ChapterID.HasValue
return ToRunProgress(run, "Current status", LatestSceneSummary(sceneResults)); ? await persistedRuns.ListChapterScenesAsync(run.ChapterID.Value)
: [];
var currentScene = CurrentCanonicalScene(run, canonicalScenes);
var sceneResults = currentScene is null ? await persistedRuns.ListSceneResultsAsync(runId) : [];
return ToRunProgress(run, "Current status", currentScene, currentScene is null ? LatestSceneSummary(sceneResults) : CanonicalSceneSummary(currentScene));
} }
public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId) public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId)
@ -56,11 +60,14 @@ public sealed class StoryIntelligenceHub(
public static string UserGroup(int userId) => $"story-intelligence:{userId}"; public static string UserGroup(int userId) => $"story-intelligence:{userId}";
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, string? latestSceneSummary) private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, string? latestSceneSummary)
{ {
var completedScenes = run.CompletedScenes ?? 0; var completedScenes = run.CompletedScenes ?? 0;
var totalScenes = run.TotalDetectedScenes ?? 0; var totalScenes = run.TotalDetectedScenes ?? 0;
var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds)); var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
var currentSceneNumber = currentScene is null
? CurrentSceneNumber(run)
: Convert.ToInt32(currentScene.SceneNumber);
return new StoryIntelligenceRunProgressEvent return new StoryIntelligenceRunProgressEvent
{ {
@ -79,7 +86,11 @@ public sealed class StoryIntelligenceHub(
FailedScenes = run.FailedScenes, FailedScenes = run.FailedScenes,
TotalTokens = run.TotalTokens, TotalTokens = run.TotalTokens,
TotalDurationMs = elapsedMs, TotalDurationMs = elapsedMs,
CurrentSceneNumber = run.CompletedScenes, CurrentSceneID = currentScene?.SceneID,
CurrentSceneNumber = currentSceneNumber,
CurrentSceneDisplayTitle = currentScene is null || !currentSceneNumber.HasValue
? null
: SceneTitleDisplay.Format(currentScene.SceneNumber, currentScene.SceneTitle),
ElapsedTime = FormatDuration(elapsedMs), ElapsedTime = FormatDuration(elapsedMs),
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes), EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes),
LatestSceneSummary = latestSceneSummary, LatestSceneSummary = latestSceneSummary,
@ -90,6 +101,56 @@ public sealed class StoryIntelligenceHub(
}; };
} }
private static Scene? CurrentCanonicalScene(StoryIntelligenceSavedRun run, IReadOnlyList<Scene> scenes)
{
if (scenes.Count == 0)
{
return null;
}
var orderedScenes = scenes.OrderBy(scene => scene.SceneNumber).ToList();
var sceneNumber = CurrentSceneNumber(run);
if (sceneNumber is not > 0)
{
return orderedScenes[0];
}
var byNumber = orderedScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == sceneNumber.Value);
if (byNumber is not null)
{
return byNumber;
}
return orderedScenes[Math.Clamp(sceneNumber.Value - 1, 0, orderedScenes.Count - 1)];
}
private static int? CurrentSceneNumber(StoryIntelligenceSavedRun run)
{
var completed = Math.Max(0, run.CompletedScenes ?? 0);
var total = Math.Max(0, run.TotalDetectedScenes ?? 0);
if (run.Status == StoryIntelligenceRunStatuses.Running
&& string.Equals(run.CurrentStage, StoryIntelligenceFailureStages.SceneIntelligence, StringComparison.OrdinalIgnoreCase)
&& total > 0)
{
return Math.Clamp(completed + 1, 1, total);
}
if (completed > 0)
{
return Math.Clamp(completed, 1, total > 0 ? total : completed);
}
return run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running && total > 0
? 1
: null;
}
private static string? CanonicalSceneSummary(Scene? scene)
=> FirstConfigured(scene?.StructuralSummary, scene?.Summary);
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string FormatDuration(long? milliseconds) private static string FormatDuration(long? milliseconds)
{ {
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0)); var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0));

View File

@ -82,7 +82,9 @@ public sealed class StoryIntelligenceRunProgressEvent
public int? FailedScenes { get; init; } public int? FailedScenes { get; init; }
public int? TotalTokens { get; init; } public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; } public long? TotalDurationMs { get; init; }
public int? CurrentSceneID { get; init; }
public int? CurrentSceneNumber { get; init; } public int? CurrentSceneNumber { get; init; }
public string? CurrentSceneDisplayTitle { get; init; }
public string ElapsedTime { get; init; } = "0 sec"; public string ElapsedTime { get; init; } = "0 sec";
public string? EstimatedRemaining { get; init; } public string? EstimatedRemaining { get; init; }
public string? LatestSceneSummary { get; init; } public string? LatestSceneSummary { get; init; }

View File

@ -448,6 +448,17 @@ public sealed class OnboardingStoryIntelligenceService(
? await commits.BuildConfirmationAsync(item.RunID) ? await commits.BuildConfirmationAsync(item.RunID)
: null; : null;
var sceneResults = await runs.ListSceneResultsAsync(item.RunID); var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
var canonicalScenes = item.ChapterID > 0
? await runs.ListChapterScenesAsync(item.ChapterID)
: [];
var currentScene = CurrentCanonicalScene(run, canonicalScenes);
var currentSceneNumber = currentScene is null
? CurrentSceneNumber(run)
: Convert.ToInt32(currentScene.SceneNumber);
var currentSceneDisplayTitle = currentScene is null
? (currentSceneNumber.HasValue ? SceneTitleDisplay.Format(currentSceneNumber.Value, null) : null)
: SceneTitleDisplay.Format(currentScene.SceneNumber, currentScene.SceneTitle);
var canonicalSceneSummary = CanonicalSceneSummary(currentScene);
chapters.Add(new StoryIntelligenceOnboardingChapterViewModel chapters.Add(new StoryIntelligenceOnboardingChapterViewModel
{ {
@ -470,7 +481,10 @@ public sealed class OnboardingStoryIntelligenceService(
EstimatedCostGBP = run.EstimatedCostGBP, EstimatedCostGBP = run.EstimatedCostGBP,
FailureStage = run.FailureStage, FailureStage = run.FailureStage,
ErrorMessage = run.ErrorMessage, ErrorMessage = run.ErrorMessage,
LatestSceneSummary = LatestSceneSummary(sceneResults), CurrentSceneID = currentScene?.SceneID,
CurrentSceneNumber = currentSceneNumber,
CurrentSceneDisplayTitle = currentSceneDisplayTitle,
LatestSceneSummary = currentScene is null ? LatestSceneSummary(sceneResults) : canonicalSceneSummary,
HasCompletedCommit = commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase), HasCompletedCommit = commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase),
CanCommit = confirmation?.CanCommit == true, CanCommit = confirmation?.CanCommit == true,
ScenesCreated = commit?.ScenesCreated ?? 0, ScenesCreated = commit?.ScenesCreated ?? 0,
@ -1003,6 +1017,53 @@ public sealed class OnboardingStoryIntelligenceService(
? 0 ? 0
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
private static Scene? CurrentCanonicalScene(StoryIntelligenceSavedRun run, IReadOnlyList<Scene> scenes)
{
if (scenes.Count == 0)
{
return null;
}
var orderedScenes = scenes.OrderBy(scene => scene.SceneNumber).ToList();
var sceneNumber = CurrentSceneNumber(run);
if (sceneNumber is not > 0)
{
return orderedScenes[0];
}
var byNumber = orderedScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == sceneNumber.Value);
if (byNumber is not null)
{
return byNumber;
}
return orderedScenes[Math.Clamp(sceneNumber.Value - 1, 0, orderedScenes.Count - 1)];
}
private static int? CurrentSceneNumber(StoryIntelligenceSavedRun run)
{
var completed = Math.Max(0, run.CompletedScenes ?? 0);
var total = Math.Max(0, run.TotalDetectedScenes ?? 0);
if (run.Status == StoryIntelligenceRunStatuses.Running
&& string.Equals(run.CurrentStage, StoryIntelligenceFailureStages.SceneIntelligence, StringComparison.OrdinalIgnoreCase)
&& total > 0)
{
return Math.Clamp(completed + 1, 1, total);
}
if (completed > 0)
{
return Math.Clamp(completed, 1, total > 0 ? total : completed);
}
return run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running && total > 0
? 1
: null;
}
private static string? CanonicalSceneSummary(Scene? scene)
=> FirstConfigured(scene?.StructuralSummary, scene?.Summary);
private static string? LatestSceneSummary(IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults) private static string? LatestSceneSummary(IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults)
{ {
foreach (var scene in sceneResults foreach (var scene in sceneResults

View File

@ -160,7 +160,11 @@ public sealed class PersistedStoryIntelligenceRunner(
totalDetectedScenes: sceneBlocks.Count, totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes, completedScenes: completedScenes,
failedScenes: failedScenes, failedScenes: failedScenes,
totalTokens: totals.TotalTokens); totalTokens: totals.TotalTokens,
currentSceneId: block.SceneID,
currentSceneNumber: block.TemporarySceneNumber,
currentSceneDisplayTitle: block.DisplayTitle,
latestSceneSummary: block.CanonicalSummary);
continue; continue;
} }
@ -182,7 +186,10 @@ public sealed class PersistedStoryIntelligenceRunner(
completedScenes: completedScenes, completedScenes: completedScenes,
failedScenes: failedScenes, failedScenes: failedScenes,
totalTokens: totals.TotalTokens, totalTokens: totals.TotalTokens,
currentSceneNumber: block.TemporarySceneNumber); currentSceneId: block.SceneID,
currentSceneNumber: block.TemporarySceneNumber,
currentSceneDisplayTitle: block.DisplayTitle,
latestSceneSummary: block.CanonicalSummary);
try try
{ {
@ -307,7 +314,9 @@ public sealed class PersistedStoryIntelligenceRunner(
failedScenes: failedScenes, failedScenes: failedScenes,
totalTokens: totals.TotalTokens, totalTokens: totals.TotalTokens,
currentSceneNumber: block.TemporarySceneNumber, currentSceneNumber: block.TemporarySceneNumber,
latestSceneSummary: SceneSummaryText(sceneAttempt.Parsed), currentSceneId: block.SceneID,
currentSceneDisplayTitle: block.DisplayTitle,
latestSceneSummary: block.CanonicalSummary,
recentDiscoveries: BuildDiscoveries(sceneAttempt.Parsed)); recentDiscoveries: BuildDiscoveries(sceneAttempt.Parsed));
if (!validation.IsValid || validation.Warnings.Count > 0) if (!validation.IsValid || validation.Warnings.Count > 0)
{ {
@ -330,6 +339,9 @@ public sealed class PersistedStoryIntelligenceRunner(
failedScenes: failedScenes, failedScenes: failedScenes,
totalTokens: totals.TotalTokens, totalTokens: totals.TotalTokens,
currentSceneNumber: block.TemporarySceneNumber, currentSceneNumber: block.TemporarySceneNumber,
currentSceneId: block.SceneID,
currentSceneDisplayTitle: block.DisplayTitle,
latestSceneSummary: block.CanonicalSummary,
errorMessage: ex.Message); errorMessage: ex.Message);
logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber); logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber);
} }
@ -690,7 +702,9 @@ public sealed class PersistedStoryIntelligenceRunner(
int? completedScenes = null, int? completedScenes = null,
int? failedScenes = null, int? failedScenes = null,
int? totalTokens = null, int? totalTokens = null,
int? currentSceneId = null,
int? currentSceneNumber = null, int? currentSceneNumber = null,
string? currentSceneDisplayTitle = null,
string? latestSceneSummary = null, string? latestSceneSummary = null,
IReadOnlyList<string>? recentDiscoveries = null, IReadOnlyList<string>? recentDiscoveries = null,
string? errorMessage = null) string? errorMessage = null)
@ -711,7 +725,9 @@ public sealed class PersistedStoryIntelligenceRunner(
FailedScenes = failedScenes, FailedScenes = failedScenes,
TotalTokens = totalTokens, TotalTokens = totalTokens,
TotalDurationMs = elapsedMs, TotalDurationMs = elapsedMs,
CurrentSceneID = currentSceneId,
CurrentSceneNumber = currentSceneNumber, CurrentSceneNumber = currentSceneNumber,
CurrentSceneDisplayTitle = currentSceneDisplayTitle,
ElapsedTime = FormatDuration(elapsedMs), ElapsedTime = FormatDuration(elapsedMs),
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes ?? 0, totalDetectedScenes ?? 0), EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes ?? 0, totalDetectedScenes ?? 0),
LatestSceneSummary = latestSceneSummary, LatestSceneSummary = latestSceneSummary,
@ -991,6 +1007,7 @@ public sealed class PersistedStoryIntelligenceRunner(
var end = source.SourceEndParagraph ?? source.WordEndParagraphIndex ?? 0; var end = source.SourceEndParagraph ?? source.WordEndParagraphIndex ?? 0;
var sourceLabel = $"{SourceLabel(run)}, scene {sceneNumber:N0}"; var sourceLabel = $"{SourceLabel(run)}, scene {sceneNumber:N0}";
var sceneText = (source.SourceText ?? string.Empty).Trim(); var sceneText = (source.SourceText ?? string.Empty).Trim();
var canonicalSummary = FirstNonBlank(scene.StructuralSummary, scene.Summary, source.StructuralSummary);
var splitValid = scene.SceneID > 0 var splitValid = scene.SceneID > 0
&& !string.IsNullOrWhiteSpace(sceneText) && !string.IsNullOrWhiteSpace(sceneText)
&& (!source.SourceStartParagraph.HasValue || !source.SourceEndParagraph.HasValue || source.SourceStartParagraph <= source.SourceEndParagraph) && (!source.SourceStartParagraph.HasValue || !source.SourceEndParagraph.HasValue || source.SourceStartParagraph <= source.SourceEndParagraph)
@ -1008,11 +1025,13 @@ public sealed class PersistedStoryIntelligenceRunner(
run, run,
scene.SceneID, scene.SceneID,
scene.SceneNumber, scene.SceneNumber,
FirstNonBlank(source.StructuralSummary, scene.StructuralSummary), canonicalSummary,
sourceLabel, sourceLabel,
configuredMetrics, configuredMetrics,
existingPlotLines) existingPlotLines)
: string.Empty, : string.Empty,
SceneTitleDisplay.Format(scene.SceneNumber, scene.SceneTitle),
canonicalSummary,
splitValid); splitValid);
} }
@ -1030,7 +1049,7 @@ public sealed class PersistedStoryIntelligenceRunner(
if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count) if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count)
{ {
return new StorySceneTextBlock(null, sceneNumber, start, end, sourceLabel, string.Empty, string.Empty, false); return new StorySceneTextBlock(null, sceneNumber, start, end, sourceLabel, string.Empty, string.Empty, null, boundary.StructuralSummary, false);
} }
var sceneText = string.Join(Environment.NewLine + Environment.NewLine, paragraphs.Skip(start - 1).Take(end - start + 1)); var sceneText = string.Join(Environment.NewLine + Environment.NewLine, paragraphs.Skip(start - 1).Take(end - start + 1));
@ -1049,6 +1068,8 @@ public sealed class PersistedStoryIntelligenceRunner(
sourceLabel, sourceLabel,
configuredMetrics, configuredMetrics,
existingPlotLines), existingPlotLines),
SceneTitleDisplay.Format(sceneNumber, null),
boundary.StructuralSummary,
true); true);
} }
@ -1363,6 +1384,8 @@ public sealed class PersistedStoryIntelligenceRunner(
string SourceLabel, string SourceLabel,
string SceneText, string SceneText,
string SceneContextJson, string SceneContextJson,
string? DisplayTitle,
string? CanonicalSummary,
bool SplitValid); bool SplitValid);
private sealed record SceneAnalysisModeSelection( private sealed record SceneAnalysisModeSelection(

View File

@ -642,6 +642,9 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel
public decimal? EstimatedCostGBP { get; init; } public decimal? EstimatedCostGBP { get; init; }
public string? FailureStage { get; init; } public string? FailureStage { get; init; }
public string? ErrorMessage { get; init; } public string? ErrorMessage { get; init; }
public int? CurrentSceneID { get; init; }
public int? CurrentSceneNumber { get; init; }
public string? CurrentSceneDisplayTitle { get; init; }
public string? LatestSceneSummary { get; init; } public string? LatestSceneSummary { get; init; }
public bool HasCompletedCommit { get; init; } public bool HasCompletedCommit { get; init; }
public bool CanCommit { get; init; } public bool CanCommit { get; init; }

View File

@ -17,7 +17,7 @@
<div class="onboarding-copy story-live-hero"> <div class="onboarding-copy story-live-hero">
<p class="eyebrow">Analyse manuscript</p> <p class="eyebrow">Analyse manuscript</p>
<h1 id="story-progress-title">Reading your manuscript</h1> <h1 id="story-progress-title">Reading your manuscript</h1>
<p>PlotDirector is enriching the imported scenes with summaries, context, relationships, knowledge, timeline details and metrics.</p> <p>PlotDirector is enriching the imported scenes with context, relationships, knowledge, timeline details and metrics.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -91,13 +91,15 @@
<h2>Latest from your manuscript</h2> <h2>Latest from your manuscript</h2>
@if (!string.IsNullOrWhiteSpace(currentChapter?.LatestSceneSummary)) @if (!string.IsNullOrWhiteSpace(currentChapter?.LatestSceneSummary))
{ {
<h3 class="story-latest-scene-title" data-story-latest-scene-title hidden="@(string.IsNullOrWhiteSpace(currentChapter.CurrentSceneDisplayTitle))">@(currentChapter.CurrentSceneDisplayTitle ?? string.Empty)</h3>
<p class="lead story-latest-summary" data-story-latest-summary>@currentChapter.LatestSceneSummary</p> <p class="lead story-latest-summary" data-story-latest-summary>@currentChapter.LatestSceneSummary</p>
<p class="text-muted" data-story-latest-empty hidden>Scene summaries will appear here as PlotDirector reads.</p> <p class="text-muted" data-story-latest-empty hidden>Scene context will appear here as PlotDirector analyses each imported scene.</p>
} }
else else
{ {
<h3 class="story-latest-scene-title" data-story-latest-scene-title hidden></h3>
<p class="lead story-latest-summary" data-story-latest-summary hidden></p> <p class="lead story-latest-summary" data-story-latest-summary hidden></p>
<p class="text-muted" data-story-latest-empty>Scene summaries will appear here as PlotDirector reads.</p> <p class="text-muted" data-story-latest-empty>Scene context will appear here as PlotDirector analyses each imported scene.</p>
} }
</div> </div>
<div class="story-live-discoveries"> <div class="story-live-discoveries">
@ -115,7 +117,9 @@
data-story-raw-status="@chapter.Status" data-story-raw-status="@chapter.Status"
data-story-chapter-number="@chapter.ChapterNumber" data-story-chapter-number="@chapter.ChapterNumber"
data-story-current-stage="@(chapter.CurrentStage ?? string.Empty)" data-story-current-stage="@(chapter.CurrentStage ?? string.Empty)"
data-story-current-scene-number="@(chapter.CompletedScenes ?? 0)" data-story-current-scene-id="@(chapter.CurrentSceneID?.ToString() ?? string.Empty)"
data-story-current-scene-number="@(chapter.CurrentSceneNumber?.ToString() ?? string.Empty)"
data-story-current-scene-title="@(chapter.CurrentSceneDisplayTitle ?? string.Empty)"
data-story-updated-utc="@(chapter.UpdatedUtc?.ToUniversalTime().ToString("O") ?? string.Empty)" 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)">
@ -125,6 +129,7 @@
<span data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</span> <span data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-message>@FriendlyMessage(chapter)</span> <span data-story-run-message>@FriendlyMessage(chapter)</span>
<span data-story-run-error>@(chapter.ErrorMessage ?? string.Empty)</span> <span data-story-run-error>@(chapter.ErrorMessage ?? string.Empty)</span>
<span data-story-run-current-scene-title>@(chapter.CurrentSceneDisplayTitle ?? string.Empty)</span>
<span data-story-run-latest-summary>@(chapter.LatestSceneSummary ?? string.Empty)</span> <span data-story-run-latest-summary>@(chapter.LatestSceneSummary ?? string.Empty)</span>
</div> </div>
} }
@ -334,8 +339,8 @@
private static string CurrentScene(StoryIntelligenceProgressViewModel model) private static string CurrentScene(StoryIntelligenceProgressViewModel model)
{ {
var chapter = CurrentChapter(model); var chapter = CurrentChapter(model);
return chapter?.CompletedScenes is > 0 return chapter?.CurrentSceneNumber is > 0
? chapter.CompletedScenes.Value.ToString("N0") ? chapter.CurrentSceneNumber.Value.ToString("N0")
: "Waiting"; : "Waiting";
} }
@ -346,8 +351,8 @@
return "Loading scenes..."; return "Loading scenes...";
} }
var completed = Math.Clamp(chapter?.CompletedScenes ?? 0, 0, chapter?.TotalDetectedScenes ?? 0); var current = Math.Clamp(chapter?.CurrentSceneNumber ?? chapter?.CompletedScenes ?? 0, 0, chapter?.TotalDetectedScenes ?? 0);
return $"Scene {completed:N0} of {chapter?.TotalDetectedScenes:N0}"; return $"Scene {current:N0} of {chapter?.TotalDetectedScenes:N0}";
} }
private static int ChapterProgressPercent(StoryIntelligenceOnboardingChapterViewModel? chapter) private static int ChapterProgressPercent(StoryIntelligenceOnboardingChapterViewModel? chapter)

View File

@ -804,6 +804,10 @@
padding: clamp(1.25rem, 2vw, 1.75rem); padding: clamp(1.25rem, 2vw, 1.75rem);
} }
.story-live-progress > * {
min-width: 0;
}
.story-live-hero h1 { .story-live-hero h1 {
white-space: nowrap; white-space: nowrap;
font-size: clamp(2.6rem, 4vw, 3.6rem); font-size: clamp(2.6rem, 4vw, 3.6rem);
@ -824,7 +828,8 @@
} }
.story-live-progress > .alert-info { .story-live-progress > .alert-info {
white-space: nowrap; white-space: normal;
overflow-wrap: break-word;
} }
.story-live-progress-card, .story-live-progress-card,
@ -979,6 +984,13 @@ summary.story-review-chapter-heading {
margin-bottom: .55rem; margin-bottom: .55rem;
} }
.story-latest-scene-title {
margin: 0 0 .45rem;
color: #2f6f63;
font-size: 1rem;
font-weight: 800;
}
.story-latest-summary[data-story-latest-summary] { .story-latest-summary[data-story-latest-summary] {
padding: 1.1rem 1.15rem; padding: 1.1rem 1.15rem;
border-left-width: 6px; border-left-width: 6px;

View File

@ -284,9 +284,11 @@
const status = chapter.dataset.storyRawStatus || "Pending"; const status = chapter.dataset.storyRawStatus || "Pending";
const stage = chapter.dataset.storyCurrentStage || status; const stage = chapter.dataset.storyCurrentStage || status;
const currentSceneNumber = chapter.dataset.storyCurrentSceneNumber || ""; const currentSceneNumber = chapter.dataset.storyCurrentSceneNumber || "";
const currentSceneTitle = chapter.dataset.storyCurrentSceneTitle || "";
const total = chapter.querySelector("[data-story-run-total]")?.textContent || ""; const total = chapter.querySelector("[data-story-run-total]")?.textContent || "";
const completed = chapter.querySelector("[data-story-run-completed]")?.textContent || ""; const completed = chapter.querySelector("[data-story-run-completed]")?.textContent || "";
const message = chapter.querySelector("[data-story-run-message]")?.textContent || ""; const message = chapter.querySelector("[data-story-run-message]")?.textContent || "";
const latestSummary = chapter.querySelector("[data-story-run-latest-summary]")?.textContent || "";
root.querySelectorAll("[data-story-current-chapter]").forEach((node) => { root.querySelectorAll("[data-story-current-chapter]").forEach((node) => {
node.textContent = chapter.dataset.storyChapterTitle || "Current chapter"; node.textContent = chapter.dataset.storyChapterTitle || "Current chapter";
@ -297,6 +299,19 @@
root.querySelectorAll("[data-story-current-scene]").forEach((node) => { root.querySelectorAll("[data-story-current-scene]").forEach((node) => {
node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting"; node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting";
}); });
root.querySelectorAll("[data-story-latest-scene-title]").forEach((node) => {
node.textContent = currentSceneTitle;
node.hidden = !currentSceneTitle;
});
root.querySelectorAll("[data-story-latest-summary]").forEach((node) => {
if (latestSummary) {
node.textContent = latestSummary;
node.hidden = false;
}
});
root.querySelectorAll("[data-story-latest-empty]").forEach((node) => {
node.hidden = !!latestSummary;
});
const totalNumber = Number.parseInt(total.replace(/,/g, "") || "0", 10) || 0; const totalNumber = Number.parseInt(total.replace(/,/g, "") || "0", 10) || 0;
const completedNumber = Number.parseInt(completed.replace(/,/g, "") || "0", 10) || 0; const completedNumber = Number.parseInt(completed.replace(/,/g, "") || "0", 10) || 0;
const currentSceneDisplay = currentSceneNumber ? Number.parseInt(currentSceneNumber, 10) || completedNumber : completedNumber; const currentSceneDisplay = currentSceneNumber ? Number.parseInt(currentSceneNumber, 10) || completedNumber : completedNumber;
@ -402,7 +417,9 @@
const tokens = field(state, "totalTokens", "TotalTokens"); const tokens = field(state, "totalTokens", "TotalTokens");
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 currentSceneId = field(state, "currentSceneID", "CurrentSceneID") ?? field(state, "currentSceneId", "CurrentSceneId");
const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber"); const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber");
const currentSceneDisplayTitle = field(state, "currentSceneDisplayTitle", "CurrentSceneDisplayTitle");
const updatedUtc = field(state, "updatedUtc", "UpdatedUtc"); 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");
@ -414,7 +431,17 @@
}); });
chapter.dataset.storyRawStatus = status; chapter.dataset.storyRawStatus = status;
chapter.dataset.storyCurrentStage = stage; chapter.dataset.storyCurrentStage = stage;
chapter.dataset.storyCurrentSceneNumber = completed || "0"; if (currentSceneId !== null) {
chapter.dataset.storyCurrentSceneId = String(currentSceneId);
}
if (currentSceneNumber !== null) {
chapter.dataset.storyCurrentSceneNumber = String(currentSceneNumber);
} else if (completed !== null) {
chapter.dataset.storyCurrentSceneNumber = String(completed);
}
if (currentSceneDisplayTitle) {
chapter.dataset.storyCurrentSceneTitle = currentSceneDisplayTitle;
}
if (updatedUtc) { if (updatedUtc) {
chapter.dataset.storyUpdatedUtc = updatedUtc; chapter.dataset.storyUpdatedUtc = updatedUtc;
} }
@ -465,6 +492,11 @@
node.textContent = latestSummary; node.textContent = latestSummary;
} }
}); });
chapter.querySelectorAll("[data-story-run-current-scene-title]").forEach((node) => {
if (currentSceneDisplayTitle) {
node.textContent = currentSceneDisplayTitle;
}
});
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;
@ -479,6 +511,11 @@
if (summaryNode && latestSummary) { if (summaryNode && latestSummary) {
summaryNode.textContent = latestSummary; summaryNode.textContent = latestSummary;
summaryNode.hidden = false; summaryNode.hidden = false;
const titleNode = root.querySelector("[data-story-latest-scene-title]");
if (titleNode) {
titleNode.textContent = currentSceneDisplayTitle || chapter.dataset.storyCurrentSceneTitle || "";
titleNode.hidden = !titleNode.textContent;
}
const emptyNode = root.querySelector("[data-story-latest-empty]"); const emptyNode = root.querySelector("[data-story-latest-empty]");
if (emptyNode) { if (emptyNode) {
emptyNode.hidden = true; emptyNode.hidden = true;