diff --git a/PlotLine/Hubs/StoryIntelligenceHub.cs b/PlotLine/Hubs/StoryIntelligenceHub.cs index b99d57c..fc43d1e 100644 --- a/PlotLine/Hubs/StoryIntelligenceHub.cs +++ b/PlotLine/Hubs/StoryIntelligenceHub.cs @@ -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 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 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."); diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 31bd58c..d1a9535 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -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 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); } diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 1e59e46..00ecf76 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -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> ParseAiJsonWithRetryAsync( string completedPrompt, string promptVersion, diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index e7c377c..56b8307 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -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; } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml index 261d1d3..f7036a6 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml @@ -46,11 +46,11 @@
Chapters read - @Model.CompletedChapterCount of @Model.ChapterCount + @Model.CompletedChapterCount of @Model.ChapterCount
Scenes read - @Model.TotalCompletedScenes so far + @Model.TotalCompletedScenes so far
Needs review @@ -73,17 +73,31 @@

@(currentChapter?.ChapterTitle ?? "Waiting")

@FriendlyMessage(currentChapter)

-
+

Current scene

@CurrentScene(Model) +
+ @ChapterProgressText(currentChapter) +
+
+
+

Latest from your manuscript

- -

Scene summaries will appear here as PlotDirector reads.

+ @if (!string.IsNullOrWhiteSpace(currentChapter?.LatestSceneSummary)) + { +

@currentChapter.LatestSceneSummary

+ + } + else + { + +

Scene summaries will appear here as PlotDirector reads.

+ }

Recent discoveries

@@ -106,6 +120,7 @@ @((chapter.FailedScenes ?? 0).ToString("N0")) @FriendlyMessage(chapter) @(chapter.ErrorMessage ?? string.Empty) + @(chapter.LatestSceneSummary ?? string.Empty)
}
@@ -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); + } } diff --git a/PlotLine/wwwroot/css/onboarding.css b/PlotLine/wwwroot/css/onboarding.css index c5322ab..3d5c27f 100644 --- a/PlotLine/wwwroot/css/onboarding.css +++ b/PlotLine/wwwroot/css/onboarding.css @@ -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; diff --git a/PlotLine/wwwroot/js/story-intelligence-progress.js b/PlotLine/wwwroot/js/story-intelligence-progress.js index 030f4af..ffcde39 100644 --- a/PlotLine/wwwroot/js/story-intelligence-progress.js +++ b/PlotLine/wwwroot/js/story-intelligence-progress.js @@ -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; });