Phase 20AE – Unify Story Intelligence Review Pipeline & Remove Placeholder Scene Architecture

This commit is contained in:
Nick Beckley 2026-07-07 21:09:59 +01:00
parent 5b4c78c040
commit fba73186c1
9 changed files with 107 additions and 60 deletions

View File

@ -433,7 +433,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
return new StoryIntelligenceImportCommitResult return new StoryIntelligenceImportCommitResult
{ {
Success = false, Success = false,
Message = readiness.BlockReason ?? "This chapter contains existing authored scenes." Message = readiness.BlockReason ?? "This chapter already contains scenes."
}; };
} }
@ -442,9 +442,6 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
{ {
var item = orderedScenes[index]; var item = orderedScenes[index];
var operation = "preparing scene"; var operation = "preparing scene";
var replacePlaceholder = readiness.HasUntouchedDefaultPlaceholder
&& readiness.PlaceholderSceneID.HasValue
&& index == 0;
try try
{ {
@ -462,7 +459,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
"dbo.Scene_Save", "dbo.Scene_Save",
new new
{ {
SceneID = replacePlaceholder ? readiness.PlaceholderSceneID : null, SceneID = (int?)null,
ChapterID = request.ChapterID, ChapterID = request.ChapterID,
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber), SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
SceneTitle = sceneTitle, SceneTitle = sceneTitle,
@ -587,9 +584,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
} }
var notes = new List<string>(); var notes = new List<string>();
notes.Add(readiness.HasUntouchedDefaultPlaceholder notes.Add("Committed into empty chapter.");
? "Replaced untouched default placeholder scene."
: "Committed into empty chapter.");
notes.AddRange(request.Warnings.Where(warning => !string.IsNullOrWhiteSpace(warning))); notes.AddRange(request.Warnings.Where(warning => !string.IsNullOrWhiteSpace(warning)));
notes.AddRange(commitWarnings.Where(warning => !string.IsNullOrWhiteSpace(warning))); notes.AddRange(commitWarnings.Where(warning => !string.IsNullOrWhiteSpace(warning)));

View File

@ -417,8 +417,6 @@ public sealed class StoryIntelligenceChapterImportReadiness
{ {
public int ActiveSceneCount { get; init; } public int ActiveSceneCount { get; init; }
public bool CanCommit { get; init; } public bool CanCommit { get; init; }
public bool HasUntouchedDefaultPlaceholder { get; init; }
public int? PlaceholderSceneID { get; init; }
public string CommitMode { get; init; } = string.Empty; public string CommitMode { get; init; } = string.Empty;
public string? BlockReason { get; init; } public string? BlockReason { get; init; }
} }

View File

@ -247,7 +247,9 @@ public sealed class OnboardingStoryIntelligenceService(
CommitMessage = commit?.Notes, CommitMessage = commit?.Notes,
Blockers = confirmation?.Blockers ?? [], Blockers = confirmation?.Blockers ?? [],
Warnings = confirmation?.Warnings ?? [], Warnings = confirmation?.Warnings ?? [],
Scenes = BuildScenePreviews(sceneResults) Scenes = confirmation is null
? []
: BuildScenePreviews(confirmation.Scenes)
}); });
} }
@ -406,19 +408,17 @@ public sealed class OnboardingStoryIntelligenceService(
return null; return null;
} }
private static IReadOnlyList<StoryIntelligenceOnboardingScenePreviewViewModel> BuildScenePreviews(IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults) private static IReadOnlyList<StoryIntelligenceOnboardingScenePreviewViewModel> BuildScenePreviews(IReadOnlyList<StoryIntelligenceSceneImportItem> importScenes)
=> sceneResults => importScenes
.OrderBy(scene => scene.TemporarySceneNumber) .OrderBy(scene => scene.TemporarySceneNumber)
.Select(scene => new { Result = scene, Parsed = TryReadScene(scene.ParsedJson) })
.Where(scene => scene.Parsed is not null)
.Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel .Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel
{ {
SceneNumber = scene.Result.TemporarySceneNumber, SceneNumber = scene.TemporarySceneNumber,
Summary = Trim(FirstConfigured(scene.Parsed?.Summary?.Short, scene.Parsed?.Summary?.Detailed, $"Scene {scene.Result.TemporarySceneNumber:N0}")!, 180), Summary = Trim(FirstConfigured(scene.ParsedScene.Summary?.Short, scene.ParsedScene.Summary?.Detailed, $"Scene {scene.TemporarySceneNumber:N0}")!, 180),
Pov = FirstConfigured(scene.Parsed?.PointOfView?.CharacterName, scene.Parsed?.PointOfView?.NarrativeMode), Pov = FirstConfigured(scene.ParsedScene.PointOfView?.CharacterName, scene.ParsedScene.PointOfView?.NarrativeMode),
Setting = BuildSetting(scene.Parsed?.Setting), Setting = BuildSetting(scene.ParsedScene.Setting),
Confidence = scene.Parsed?.Summary?.Confidence ?? scene.Parsed?.Setting?.Confidence, Confidence = scene.ParsedScene.Summary?.Confidence ?? scene.ParsedScene.Setting?.Confidence,
HasWarnings = scene.Result.ValidationWarningsCount > 0 HasWarnings = false
}) })
.ToList(); .ToList();

