Phase 20AI – Story Intelligence Review and Scene Creation

This commit is contained in:
Nick Beckley 2026-07-07 19:51:19 +01:00
parent 37e01f4b5d
commit 058b3d10dc
6 changed files with 280 additions and 39 deletions

View File

@ -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<IActionResult> 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<IActionResult> StoryIntelligenceComplete(Guid batchId)
{

View File

@ -13,6 +13,7 @@ public interface IOnboardingStoryIntelligenceService
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> 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<StoryIntelligenceCompletionViewModel?> 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<StoryIntelligenceOnboardingScenePreviewViewModel> BuildScenePreviews(IReadOnlyList<StoryIntelligenceSavedSceneResult> 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<SceneIntelligenceScene>(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<SceneIntelligenceScene>(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

View File

@ -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<string> Blockers { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = [];
public IReadOnlyList<StoryIntelligenceOnboardingScenePreviewViewModel> 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; }

View File

@ -36,7 +36,11 @@
<div class="onboarding-actions">
<a class="btn btn-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">View scenes</a>
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">View book</a>
<a class="btn btn-outline-primary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">View book</a>
@if (Model.FirstChapterID.HasValue)
{
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Chapter" asp-route-id="@Model.FirstChapterID">Open first chapter</a>
}
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Index">Back to Dashboard</a>
</div>
</div>

View File

@ -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));
}
<section class="onboarding-shell" aria-labelledby="story-review-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 8 })" />
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 9 })" />
<div class="onboarding-copy">
<p class="eyebrow">Step 8 of 9</p>
<h1 id="story-review-title">Review analysis</h1>
<p>PlotDirector has finished reading your manuscript. Review the chapter results, then create scenes when you are ready.</p>
<p class="eyebrow">Step 9 of 9</p>
<h1 id="story-review-title">Review Story Intelligence Results</h1>
<p>PlotDirector has finished reading your manuscript. Nothing has been added to your project yet. Please review the detected scenes before creating them.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -30,39 +30,48 @@
}
<section class="alert alert-info">
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.
</section>
<section class="story-review-summary" aria-label="Analysis summary">
<div>
<span>Chapters analysed</span>
<strong>@Model.CompletedChapterCount of @Model.ChapterCount</strong>
<span>Story Intelligence complete</span>
<strong>@Model.CompletedChapterCount.ToString("N0") chapter@(Model.CompletedChapterCount == 1 ? string.Empty : "s")</strong>
</div>
<div>
<span>Scenes found</span>
<span>Scenes detected</span>
<strong>@Model.TotalDetectedScenes.ToString("N0")</strong>
</div>
<div>
<span>Ready to create</span>
<strong>@readyToCreate.ToString("N0")</strong>
<span>Words analysed</span>
<strong>@Model.TotalAnalysedWords.ToString("N0")</strong>
</div>
<div>
<span>Needs review</span>
<strong>@chaptersNeedingReview.ToString("N0")</strong>
<span>Elapsed time</span>
<strong>@FormatDuration(Model.TotalDurationMs)</strong>
</div>
<div>
<span>Estimated AI cost</span>
<strong>@FormatCost(Model.TotalEstimatedCostUSD)</strong>
</div>
<div>
<span>Warnings requiring review</span>
<strong>@warningsCount.ToString("N0")</strong>
</div>
</section>
<section class="story-review-chapter-list" aria-label="Chapter review">
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
{
<article class="story-review-chapter-card">
<div class="story-review-chapter-heading">
<details class="story-review-chapter-card">
<summary class="story-review-chapter-heading">
<div>
<p class="eyebrow">Chapter @chapter.ChapterNumber</p>
<h2>@chapter.ChapterTitle</h2>
<p>@((chapter.TotalDetectedScenes ?? 0).ToString("N0")) scene@(chapter.TotalDetectedScenes == 1 ? string.Empty : "s") detected / confidence @FormatConfidence(chapter.AverageConfidence)</p>
</div>
<strong class="@StatusClass(chapter)">@FriendlyStatus(chapter)</strong>
</div>
</summary>
<div class="story-review-scene-panel">
<div>
@ -77,6 +86,10 @@
<span>Needs review</span>
<strong>@(((chapter.FailedScenes ?? 0) + chapter.Blockers.Count).ToString("N0"))</strong>
</div>
<div>
<span>Confidence</span>
<strong>@FormatConfidence(chapter.AverageConfidence)</strong>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(chapter.CommitMessage))
@ -115,26 +128,31 @@
<p class="story-review-note story-review-note--warning">@chapter.ErrorMessage</p>
}
<div class="story-review-card-actions">
@if (chapter.HasCompletedCommit)
<div class="story-scene-preview-list">
@if (chapter.Scenes.Count == 0)
{
<button class="btn btn-outline-secondary" type="button" disabled>@chapter.ScenesCreated.ToString("N0") scene(s) created</button>
}
else if (chapter.CanCommit && Model.BatchID.HasValue)
{
<form asp-action="CommitStoryIntelligence" method="post">
<input type="hidden" name="batchId" value="@Model.BatchID" />
<input type="hidden" name="runId" value="@chapter.RunID" />
<button class="btn btn-primary" type="submit">Create scenes</button>
</form>
}
else if (chapter.IsActive && Model.BatchID.HasValue)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.BatchID">View progress</a>
<p class="text-muted mb-0">No readable scene previews are available for this chapter.</p>
}
else
{
<button class="btn btn-outline-secondary" type="button" disabled>Needs attention</button>
@foreach (var scene in chapter.Scenes)
{
<article class="story-scene-preview">
<div>
<strong>Scene @scene.SceneNumber.ToString("N0")</strong>
@if (scene.HasWarnings)
{
<span class="story-review-status story-review-status--active">Review recommended</span>
}
</div>
<p>@scene.Summary</p>
<dl>
<div><dt>POV</dt><dd>@Display(scene.Pov)</dd></div>
<div><dt>Setting</dt><dd>@Display(scene.Setting)</dd></div>
<div><dt>Confidence</dt><dd>@FormatConfidence(scene.Confidence)</dd></div>
</dl>
</article>
}
}
</div>
@ -151,18 +169,30 @@
</dl>
</details>
}
</article>
</details>
}
</section>
<section class="alert alert-info">
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.
</section>
<div class="onboarding-actions">
@if (Model.BatchID.HasValue)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.BatchID">View progress</a>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.BatchID">Back</a>
@if (Model.HasReadyScenesToCreate)
{
<form asp-action="CommitAllStoryIntelligence" method="post">
<input type="hidden" name="batchId" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit">Create PlotDirector Scenes</button>
</form>
}
else if (Model.AllCommitted)
{
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
}
}
<form asp-action="ScanManuscript" method="post">
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
</form>
</div>
</div>
</section>
@ -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";
}
}

View File

@ -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 {