Phase 20AG – Fix Progress Card Wrapping and Add Chapter Scene Progress

This commit is contained in:
Nick Beckley 2026-07-06 22:00:14 +01:00
parent 766ad28325
commit 2097122898
7 changed files with 269 additions and 17 deletions

View File

@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using PlotLine.Data;
@ -37,9 +38,13 @@ public sealed class StoryIntelligenceHub(
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
var run = await persistedRuns.GetRunAsync(runId);
return run is null || run.UserID != userId
? null
: ToRunProgress(run, "Current status");
if (run is null || run.UserID != userId)
{
return null;
}
var sceneResults = await persistedRuns.ListSceneResultsAsync(runId);
return ToRunProgress(run, "Current status", LatestSceneSummary(sceneResults));
}
public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId)
@ -51,7 +56,7 @@ public sealed class StoryIntelligenceHub(
public static string UserGroup(int userId) => $"story-intelligence:{userId}";
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType)
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, string? latestSceneSummary)
{
var completedScenes = run.CompletedScenes ?? 0;
var totalScenes = run.TotalDetectedScenes ?? 0;
@ -77,6 +82,7 @@ public sealed class StoryIntelligenceHub(
CurrentSceneNumber = run.CompletedScenes,
ElapsedTime = FormatDuration(elapsedMs),
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes),
LatestSceneSummary = latestSceneSummary,
ErrorMessage = run.ErrorMessage,
UpdatedUtc = run.UpdatedUtc,
IsActive = run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running,
@ -106,6 +112,85 @@ public sealed class StoryIntelligenceHub(
return FormatDuration(averageSceneMs * (totalScenes - completedScenes));
}
private static string? LatestSceneSummary(IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults)
{
foreach (var scene in sceneResults
.Where(scene => scene.ValidationErrorsCount == 0 && !string.IsNullOrWhiteSpace(scene.ParsedJson))
.OrderByDescending(scene => scene.TemporarySceneNumber))
{
var summary = ExtractShortSummary(scene.ParsedJson);
if (!string.IsNullOrWhiteSpace(summary))
{
return Trim(summary, 220);
}
}
return null;
}
private static string? ExtractShortSummary(string json)
{
try
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (TryReadShortSummary(root, out var direct))
{
return direct;
}
if (root.TryGetProperty("parsedScene", out var parsedScene)
&& TryReadShortSummary(parsedScene, out var wrapped))
{
return wrapped;
}
}
catch (JsonException)
{
return null;
}
return null;
}
private static bool TryReadShortSummary(JsonElement element, out string? summary)
{
summary = null;
if (element.ValueKind != JsonValueKind.Object
|| !element.TryGetProperty("summary", out var summaryElement)
|| summaryElement.ValueKind != JsonValueKind.Object)
{
return false;
}
if (TryReadSummaryValue(summaryElement, "short", out summary))
{
return !string.IsNullOrWhiteSpace(summary);
}
return TryReadSummaryValue(summaryElement, "detailed", out summary)
&& !string.IsNullOrWhiteSpace(summary);
}
private static bool TryReadSummaryValue(JsonElement summaryElement, string propertyName, out string? value)
{
value = null;
if (summaryElement.TryGetProperty(propertyName, out var textElement)
&& textElement.ValueKind == JsonValueKind.String)
{
value = textElement.GetString();
return !string.IsNullOrWhiteSpace(value);
}
return false;
}
private static string Trim(string value, int maxLength)
{
var trimmed = value.Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength].TrimEnd();
}
private int RequireUserId()
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence.");

View File