View File

@ -129,11 +129,7 @@ public sealed class StoryIntelligenceImportCommitService(
: null; : null;
if (readiness is not null && !readiness.CanCommit) if (readiness is not null && !readiness.CanCommit)
{ {
blockers.Add(readiness.BlockReason ?? "This chapter contains existing authored scenes."); blockers.Add(readiness.BlockReason ?? "This chapter already contains scenes.");
}
else if (readiness?.HasUntouchedDefaultPlaceholder == true)
{
warnings.Add("This chapter contains only the default empty scene. It will be replaced by Scene 1 from the import.");
} }
if (existingCommit is not null && string.Equals(existingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) if (existingCommit is not null && string.Equals(existingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
@ -169,7 +165,7 @@ public sealed class StoryIntelligenceImportCommitService(
var parsed = TryReadScene(sceneResult); var parsed = TryReadScene(sceneResult);
if (parsed is null) if (parsed is null)
{ {
warnings.Add($"Suggested scene {sceneResult.TemporarySceneNumber:N0} was skipped because its parsed JSON could not be read."); warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} could not be prepared automatically and needs review.");
continue; continue;
} }
@ -178,7 +174,7 @@ public sealed class StoryIntelligenceImportCommitService(
if (importScenes.Count == 0) if (importScenes.Count == 0)
{ {
blockers.Add("No valid scene intelligence results are available to import."); blockers.Add("No scenes are ready to create for this chapter.");
} }
var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None"; var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None";
@ -200,13 +196,13 @@ public sealed class StoryIntelligenceImportCommitService(
BookTitle = bookTitle, BookTitle = bookTitle,
ChapterLabel = chapterLabel, ChapterLabel = chapterLabel,
ExistingSceneCount = readiness?.ActiveSceneCount ?? 0, ExistingSceneCount = readiness?.ActiveSceneCount ?? 0,
HasUntouchedDefaultPlaceholder = readiness?.HasUntouchedDefaultPlaceholder == true,
CommitMode = readiness?.CommitMode ?? string.Empty, CommitMode = readiness?.CommitMode ?? string.Empty,
ScenesToCreate = importScenes.Count, ScenesToCreate = importScenes.Count,
MetricsToImport = importScenes.Sum(scene => scene.Metrics.Count), MetricsToImport = importScenes.Sum(scene => scene.Metrics.Count),
MetricNames = importScenes.SelectMany(scene => scene.Metrics.Select(metric => metric.MetricName)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name).ToList(), MetricNames = importScenes.SelectMany(scene => scene.Metrics.Select(metric => metric.MetricName)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name).ToList(),
Warnings = warnings, Warnings = warnings,
Blockers = blockers Blockers = blockers,
Scenes = importScenes
}, },
importScenes, importScenes,
timeModeId, timeModeId,

View File

@ -0,0 +1,29 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.TR_Chapters_CreateDefaultScene', N'TR') IS NOT NULL
DROP TRIGGER dbo.TR_Chapters_CreateDefaultScene;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_GetChapterReadiness
@ChapterID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ActiveSceneCount int;
SELECT @ActiveSceneCount = COUNT(1)
FROM dbo.Scenes
WHERE ChapterID = @ChapterID
AND IsArchived = 0;
SELECT
@ActiveSceneCount AS ActiveSceneCount,
CAST(CASE WHEN @ActiveSceneCount = 0 THEN 1 ELSE 0 END AS bit) AS CanCommit,
CAST(CASE WHEN @ActiveSceneCount = 0 THEN N'EmptyChapter' ELSE N'Blocked' END AS nvarchar(80)) AS CommitMode,
CAST(CASE WHEN @ActiveSceneCount = 0 THEN NULL ELSE N'This chapter already contains scenes.' END AS nvarchar(300)) AS BlockReason;
END;
GO

View File

@ -304,13 +304,13 @@ public sealed class StoryIntelligenceImportConfirmationViewModel
public string BookTitle { get; init; } = string.Empty; public string BookTitle { get; init; } = string.Empty;
public string ChapterLabel { get; init; } = string.Empty; public string ChapterLabel { get; init; } = string.Empty;
public int ExistingSceneCount { get; init; } public int ExistingSceneCount { get; init; }
public bool HasUntouchedDefaultPlaceholder { get; init; }
public string CommitMode { get; init; } = string.Empty; public string CommitMode { get; init; } = string.Empty;
public int ScenesToCreate { get; init; } public int ScenesToCreate { get; init; }
public int MetricsToImport { get; init; } public int MetricsToImport { get; init; }
public IReadOnlyList<string> MetricNames { get; init; } = []; public IReadOnlyList<string> MetricNames { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = []; public IReadOnlyList<string> Warnings { get; init; } = [];
public IReadOnlyList<string> Blockers { get; init; } = []; public IReadOnlyList<string> Blockers { get; init; } = [];
public IReadOnlyList<StoryIntelligenceSceneImportItem> Scenes { get; init; } = [];
public bool HasCompletedCommit => ExistingCommit is not null public bool HasCompletedCommit => ExistingCommit is not null
&& string.Equals(ExistingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase); && string.Equals(ExistingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase);
public bool CanCommit => Blockers.Count == 0 && !HasCompletedCommit; public bool CanCommit => Blockers.Count == 0 && !HasCompletedCommit;

View File

@ -54,13 +54,6 @@
</div> </div>
} }
@if (Model.HasUntouchedDefaultPlaceholder)
{
<div class="alert alert-info">
This chapter contains only the default empty scene. It will be replaced by Scene 1 from the import.
</div>
}
<dl> <dl>
<dt>Project</dt> <dt>Project</dt>
<dd>@Model.ProjectName</dd> <dd>@Model.ProjectName</dd>
@ -78,10 +71,10 @@
<dd>@(Model.MetricNames.Count == 0 ? "None" : string.Join(", ", Model.MetricNames))</dd> <dd>@(Model.MetricNames.Count == 0 ? "None" : string.Join(", ", Model.MetricNames))</dd>
</dl> </dl>
@if (Model.ExistingSceneCount > 0 && !Model.HasUntouchedDefaultPlaceholder) @if (Model.ExistingSceneCount > 0)
{ {
<div class="alert alert-warning"> <div class="alert alert-warning">
This chapter already has authored scenes. The current import mode will not merge, replace, or overwrite authored scene content. This chapter already has scenes. The current import mode will not merge, replace, or overwrite scene content.
</div> </div>
} }

View File

@ -156,7 +156,7 @@
StoryIntelligenceRunStatuses.Pending => "Waiting", StoryIntelligenceRunStatuses.Pending => "Waiting",
StoryIntelligenceRunStatuses.Running => "Reading", StoryIntelligenceRunStatuses.Running => "Reading",
StoryIntelligenceRunStatuses.Completed => "Ready to create", StoryIntelligenceRunStatuses.Completed => "Ready to create",
StoryIntelligenceRunStatuses.CompletedWithWarnings => "Ready with notes", StoryIntelligenceRunStatuses.CompletedWithWarnings => "Ready",
StoryIntelligenceRunStatuses.Failed => "Needs review", StoryIntelligenceRunStatuses.Failed => "Needs review",
StoryIntelligenceRunStatuses.Cancelled => "Cancelled", StoryIntelligenceRunStatuses.Cancelled => "Cancelled",
_ => "Waiting" _ => "Waiting"

View File

@ -8,7 +8,7 @@
var isAdmin = !string.IsNullOrWhiteSpace(userEmail) var isAdmin = !string.IsNullOrWhiteSpace(userEmail)
&& adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase)); && adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase));
var chaptersNeedingReview = Model.Chapters.Count(NeedsReview); var chaptersNeedingReview = Model.Chapters.Count(NeedsReview);
var warningsCount = Model.Chapters.Sum(chapter => chapter.Warnings.Count + chapter.Blockers.Count + (chapter.FailedScenes ?? 0)); var warningsCount = Model.Chapters.Sum(chapter => NeedsReviewCount(chapter));
} }
<section class="onboarding-shell" aria-labelledby="story-review-title"> <section class="onboarding-shell" aria-labelledby="story-review-title">
@ -55,7 +55,7 @@
<strong>@FormatCost(Model.TotalEstimatedCostUSD)</strong> <strong>@FormatCost(Model.TotalEstimatedCostUSD)</strong>
</div> </div>
<div> <div>
<span>Warnings requiring review</span> <span>Needs review</span>
<strong>@warningsCount.ToString("N0")</strong> <strong>@warningsCount.ToString("N0")</strong>
</div> </div>
</section> </section>
@ -68,27 +68,27 @@
<div> <div>
<p class="eyebrow">Chapter @chapter.ChapterNumber</p> <p class="eyebrow">Chapter @chapter.ChapterNumber</p>
<h2>@chapter.ChapterTitle</h2> <h2>@chapter.ChapterTitle</h2>
<p>@((chapter.TotalDetectedScenes ?? 0).ToString("N0")) scene@(chapter.TotalDetectedScenes == 1 ? string.Empty : "s") detected / confidence @FormatConfidence(chapter.AverageConfidence)</p> <p>@DetectedCount(chapter).ToString("N0") scene@(DetectedCount(chapter) == 1 ? string.Empty : "s") detected / @ReadyCount(chapter).ToString("N0") ready to create</p>
</div> </div>
<strong class="@StatusClass(chapter)">@FriendlyStatus(chapter)</strong> <strong class="@StatusClass(chapter)">@FriendlyStatus(chapter)</strong>
</summary> </summary>
<div class="story-review-scene-panel"> <div class="story-review-scene-panel">
<div> <div>
<span>Scenes found</span> <span>Detected scenes</span>
<strong>@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</strong> <strong>@DetectedCount(chapter).ToString("N0")</strong>
</div> </div>
<div> <div>
<span>Scenes ready</span> <span>Ready</span>
<strong>@((chapter.CompletedScenes ?? 0).ToString("N0"))</strong> <strong>@ReadyCount(chapter).ToString("N0")</strong>
</div> </div>
<div> <div>
<span>Needs review</span> <span>Needs review</span>
<strong>@(((chapter.FailedScenes ?? 0) + chapter.Blockers.Count).ToString("N0"))</strong> <strong>@NeedsReviewCount(chapter).ToString("N0")</strong>
</div> </div>
<div> <div>
<span>Confidence</span> <span>Rejected</span>
<strong>@FormatConfidence(chapter.AverageConfidence)</strong> <strong>@RejectedCount(chapter).ToString("N0")</strong>
</div> </div>
</div> </div>
@ -104,7 +104,7 @@
<ul> <ul>
@foreach (var warning in chapter.Warnings) @foreach (var warning in chapter.Warnings)
{ {
<li>@warning</li> <li>@AuthorMessage(warning)</li>
} }
</ul> </ul>
</div> </div>
@ -117,7 +117,7 @@
<ul> <ul>
@foreach (var blocker in chapter.Blockers) @foreach (var blocker in chapter.Blockers)
{ {
<li>@blocker</li> <li>@AuthorMessage(blocker)</li>
} }
</ul> </ul>
</div> </div>
@ -125,7 +125,7 @@
@if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage)) @if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage))
{ {
<p class="story-review-note story-review-note--warning">@chapter.ErrorMessage</p> <p class="story-review-note story-review-note--warning">@AuthorMessage(chapter.ErrorMessage)</p>
} }
<div class="story-scene-preview-list"> <div class="story-scene-preview-list">
@ -174,7 +174,7 @@
</section> </section>
<section class="alert alert-info"> <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. Scene creation uses PlotDirector import safeguards. Duplicate imports are blocked, and failures roll back safely.
</section> </section>
<div class="onboarding-actions"> <div class="onboarding-actions">
@ -200,10 +200,21 @@
@functions { @functions {
private static bool NeedsReview(StoryIntelligenceOnboardingChapterViewModel chapter) private static bool NeedsReview(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.IsFailed => chapter.IsFailed
|| (chapter.FailedScenes ?? 0) > 0
|| chapter.Blockers.Count > 0 || chapter.Blockers.Count > 0
|| NeedsReviewCount(chapter) > 0
|| (!chapter.CanCommit && !chapter.HasCompletedCommit && chapter.IsRunComplete); || (!chapter.CanCommit && !chapter.HasCompletedCommit && chapter.IsRunComplete);
private static int DetectedCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> Math.Max(chapter.TotalDetectedScenes ?? 0, ReadyCount(chapter) + Math.Max(chapter.FailedScenes ?? 0, 0));
private static int ReadyCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.HasCompletedCommit ? chapter.ScenesCreated : chapter.Scenes.Count;
private static int RejectedCount(StoryIntelligenceOnboardingChapterViewModel chapter) => 0;
private static int NeedsReviewCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> Math.Max(0, DetectedCount(chapter) - ReadyCount(chapter) - RejectedCount(chapter));
private static string FriendlyStatus(StoryIntelligenceOnboardingChapterViewModel chapter) private static string FriendlyStatus(StoryIntelligenceOnboardingChapterViewModel chapter)
{ {
if (chapter.HasCompletedCommit) if (chapter.HasCompletedCommit)
@ -213,18 +224,18 @@
if (chapter.CanCommit) if (chapter.CanCommit)
{ {
return chapter.Warnings.Count > 0 ? "Ready with notes" : "Ready to create"; return "Ready";
} }
return chapter.Status switch return chapter.Status switch
{ {
StoryIntelligenceRunStatuses.Pending => "Waiting", StoryIntelligenceRunStatuses.Pending => "Waiting",
StoryIntelligenceRunStatuses.Running => "Reading", StoryIntelligenceRunStatuses.Running => "Reading",
StoryIntelligenceRunStatuses.Completed => "Needs attention", StoryIntelligenceRunStatuses.Completed => "Cannot import",
StoryIntelligenceRunStatuses.CompletedWithWarnings => "Needs attention", StoryIntelligenceRunStatuses.CompletedWithWarnings => "Cannot import",
StoryIntelligenceRunStatuses.Failed => "Needs review", StoryIntelligenceRunStatuses.Failed => "Needs review",
StoryIntelligenceRunStatuses.Cancelled => "Cancelled", StoryIntelligenceRunStatuses.Cancelled => "Cancelled",
_ => "Needs attention" _ => "Needs review"
}; };
} }
@ -247,6 +258,31 @@
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value; private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
private static string AuthorMessage(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "This chapter needs review before scenes can be created.";
}
var message = value.Trim();
if (message.Contains("JSON", StringComparison.OrdinalIgnoreCase)
|| message.Contains("parsed", StringComparison.OrdinalIgnoreCase)
|| message.Contains("parser", StringComparison.OrdinalIgnoreCase)
|| message.Contains("deserial", StringComparison.OrdinalIgnoreCase))
{
return "One or more scene suggestions could not be prepared automatically and need review.";
}
if (message.Contains("existing authored scenes", StringComparison.OrdinalIgnoreCase)
|| message.Contains("already contains scenes", StringComparison.OrdinalIgnoreCase))
{
return "This chapter already contains scenes. Choose an empty chapter before creating scenes from this analysis.";
}
return message;
}
private static string FormatConfidence(decimal? confidence) private static string FormatConfidence(decimal? confidence)
=> confidence.HasValue ? $"{Math.Clamp(confidence.Value, 0m, 1m):P0}" : "Not available"; => confidence.HasValue ? $"{Math.Clamp(confidence.Value, 0m, 1m):P0}" : "Not available";