Phase 20AG – Fix Progress Card Wrapping and Add Chapter Scene Progress
This commit is contained in:
parent
766ad28325
commit
2097122898
@ -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.");
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
<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>
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user