@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
@ -216,6 +217,7 @@ public sealed class OnboardingStoryIntelligenceService(
var confirmation = run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
? await commits.BuildConfirmationAsync(item.RunID)
: null;
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
chapters.Add(new StoryIntelligenceOnboardingChapterViewModel
{
@ -235,6 +237,7 @@ public sealed class OnboardingStoryIntelligenceService(
TotalDurationMs = run.TotalDurationMs,
FailureStage = run.FailureStage,
ErrorMessage = run.ErrorMessage,
LatestSceneSummary = LatestSceneSummary(sceneResults),
HasCompletedCommit = commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase),
CanCommit = confirmation?.CanCommit == true,
ScenesCreated = commit?.ScenesCreated ?? 0,
@ -347,6 +350,85 @@ public sealed class OnboardingStoryIntelligenceService(
? 0
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
private static string? LatestSceneSummary(IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults)
{
foreach (var scene in sceneResults
.Where(scene => scene.ValidationErrorsCount == 0 && !string.IsNullOrWhiteSpace(scene.ParsedJson))
.OrderByDescending(scene => scene.TemporarySceneNumber))
{
var summary = ExtractShortSummary(scene.ParsedJson);
if (!string.IsNullOrWhiteSpace(summary))
{
return Trim(summary, 220);
}
}
return null;
}
private static string? ExtractShortSummary(string json)
{
try
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (TryReadShortSummary(root, out var direct))
{
return direct;
}
if (root.TryGetProperty("parsedScene", out var parsedScene)
&& TryReadShortSummary(parsedScene, out var wrapped))
{
return wrapped;
}
}
catch (JsonException)
{
return null;
}
return null;
}
private static bool TryReadShortSummary(JsonElement element, out string? summary)
{
summary = null;
if (element.ValueKind != JsonValueKind.Object
|| !element.TryGetProperty("summary", out var summaryElement)
|| summaryElement.ValueKind != JsonValueKind.Object)
{
return false;
}
if (TryReadSummaryValue(summaryElement, "short", out summary))
{
return !string.IsNullOrWhiteSpace(summary);
}
return TryReadSummaryValue(summaryElement, "detailed", out summary)
&& !string.IsNullOrWhiteSpace(summary);
}
private static bool TryReadSummaryValue(JsonElement summaryElement, string propertyName, out string? value)
{
value = null;
if (summaryElement.TryGetProperty(propertyName, out var textElement)
&& textElement.ValueKind == JsonValueKind.String)
{
value = textElement.GetString();
return !string.IsNullOrWhiteSpace(value);
}
return false;
}
private static string Trim(string value, int maxLength)
{
var trimmed = value.Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength].TrimEnd();
}
private sealed record IncludedChapter(ManuscriptScanChapterReviewDecision Decision, ManuscriptScanChapterPreview Preview);
}

View File

@ -343,7 +343,7 @@ public sealed class PersistedStoryIntelligenceRunner(
failedScenes: failedScenes,
totalTokens: totals.TotalTokens,
currentSceneNumber: block.TemporarySceneNumber,
latestSceneSummary: TrimOptional(sceneAttempt.Parsed?.Summary?.Short, 220),
latestSceneSummary: SceneSummaryText(sceneAttempt.Parsed),
recentDiscoveries: BuildDiscoveries(sceneAttempt.Parsed));
if (!validation.IsValid || validation.Warnings.Count > 0)
{
@ -608,6 +608,11 @@ public sealed class PersistedStoryIntelligenceRunner(
: $"Relationship signal found: {characterA} and {characterB} - {signal}";
}
private static string? SceneSummaryText(SceneIntelligenceScene? scene)
=> TrimOptional(
FirstConfigured(scene?.Summary?.Short, scene?.Summary?.Detailed),
220);
private static string? TrimOptional(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
@ -619,6 +624,9 @@ public sealed class PersistedStoryIntelligenceRunner(
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength].TrimEnd();
}
private static string? FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private async Task<AiJsonParseResult<T>> ParseAiJsonWithRetryAsync<T>(
string completedPrompt,
string promptVersion,

View File

@ -171,6 +171,7 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel
public long? TotalDurationMs { get; init; }
public string? FailureStage { get; init; }
public string? ErrorMessage { get; init; }
public string? LatestSceneSummary { get; init; }
public bool HasCompletedCommit { get; init; }
public bool CanCommit { get; init; }
public int ScenesCreated { get; init; }

View File

@ -46,11 +46,11 @@
<div class="story-live-stats">
<div>
<span>Chapters read</span>
<strong class="story-stat-value"><span data-story-chapters-completed>@Model.CompletedChapterCount</span> of @Model.ChapterCount</strong>
<strong class="story-stat-value" data-story-chapters-read-value>@Model.CompletedChapterCount of @Model.ChapterCount</strong>
</div>
<div>
<span>Scenes read</span>
<strong class="story-stat-value"><span data-story-scenes-completed>@Model.TotalCompletedScenes</span> so far</strong>
<strong class="story-stat-value" data-story-scenes-read-value>@Model.TotalCompletedScenes so far</strong>
</div>
<div>
<span>Needs review</span>
@ -73,17 +73,31 @@
<h2 data-story-current-chapter>@(currentChapter?.ChapterTitle ?? "Waiting")</h2>
<p data-story-current-message>@FriendlyMessage(currentChapter)</p>
</div>
<div>
<div class="story-current-scene-panel">
<p class="eyebrow">Current scene</p>
<strong data-story-current-scene>@CurrentScene(Model)</strong>
<div class="story-chapter-progress">
<span data-story-chapter-progress-text>@ChapterProgressText(currentChapter)</span>
<div class="progress" role="progressbar" aria-label="Current chapter progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="@ChapterProgressPercent(currentChapter)">
<div class="progress-bar" data-story-chapter-progress-bar style="width:@ChapterProgressPercent(currentChapter)%"></div>
</div>
</div>
</div>
</section>
<section class="story-live-feed-grid" aria-label="Live manuscript feed">
<div>
<h2>Latest from your manuscript</h2>
@if (!string.IsNullOrWhiteSpace(currentChapter?.LatestSceneSummary))
{
<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>
}
else
{
<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>
}
</div>
<div class="story-live-discoveries">
<h2>Recent discoveries</h2>
@ -106,6 +120,7 @@
<span data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-message>@FriendlyMessage(chapter)</span>
<span data-story-run-error>@(chapter.ErrorMessage ?? string.Empty)</span>
<span data-story-run-latest-summary>@(chapter.LatestSceneSummary ?? string.Empty)</span>
</div>
}
</div>
@ -314,4 +329,27 @@
? chapter.CompletedScenes.Value.ToString("N0")
: "Waiting";
}
private static string ChapterProgressText(StoryIntelligenceOnboardingChapterViewModel? chapter)
{
if ((chapter?.TotalDetectedScenes ?? 0) <= 0)
{
return "Finding scenes...";
}
var completed = Math.Clamp(chapter?.CompletedScenes ?? 0, 0, chapter?.TotalDetectedScenes ?? 0);
return $"Scene {completed:N0} of {chapter?.TotalDetectedScenes:N0}";
}
private static int ChapterProgressPercent(StoryIntelligenceOnboardingChapterViewModel? chapter)
{
var total = chapter?.TotalDetectedScenes ?? 0;
if (total <= 0)
{
return 0;
}
var completed = Math.Clamp(chapter?.CompletedScenes ?? 0, 0, total);
return Math.Clamp((int)Math.Floor(completed * 100m / total), 0, 100);
}
}

