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
{
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 operation = "preparing scene";
var replacePlaceholder = readiness.HasUntouchedDefaultPlaceholder
&& readiness.PlaceholderSceneID.HasValue
&& index == 0;
try
{
@ -462,7 +459,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
"dbo.Scene_Save",
new
{
SceneID = replacePlaceholder ? readiness.PlaceholderSceneID : null,
SceneID = (int?)null,
ChapterID = request.ChapterID,
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
SceneTitle = sceneTitle,
@ -587,9 +584,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
}
var notes = new List<string>();
notes.Add(readiness.HasUntouchedDefaultPlaceholder
? "Replaced untouched default placeholder scene."
: "Committed into empty chapter.");
notes.Add("Committed into empty chapter.");
notes.AddRange(request.Warnings.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 bool CanCommit { get; init; }
public bool HasUntouchedDefaultPlaceholder { get; init; }
public int? PlaceholderSceneID { get; init; }
public string CommitMode { get; init; } = string.Empty;
public string? BlockReason { get; init; }
}

View File

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

View File

@ -129,11 +129,7 @@ public sealed class StoryIntelligenceImportCommitService(
: null;
if (readiness is not null && !readiness.CanCommit)
{
blockers.Add(readiness.BlockReason ?? "This chapter contains existing authored 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.");
blockers.Add(readiness.BlockReason ?? "This chapter already contains scenes.");
}
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);
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;
}
@ -178,7 +174,7 @@ public sealed class StoryIntelligenceImportCommitService(
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";
@ -200,13 +196,13 @@ public sealed class StoryIntelligenceImportCommitService(
BookTitle = bookTitle,
ChapterLabel = chapterLabel,
ExistingSceneCount = readiness?.ActiveSceneCount ?? 0,
HasUntouchedDefaultPlaceholder = readiness?.HasUntouchedDefaultPlaceholder == true,
CommitMode = readiness?.CommitMode ?? string.Empty,
ScenesToCreate = importScenes.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(),
Warnings = warnings,
Blockers = blockers
Blockers = blockers,
Scenes = importScenes
},
importScenes,
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 ChapterLabel { get; init; } = string.Empty;
public int ExistingSceneCount { get; init; }
public bool HasUntouchedDefaultPlaceholder { get; init; }
public string CommitMode { get; init; } = string.Empty;
public int ScenesToCreate { get; init; }
public int MetricsToImport { get; init; }
public IReadOnlyList<string> MetricNames { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = [];
public IReadOnlyList<string> Blockers { get; init; } = [];
public IReadOnlyList<StoryIntelligenceSceneImportItem> Scenes { get; init; } = [];
public bool HasCompletedCommit => ExistingCommit is not null
&& string.Equals(ExistingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase);
public bool CanCommit => Blockers.Count == 0 && !HasCompletedCommit;

View File

@ -54,13 +54,6 @@
</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>
<dt>Project</dt>
<dd>@Model.ProjectName</dd>
@ -78,10 +71,10 @@
<dd>@(Model.MetricNames.Count == 0 ? "None" : string.Join(", ", Model.MetricNames))</dd>
</dl>
@if (Model.ExistingSceneCount > 0 && !Model.HasUntouchedDefaultPlaceholder)
@if (Model.ExistingSceneCount > 0)
{
<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>
}

View File

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

View File

@ -8,7 +8,7 @@
var isAdmin = !string.IsNullOrWhiteSpace(userEmail)
&& adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase));
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">
@ -55,7 +55,7 @@
<strong>@FormatCost(Model.TotalEstimatedCostUSD)</strong>
</div>
<div>
<span>Warnings requiring review</span>
<span>Needs review</span>
<strong>@warningsCount.ToString("N0")</strong>
</div>
</section>
@ -68,27 +68,27 @@
<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>
<p>@DetectedCount(chapter).ToString("N0") scene@(DetectedCount(chapter) == 1 ? string.Empty : "s") detected / @ReadyCount(chapter).ToString("N0") ready to create</p>
</div>
<strong class="@StatusClass(chapter)">@FriendlyStatus(chapter)</strong>
</summary>
<div class="story-review-scene-panel">
<div>
<span>Scenes found</span>
<strong>@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</strong>
<span>Detected scenes</span>
<strong>@DetectedCount(chapter).ToString("N0")</strong>
</div>
<div>
<span>Scenes ready</span>
<strong>@((chapter.CompletedScenes ?? 0).ToString("N0"))</strong>
<span>Ready</span>
<strong>@ReadyCount(chapter).ToString("N0")</strong>
</div>
<div>
<span>Needs review</span>
<strong>@(((chapter.FailedScenes ?? 0) + chapter.Blockers.Count).ToString("N0"))</strong>
<strong>@NeedsReviewCount(chapter).ToString("N0")</strong>
</div>
<div>
<span>Confidence</span>
<strong>@FormatConfidence(chapter.AverageConfidence)</strong>
<span>Rejected</span>
<strong>@RejectedCount(chapter).ToString("N0")</strong>
</div>
</div>
@ -104,7 +104,7 @@
<ul>
@foreach (var warning in chapter.Warnings)
{
<li>@warning</li>
<li>@AuthorMessage(warning)</li>
}
</ul>
</div>
@ -117,7 +117,7 @@
<ul>
@foreach (var blocker in chapter.Blockers)
{
<li>@blocker</li>
<li>@AuthorMessage(blocker)</li>
}
</ul>
</div>
@ -125,7 +125,7 @@
@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">
@ -174,7 +174,7 @@
</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.
Scene creation uses PlotDirector import safeguards. Duplicate imports are blocked, and failures roll back safely.
</section>
<div class="onboarding-actions">
@ -200,10 +200,21 @@
@functions {
private static bool NeedsReview(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.IsFailed
|| (chapter.FailedScenes ?? 0) > 0
|| chapter.Blockers.Count > 0
|| NeedsReviewCount(chapter) > 0
|| (!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)
{
if (chapter.HasCompletedCommit)
@ -213,18 +224,18 @@
if (chapter.CanCommit)
{
return chapter.Warnings.Count > 0 ? "Ready with notes" : "Ready to create";
return "Ready";
}
return chapter.Status switch
{
StoryIntelligenceRunStatuses.Pending => "Waiting",
StoryIntelligenceRunStatuses.Running => "Reading",
StoryIntelligenceRunStatuses.Completed => "Needs attention",
StoryIntelligenceRunStatuses.CompletedWithWarnings => "Needs attention",
StoryIntelligenceRunStatuses.Completed => "Cannot import",
StoryIntelligenceRunStatuses.CompletedWithWarnings => "Cannot import",
StoryIntelligenceRunStatuses.Failed => "Needs review",
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 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)
=> confidence.HasValue ? $"{Math.Clamp(confidence.Value, 0m, 1m):P0}" : "Not available";