From 058b3d10dc7df143b72277b0f60ac94077ec1581 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 7 Jul 2026 19:51:19 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020AI=20=E2=80=93=20Story=20Intelligence?= =?UTF-8?q?=20Review=20and=20Scene=20Creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PlotLine/Controllers/OnboardingController.cs | 16 +++ .../OnboardingStoryIntelligenceService.cs | 100 +++++++++++++- PlotLine/ViewModels/OnboardingViewModels.cs | 19 +++ .../StoryIntelligenceComplete.cshtml | 6 +- .../Onboarding/StoryIntelligenceReview.cshtml | 125 ++++++++++++------ PlotLine/wwwroot/css/onboarding.css | 53 ++++++++ 6 files changed, 280 insertions(+), 39 deletions(-) diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index ba02832..aab02d2 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -132,6 +132,22 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); } + [HttpPost("story-intelligence/commit-all")] + [ValidateAntiForgeryToken] + public async Task CommitAllStoryIntelligence(Guid batchId) + { + var (progress, result) = await storyIntelligence.CommitAllAsync(batchId); + if (progress is null) + { + return NotFound(); + } + + TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; + return result.Success && progress.AllCommitted + ? RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId }) + : RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + } + [HttpGet("story-intelligence/complete")] public async Task StoryIntelligenceComplete(Guid batchId) { diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index d1a9535..1c10537 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -13,6 +13,7 @@ public interface IOnboardingStoryIntelligenceService Task GetProgressAsync(Guid batchId); Task CancelAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId); + Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId); Task GetCompletionAsync(Guid batchId); } @@ -235,6 +236,8 @@ public sealed class OnboardingStoryIntelligenceService( SourceWordCount = run.SourceWordCount, TotalTokens = run.TotalTokens, TotalDurationMs = run.TotalDurationMs, + EstimatedCostUSD = run.EstimatedCostUSD, + EstimatedCostGBP = run.EstimatedCostGBP, FailureStage = run.FailureStage, ErrorMessage = run.ErrorMessage, LatestSceneSummary = LatestSceneSummary(sceneResults), @@ -243,7 +246,8 @@ public sealed class OnboardingStoryIntelligenceService( ScenesCreated = commit?.ScenesCreated ?? 0, CommitMessage = commit?.Notes, Blockers = confirmation?.Blockers ?? [], - Warnings = confirmation?.Warnings ?? [] + Warnings = confirmation?.Warnings ?? [], + Scenes = BuildScenePreviews(sceneResults) }); } @@ -287,6 +291,41 @@ public sealed class OnboardingStoryIntelligenceService( return (await GetProgressAsync(batchId), result); } + public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId) + { + var progress = await GetProgressAsync(batchId); + if (progress is null) + { + return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); + } + + var userId = RequireUserId(); + var created = 0; + foreach (var chapter in progress.Chapters.OrderBy(chapter => chapter.ChapterNumber).Where(chapter => chapter.CanCommit)) + { + var result = await commits.CommitAsync(chapter.RunID, userId); + if (!result.Success) + { + return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult + { + Success = false, + Message = $"Scene creation stopped. Chapter {chapter.ChapterNumber:N0} could not be imported. Reason: {result.Message}", + ScenesCreated = created + }); + } + + created += result.ScenesCreated; + } + + await onboarding.MarkCompletedAsync(); + return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult + { + Success = true, + ScenesCreated = created, + Message = $"Scenes successfully created. {created:N0} scene(s) added to PlotDirector." + }); + } + public async Task GetCompletionAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); @@ -300,6 +339,7 @@ public sealed class OnboardingStoryIntelligenceService( BatchID = progress.BatchID, ProjectID = progress.ProjectID, BookID = progress.BookID, + FirstChapterID = progress.Chapters.OrderBy(chapter => chapter.ChapterNumber).FirstOrDefault()?.ChapterID, ProjectName = progress.ProjectName, BookTitle = progress.BookTitle, ChaptersCommitted = progress.CommittedChapterCount, @@ -366,6 +406,64 @@ public sealed class OnboardingStoryIntelligenceService( return null; } + private static IReadOnlyList BuildScenePreviews(IReadOnlyList sceneResults) + => sceneResults + .OrderBy(scene => scene.TemporarySceneNumber) + .Select(scene => new { Result = scene, Parsed = TryReadScene(scene.ParsedJson) }) + .Where(scene => scene.Parsed is not null) + .Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel + { + SceneNumber = scene.Result.TemporarySceneNumber, + Summary = Trim(FirstConfigured(scene.Parsed?.Summary?.Short, scene.Parsed?.Summary?.Detailed, $"Scene {scene.Result.TemporarySceneNumber:N0}")!, 180), + Pov = FirstConfigured(scene.Parsed?.PointOfView?.CharacterName, scene.Parsed?.PointOfView?.NarrativeMode), + Setting = BuildSetting(scene.Parsed?.Setting), + Confidence = scene.Parsed?.Summary?.Confidence ?? scene.Parsed?.Setting?.Confidence, + HasWarnings = scene.Result.ValidationWarningsCount > 0 + }) + .ToList(); + + private static SceneIntelligenceScene? TryReadScene(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + var direct = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion)) + { + return direct; + } + + using var document = JsonDocument.Parse(json); + if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene)) + { + return parsedScene.Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + } + catch (JsonException) + { + return null; + } + + return null; + } + + private static string? BuildSetting(SceneIntelligenceSetting? setting) + => setting is null + ? null + : FirstConfigured( + setting.LocationName, + setting.GenericRoomType, + setting.LocationType, + setting.DateOrTimeReference, + setting.TimeOfDay); + + private static string? FirstConfigured(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim(); + private static string? ExtractShortSummary(string json) { try diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 7e77083..0b15515 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -144,8 +144,12 @@ public sealed class StoryIntelligenceProgressViewModel public int TotalDetectedScenes => Chapters.Sum(chapter => chapter.TotalDetectedScenes ?? 0); public int TotalCompletedScenes => Chapters.Sum(chapter => chapter.CompletedScenes ?? 0); public int TotalFailedScenes => Chapters.Sum(chapter => chapter.FailedScenes ?? 0); + public int TotalAnalysedWords => Chapters.Sum(chapter => chapter.SourceWordCount ?? 0); + public decimal? TotalEstimatedCostUSD => Chapters.Any(chapter => chapter.EstimatedCostUSD.HasValue) ? Chapters.Sum(chapter => chapter.EstimatedCostUSD ?? 0m) : null; + public long TotalDurationMs => Chapters.Sum(chapter => chapter.TotalDurationMs ?? 0); public bool HasActiveRuns => Chapters.Any(chapter => chapter.IsActive); public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit); + public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit); } public sealed class StoryIntelligenceCompletionViewModel @@ -154,6 +158,7 @@ public sealed class StoryIntelligenceCompletionViewModel public Guid? BatchID { get; init; } public int ProjectID { get; init; } public int BookID { get; init; } + public int? FirstChapterID { get; init; } public string? ProjectName { get; init; } public string? BookTitle { get; init; } public int ChaptersCommitted { get; init; } @@ -176,6 +181,8 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel public int? SourceWordCount { get; init; } public int? TotalTokens { get; init; } public long? TotalDurationMs { get; init; } + public decimal? EstimatedCostUSD { get; init; } + public decimal? EstimatedCostGBP { get; init; } public string? FailureStage { get; init; } public string? ErrorMessage { get; init; } public string? LatestSceneSummary { get; init; } @@ -185,11 +192,23 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel public string? CommitMessage { get; init; } public IReadOnlyList Blockers { get; init; } = []; public IReadOnlyList Warnings { get; init; } = []; + public IReadOnlyList Scenes { get; init; } = []; + public decimal? AverageConfidence => Scenes.Count == 0 ? null : Scenes.Average(scene => scene.Confidence ?? 0m); public bool IsActive => Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running; public bool IsRunComplete => Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings; public bool IsFailed => Status is StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled; } +public sealed class StoryIntelligenceOnboardingScenePreviewViewModel +{ + public int SceneNumber { get; init; } + public string Summary { get; init; } = string.Empty; + public string? Pov { get; init; } + public string? Setting { get; init; } + public decimal? Confidence { get; init; } + public bool HasWarnings { get; init; } +} + public sealed class StoryIntelligenceDashboardViewModel { public bool ShouldShow { get; init; } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml index 7d5560d..f5e5dc8 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml @@ -36,7 +36,11 @@
View scenes - View book + View book + @if (Model.FirstChapterID.HasValue) + { + Open first chapter + } Back to Dashboard
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml index 8c41bed..c7fb131 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml @@ -8,16 +8,16 @@ var isAdmin = !string.IsNullOrWhiteSpace(userEmail) && adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase)); var chaptersNeedingReview = Model.Chapters.Count(NeedsReview); - var readyToCreate = Model.Chapters.Count(chapter => chapter.CanCommit); + var warningsCount = Model.Chapters.Sum(chapter => chapter.Warnings.Count + chapter.Blockers.Count + (chapter.FailedScenes ?? 0)); }
- +
-