View File

@ -711,9 +711,7 @@
}
.story-stat-value {
display: inline-flex;
align-items: baseline;
gap: .25rem;
display: inline-block;
max-width: 100%;
white-space: nowrap;
}
@ -734,6 +732,29 @@
font-size: 1.25rem;
}
.story-current-scene-panel {
display: grid;
align-content: start;
gap: .35rem;
}
.story-chapter-progress {
display: grid;
gap: .35rem;
margin-top: .2rem;
}
.story-chapter-progress span {
color: var(--bs-secondary-color);
font-size: .9rem;
font-weight: 700;
white-space: nowrap;
}
.story-chapter-progress .progress {
height: .45rem;
}
.story-live-feed-grid {
grid-template-columns: 1fr;
align-items: start;

View File

@ -285,11 +285,11 @@
root.querySelectorAll("[data-story-overall-progress-bar]").forEach((node) => {
node.style.width = `${Math.max(0, Math.min(100, percent))}%`;
});
root.querySelectorAll("[data-story-chapters-completed]").forEach((node) => {
node.textContent = numberText(totals.completedChapters);
root.querySelectorAll("[data-story-chapters-read-value]").forEach((node) => {
node.textContent = `${numberText(totals.completedChapters)} of ${numberText(chapters.length)}`;
});
root.querySelectorAll("[data-story-scenes-completed]").forEach((node) => {
node.textContent = numberText(totals.completedScenes);
root.querySelectorAll("[data-story-scenes-read-value]").forEach((node) => {
node.textContent = `${numberText(totals.completedScenes)} so far`;
});
root.querySelectorAll("[data-story-scenes-total]").forEach((node) => {
node.textContent = numberText(totals.totalScenes);
@ -386,6 +386,11 @@
node.textContent = error || "";
node.hidden = !error;
});
chapter.querySelectorAll("[data-story-run-latest-summary]").forEach((node) => {
if (latestSummary) {
node.textContent = latestSummary;
}
});
root.querySelectorAll("[data-story-current-chapter]").forEach((node) => {
node.textContent = chapter.dataset.storyChapterTitle || "Current chapter";
@ -396,6 +401,18 @@
root.querySelectorAll("[data-story-current-scene]").forEach((node) => {
node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting";
});
const totalNumber = Number.parseInt(total || "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) => {
const percent = totalNumber > 0 ? Math.floor((Math.min(completedNumber, totalNumber) * 100) / totalNumber) : 0;
node.style.width = `${Math.max(0, Math.min(100, percent))}%`;
});
root.querySelectorAll("[data-story-current-message]").forEach((node) => {
node.textContent = displayMessage;
});