Step 8 of 9

-

Review analysis

-

PlotDirector has finished reading your manuscript. Review the chapter results, then create scenes when you are ready.

+

Step 9 of 9

+

Review Story Intelligence Results

+

PlotDirector has finished reading your manuscript. Nothing has been added to your project yet. Please review the detected scenes before creating them.

@if (TempData["OnboardingStoryIntelligenceError"] is string error) @@ -30,39 +30,48 @@ }
- Nothing is added to your project until you choose Create scenes. This step only creates scenes, summaries and supported scene notes. + Only scenes will be created. Characters, locations, assets, relationships and knowledge will be imported during later Story Intelligence phases.
- Chapters analysed - @Model.CompletedChapterCount of @Model.ChapterCount + Story Intelligence complete + @Model.CompletedChapterCount.ToString("N0") chapter@(Model.CompletedChapterCount == 1 ? string.Empty : "s")
- Scenes found + Scenes detected @Model.TotalDetectedScenes.ToString("N0")
- Ready to create - @readyToCreate.ToString("N0") + Words analysed + @Model.TotalAnalysedWords.ToString("N0")
- Needs review - @chaptersNeedingReview.ToString("N0") + Elapsed time + @FormatDuration(Model.TotalDurationMs) +
+
+ Estimated AI cost + @FormatCost(Model.TotalEstimatedCostUSD) +
+
+ Warnings requiring review + @warningsCount.ToString("N0")
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber)) { -
-
+
+

Chapter @chapter.ChapterNumber

@chapter.ChapterTitle

+

@((chapter.TotalDetectedScenes ?? 0).ToString("N0")) scene@(chapter.TotalDetectedScenes == 1 ? string.Empty : "s") detected / confidence @FormatConfidence(chapter.AverageConfidence)

@FriendlyStatus(chapter) -
+
@@ -77,6 +86,10 @@ Needs review @(((chapter.FailedScenes ?? 0) + chapter.Blockers.Count).ToString("N0"))
+
+ Confidence + @FormatConfidence(chapter.AverageConfidence) +
@if (!string.IsNullOrWhiteSpace(chapter.CommitMessage)) @@ -115,26 +128,31 @@

@chapter.ErrorMessage

} -
- @if (chapter.HasCompletedCommit) +
+ @if (chapter.Scenes.Count == 0) { - - } - else if (chapter.CanCommit && Model.BatchID.HasValue) - { -
- - - -
- } - else if (chapter.IsActive && Model.BatchID.HasValue) - { - View progress +

No readable scene previews are available for this chapter.

} else { - + @foreach (var scene in chapter.Scenes) + { +
+
+ Scene @scene.SceneNumber.ToString("N0") + @if (scene.HasWarnings) + { + Review recommended + } +
+

@scene.Summary

+
+
POV
@Display(scene.Pov)
+
Setting
@Display(scene.Setting)
+
Confidence
@FormatConfidence(scene.Confidence)
+
+
+ } }
@@ -151,18 +169,30 @@ } -
+ }
+
+ Scene creation uses the existing PlotDirector import safeguards. Default empty Scene 1 placeholders can be replaced, duplicate imports are blocked, and failures roll back safely. +
+
@if (Model.BatchID.HasValue) { - View progress + Back + @if (Model.HasReadyScenesToCreate) + { +
+ + +
+ } + else if (Model.AllCommitted) + { + Continue + } } -
- -
@@ -214,4 +244,25 @@ ? "story-review-status story-review-status--active" : "story-review-status story-review-status--warning"; } + + private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value; + + private static string FormatConfidence(decimal? confidence) + => confidence.HasValue ? $"{Math.Clamp(confidence.Value, 0m, 1m):P0}" : "Not available"; + + private static string FormatCost(decimal? cost) + => cost.HasValue ? $"About ${cost.Value:0.00}" : "Not available"; + + private static string FormatDuration(long milliseconds) + { + var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)); + if (elapsed.TotalHours >= 1) + { + return $"{Math.Ceiling(elapsed.TotalHours):N0} hr"; + } + + return elapsed.TotalMinutes >= 1 + ? $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min" + : $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec"; + } } diff --git a/PlotLine/wwwroot/css/onboarding.css b/PlotLine/wwwroot/css/onboarding.css index dbbfc7a..8a217d1 100644 --- a/PlotLine/wwwroot/css/onboarding.css +++ b/PlotLine/wwwroot/css/onboarding.css @@ -649,6 +649,15 @@ gap: 1rem; } +summary.story-review-chapter-heading { + cursor: pointer; +} + +.story-review-chapter-heading p:not(.eyebrow) { + margin: .25rem 0 0; + color: var(--bs-secondary-color); +} + .story-live-progress-heading strong { display: block; font-size: clamp(2rem, 3vw, 2.8rem); @@ -856,6 +865,49 @@ gap: .65rem; } +.story-scene-preview-list { + display: grid; + gap: .65rem; +} + +.story-scene-preview { + display: grid; + gap: .45rem; + border: 1px solid rgba(31, 42, 68, .1); + border-radius: 8px; + padding: .75rem; + background: rgba(255, 255, 255, .58); +} + +.story-scene-preview > div:first-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; +} + +.story-scene-preview p { + margin: 0; +} + +.story-scene-preview dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: .5rem; + margin: 0; +} + +.story-scene-preview dt { + color: var(--bs-secondary-color); + font-size: .78rem; + font-weight: 800; + text-transform: uppercase; +} + +.story-scene-preview dd { + margin: .1rem 0 0; +} + .story-review-admin { border-top: 1px solid rgba(31, 42, 68, .1); padding-top: .75rem; @@ -1231,6 +1283,7 @@ .story-live-stats, .story-review-summary, .story-review-scene-panel, + .story-scene-preview dl, .story-live-current, .story-live-feed-grid, .story-review-admin dl {