Remove obsolete Story Intelligence scene review workflow

This commit is contained in:
Nick Beckley 2026-08-29 09:33:14 +00:00
parent e9f15e076b
commit 0ec0167746
20 changed files with 250 additions and 463 deletions

View File

@ -231,8 +231,11 @@ var tests = new (string Name, Action Test)[]
("Word manuscript unlink keeps Story Intelligence data untouched", WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched),
("Word manuscript cleanup remains relinkable and idempotent", WordManuscriptCleanupRemainsRelinkableAndIdempotent),
("Story Intelligence Review Centre route replaces linear review resume", StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume),
("Story Intelligence obsolete create scene workflow is removed", StoryIntelligenceObsoleteCreateSceneWorkflowIsRemoved),
("Story Intelligence Review Centre counts only needs-attention categories", StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories),
("Story Intelligence Review Centre opens with character gate", StoryIntelligenceReviewCentreOpensWithCharacterGate),
("Story Intelligence review modules are independent after character gate", StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate),
("Story Intelligence downstream review uses completed scene analysis", StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis),
("Story Intelligence review posts do not use success interstitials", StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials),
("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard),
("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
@ -609,7 +612,7 @@ static void StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning()
Assert(onboardingViewModels.Contains("public bool CanContinueReview => !IsInProgress", StringComparison.Ordinal), "Project dashboard should suppress review buttons for running analysis.");
Assert(coreServices.Contains("var isInProgress = string.Equals(pipeline.Status, StoryIntelligencePipelineStatuses.InProgress", StringComparison.Ordinal), "Book action builder should inspect the pipeline status.");
Assert(coreServices.Contains("CanContinue = !isInProgress", StringComparison.Ordinal), "Book action builder should block review navigation while analysis is running.");
Assert(coreServices.Contains("ActionLabel = pipeline.IsComplete ? \"Open Review Centre\" : \"Continue review\"", StringComparison.Ordinal), "Open Review Centre should be reserved for complete Story Intelligence.");
Assert(coreServices.Contains("ActionLabel = isInProgress ? \"Analysis running\" : \"Open Review Centre\"", StringComparison.Ordinal), "Open Review Centre should be available for completed analysis that still has review work.");
Assert(storyService.Contains("IsInProgress = string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress", StringComparison.Ordinal), "Project dashboard pipeline rows should map in-progress state.");
Assert(bookView.Contains("if (action.CanContinue)", StringComparison.Ordinal), "Book details should render the review link only when the pipeline can continue.");
Assert(bookView.Contains("<span class=\"status-pill\">Analysis running</span>", StringComparison.Ordinal), "Book details should show running status instead of a premature review-centre button.");
@ -626,8 +629,24 @@ static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume()
Assert(controller.Contains("story-intelligence/review-centre", StringComparison.Ordinal), "Review Centre route should exist.");
Assert(controller.Contains("StoryIntelligenceResumeRoutes.ReviewCentre => RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), "Resume routing should open Review Centre.");
Assert(service.Contains("public const string ReviewCentre = \"ReviewCentre\";", StringComparison.Ordinal), "Resume route constants should include ReviewCentre.");
Assert(service.Contains("? StoryIntelligenceResumeRoutes.ReviewCentre", StringComparison.Ordinal), "Completed character resolution should resume to Review Centre.");
Assert(view.Contains("Review only the story intelligence that needs an author decision.", StringComparison.Ordinal), "Review Centre should describe needs-attention review.");
Assert(service.Contains("SceneAnalysisComplete(state)\n ? StoryIntelligenceResumeRoutes.ReviewCentre", StringComparison.Ordinal), "Completed scene analysis should resume to Review Centre.");
Assert(view.Contains("Your manuscript analysis is complete.", StringComparison.Ordinal), "Review Centre should describe completed analysis.");
}
static void StoryIntelligenceObsoleteCreateSceneWorkflowIsRemoved()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml"));
var overviewView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligence.cshtml"));
var reviewViewPath = Path.Combine(root, "Views/Onboarding/StoryIntelligenceReview.cshtml");
var legacyReviewAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceReview", "[HttpGet(\"story-intelligence/review-centre\")]");
Assert(!File.Exists(reviewViewPath), "Obsolete Create Scene Intelligence view should be removed.");
Assert(legacyReviewAction.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), "Legacy review URL should redirect to Review Centre after analysis.");
Assert(!legacyReviewAction.Contains("View(model)", StringComparison.Ordinal), "Legacy review URL must never render obsolete UI.");
Assert(!progressView.Contains("Create scene intelligence", StringComparison.OrdinalIgnoreCase), "Progress view should not link to old create scene workflow.");
Assert(!overviewView.Contains("Create scene intelligence", StringComparison.OrdinalIgnoreCase), "Overview should not describe old create scene workflow.");
}
static void StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories()
@ -640,10 +659,23 @@ static void StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories()
Assert(viewModel.Contains("CharacterNeedsAttentionCount => CharacterReview.Candidates.Count", StringComparison.Ordinal), "Character count should be derived from unresolved candidates.");
Assert(viewModel.Contains("LocationNeedsAttentionCount => LocationReview.Candidates.Count", StringComparison.Ordinal), "Location count should be derived from unresolved candidates.");
Assert(viewModel.Contains("NarrativeNeedsAttentionCount => NarrativeReview.Candidates.Count", StringComparison.Ordinal), "Narrative count should be derived from unresolved candidates.");
Assert(centre.Contains("Scene Metrics", StringComparison.Ordinal) && centre.Contains("0 need attention", StringComparison.Ordinal), "Metrics should be available, not mandatory review.");
Assert(centre.Contains("Scene Metrics", StringComparison.Ordinal) && centre.Contains("TotalAnalysedScenes", StringComparison.Ordinal), "Metrics should report analysed scene count.");
Assert(!summary.Contains("Locations identified", StringComparison.Ordinal), "Summary should not present total identified rows as review workload.");
}
static void StoryIntelligenceReviewCentreOpensWithCharacterGate()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var centreAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceReviewCentre", "[HttpGet(\"story-intelligence/characters\")]");
var centreView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
Assert(centreAction.Contains("return View(model);", StringComparison.Ordinal), "Review Centre should render after completed analysis.");
Assert(!centreAction.Contains("StoryIntelligenceCharacters", StringComparison.Ordinal), "Review Centre should not redirect unresolved Characters away from the centre.");
Assert(centreView.Contains("Resolve Characters first", StringComparison.Ordinal), "Locked modules should explain the character gate.");
Assert(centreView.Contains("Locked until Characters are resolved", StringComparison.Ordinal), "Dependent modules should remain visible but locked.");
}
static void StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate()
{
var root = PlotLineRoot();
@ -658,7 +690,30 @@ static void StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate()
Assert(!relationshipsAction.Contains("LocationStageComplete", StringComparison.Ordinal) && !relationshipsAction.Contains("AssetStageComplete", StringComparison.Ordinal), "Relationships should not require completed location or asset review.");
Assert(!knowledgeAction.Contains("LocationStageComplete", StringComparison.Ordinal) && !knowledgeAction.Contains("RelationshipStageComplete", StringComparison.Ordinal), "Knowledge should not require earlier non-character reviews.");
Assert(!narrativeAction.Contains("KnowledgeStageComplete", StringComparison.Ordinal) && !narrativeAction.Contains("RelationshipStageComplete", StringComparison.Ordinal), "Narrative review should not require knowledge or relationship review.");
Assert(locationsAction.Contains("CharacterStageComplete", StringComparison.Ordinal), "Dependent review modules should still respect the character gate.");
Assert(locationsAction.Contains("CharacterGateSatisfied", StringComparison.Ordinal), "Dependent review modules should still respect the character gate.");
Assert(locationsAction.Contains("RedirectToReviewCentreWithCharacterGate", StringComparison.Ordinal), "Direct dependent module URLs should return to Review Centre when the character gate is closed.");
}
static void StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis()
{
var root = PlotLineRoot();
var reviewServices = new[]
{
"StoryIntelligenceCharacterImportService.cs",
"StoryIntelligenceLocationImportService.cs",
"StoryIntelligenceAssetImportService.cs",
"StoryIntelligenceRelationshipImportService.cs",
"StoryIntelligenceKnowledgeImportService.cs",
"StoryIntelligenceNarrativeReviewService.cs"
};
foreach (var fileName in reviewServices)
{
var service = File.ReadAllText(Path.Combine(root, "Services", fileName));
Assert(!service.Contains("GetImportCommitAsync", StringComparison.Ordinal), $"{fileName} should not require legacy StoryIntelligenceImportCommits.");
Assert(service.Contains("StoryIntelligenceRunStatuses.Completed", StringComparison.Ordinal), $"{fileName} should use completed persisted runs as its scene-analysis prerequisite.");
Assert(service.Contains("ListSceneResultsAsync", StringComparison.Ordinal), $"{fileName} should read persisted scene analysis results.");
}
}
static void StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials()
@ -2946,7 +3001,6 @@ static void StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts()
var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var reviewView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReview.cshtml"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
Assert(commit.Contains("BuildReviewScenes(canonicalScenes, sceneResults, importScenes)", StringComparison.Ordinal), "Review confirmation should build canonical scene previews.");
@ -2956,9 +3010,7 @@ static void StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts()
Assert(commit.Contains("GroupBy(result => result.SceneID!.Value)", StringComparison.Ordinal), "Duplicate mapped SI rows should collapse to one canonical scene preview.");
Assert(onboardingStory.Contains("FirstConfigured(scene.Summary, scene.ParsedScene.Summary?.Short", StringComparison.Ordinal), "Onboarding preview should prefer canonical summary with legacy SI fallback.");
Assert(viewModel.Contains("chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes", StringComparison.Ordinal), "Review headline total should derive from canonical scene previews when available.");
Assert(reviewView.Contains("chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes", StringComparison.Ordinal), "Per-chapter detected count should derive from canonical scene previews.");
Assert(reviewView.Contains("chapter.Scenes.Count(scene => !scene.HasWarnings)", StringComparison.Ordinal), "Ready count should not include missing or failed canonical scene analysis.");
Assert(reviewView.Contains("@scene.AnalysisStatus", StringComparison.Ordinal), "Review UI should show missing/failed analysis state on canonical scenes.");
Assert(viewModel.Contains("public int TotalAnalysedScenes => Math.Max(TotalCompletedScenes, PipelineDashboard.ScenesImported)", StringComparison.Ordinal), "Review Centre should use analysed canonical scene totals after the old scene-creation view is removed.");
Assert(repository.Contains("SceneID = item.SceneID ??", StringComparison.Ordinal), "Commit should target canonical SceneID when one exists.");
}
@ -5015,7 +5067,7 @@ static void StoryIntelligenceNarrativeStageFollowsKnowledge()
Assert(pipeline.Contains("RecordNarrativeImportAsync", StringComparison.Ordinal), "Narrative import should mark the pipeline complete.");
Assert(pipelineConstraint.Contains("N'NarrativeReview'", StringComparison.Ordinal) && pipelineConstraint.Contains("N'NarrativeImport'", StringComparison.Ordinal), "Database pipeline constraint should allow narrative stages.");
Assert(knowledgeImport.Contains("hasUndecidedCandidates", StringComparison.Ordinal), "Knowledge review should be able to advance when prior decisions already imported every candidate.");
Assert(onboarding.Contains("StoryIntelligenceResumeRoutes.Narrative", StringComparison.Ordinal), "Resume logic should route to narrative review.");
Assert(onboarding.Contains("SceneAnalysisComplete(state)\n ? StoryIntelligenceResumeRoutes.ReviewCentre", StringComparison.Ordinal), "Resume logic should route completed analysis to Review Centre.");
Assert(controller.Contains("StoryIntelligenceNarrative", StringComparison.Ordinal), "Onboarding controller should expose the narrative review stage.");
Assert(knowledgeComplete.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "Knowledge completion should lead to narrative review.");
}

View File

@ -117,13 +117,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
{
StoryIntelligenceResumeRoutes.ReviewCentre => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Narrative => RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }),
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID })
StoryIntelligenceResumeRoutes.Narrative => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }),
_ => RedirectToAction("Details", "Books", new { id = bookId })
};
}
@ -192,7 +192,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
public async Task<IActionResult> StoryIntelligenceProgress(Guid batchId)
{
var model = await storyIntelligence.GetProgressAsync(batchId);
return model is null ? NotFound() : View(model);
if (model is null)
{
return NotFound();
}
return model.SceneAnalysisComplete
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId })
: RedirectToBook(model);
}
[HttpGet("story-intelligence/review")]
@ -206,12 +213,12 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
return RedirectToBook(model);
}
return model.AllCommitted
? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId })
: View(model);
return model.SceneAnalysisComplete
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId })
: RedirectToBook(model);
}
[HttpGet("story-intelligence/review-centre")]
@ -225,17 +232,15 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
return RedirectToBook(model);
}
if (!model.AllCommitted)
if (!model.SceneAnalysisComplete)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
return RedirectToBook(model);
}
return !model.PipelineDashboard.CharacterStageComplete
? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId })
: View(model);
return View(model);
}
[HttpGet("story-intelligence/characters")]
@ -249,15 +254,15 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
return RedirectToBook(model);
}
if (!model.AllCommitted)
if (!model.SceneAnalysisComplete)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
return model.PipelineDashboard.CharacterStageComplete && model.CharacterReview.Candidates.Count == 0
return model.CharacterGateSatisfied && model.CharacterReview.Candidates.Count == 0
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId })
: View(model);
}
@ -273,16 +278,16 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
return RedirectToBook(model);
}
if (!model.AllCommitted)
if (!model.SceneAnalysisComplete)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
return !model.PipelineDashboard.CharacterStageComplete
? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId })
return !model.CharacterGateSatisfied
? RedirectToReviewCentreWithCharacterGate(batchId)
: View(model);
}
@ -297,17 +302,17 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
return RedirectToBook(model);
}
if (!model.AllCommitted)
if (!model.SceneAnalysisComplete)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
if (!model.PipelineDashboard.CharacterStageComplete)
if (!model.CharacterGateSatisfied)
{
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
return RedirectToReviewCentreWithCharacterGate(batchId);
}
return View(model);
@ -324,17 +329,17 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
return RedirectToBook(model);
}
if (!model.AllCommitted)
if (!model.SceneAnalysisComplete)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
if (!model.PipelineDashboard.CharacterStageComplete)
if (!model.CharacterGateSatisfied)
{
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
return RedirectToReviewCentreWithCharacterGate(batchId);
}
return View(model);
@ -351,7 +356,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
}
TempData["OnboardingStoryIntelligenceMessage"] = "Analysis cancellation requested.";
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
return RedirectToBook(progress);
}
[HttpPost("story-intelligence/commit")]
@ -365,14 +370,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
if (result.Success && progress.AllCommitted)
{
return RedirectToAction(progress.PipelineDashboard.CharacterStageComplete || progress.CharacterReview.Candidates.Count == 0
? nameof(StoryIntelligenceReviewCentre)
: nameof(StoryIntelligenceCharacters), new { batchId });
}
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpPost("story-intelligence/commit-all")]
@ -386,11 +384,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success && progress.AllCommitted
? RedirectToAction(progress.PipelineDashboard.CharacterStageComplete || progress.CharacterReview.Candidates.Count == 0
? nameof(StoryIntelligenceReviewCentre)
: nameof(StoryIntelligenceCharacters), new { batchId })
: RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpPost("story-intelligence/characters")]
@ -496,17 +490,17 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
return RedirectToBook(model);
}
if (!model.AllCommitted)
if (!model.SceneAnalysisComplete)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
if (!model.PipelineDashboard.CharacterStageComplete)
if (!model.CharacterGateSatisfied)
{
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
return RedirectToReviewCentreWithCharacterGate(batchId);
}
return View(model);
@ -546,17 +540,17 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
return RedirectToBook(model);
}
if (!model.AllCommitted)
if (!model.SceneAnalysisComplete)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
if (!model.PipelineDashboard.CharacterStageComplete)
if (!model.CharacterGateSatisfied)
{
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
return RedirectToReviewCentreWithCharacterGate(batchId);
}
return View(model);
@ -594,15 +588,24 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return NotFound();
}
if (!progress.PipelineDashboard.CharacterStageComplete)
if (!progress.CharacterGateSatisfied)
{
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
var model = await storyIntelligence.GetCompletionAsync(batchId);
return model is null ? NotFound() : View(model);
}
private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model)
=> RedirectToAction("Details", "Books", new { id = model.BookID });
private IActionResult RedirectToReviewCentreWithCharacterGate(Guid batchId)
{
TempData["OnboardingStoryIntelligenceMessage"] = "Resolve Characters before reviewing this category.";
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpPost("scan-review")]
[RequestSizeLimit(25 * 1024 * 1024)]
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]

View File

@ -1382,13 +1382,19 @@ public sealed class BookService(
}
var isInProgress = string.Equals(pipeline.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase);
var needsCharacterReview = string.Equals(pipeline.CurrentReviewStage, StoryIntelligencePipelineStages.CharacterReview, StringComparison.OrdinalIgnoreCase)
|| string.Equals(pipeline.CurrentStage, StoryIntelligencePipelineStages.CharacterReview, StringComparison.OrdinalIgnoreCase);
return new StoryIntelligenceBookActionViewModel
{
HasCanonicalSceneSources = hasCanonicalSceneSources,
CanStart = false,
CanContinue = !isInProgress,
StatusLabel = pipeline.IsComplete ? "Complete" : isInProgress ? "Analysis in progress" : pipeline.Status,
ActionLabel = pipeline.IsComplete ? "Open Review Centre" : "Continue review",
StatusLabel = pipeline.IsComplete
? "Review complete"
: isInProgress
? "Analysis in progress"
: needsCharacterReview ? "Character review required" : "Needs review",
ActionLabel = isInProgress ? "Analysis running" : "Open Review Centre",
Description = pipeline.IsComplete
? "Story Intelligence review is saved for this book."
: isInProgress

View File

@ -664,7 +664,8 @@ public sealed class OnboardingStoryIntelligenceService(
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated),
ScenesImported = Math.Max(chapters.Sum(chapter => chapter.ScenesCreated), chapters.Sum(chapter => chapter.CompletedScenes ?? 0)),
SceneAnalysisComplete = pipeline is not null && SceneAnalysisComplete(pipeline),
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked),
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete,
@ -1069,22 +1070,9 @@ public sealed class OnboardingStoryIntelligenceService(
await batchStore.SaveAsync(batch);
var route = characterStageComplete
var route = SceneAnalysisComplete(state)
? StoryIntelligenceResumeRoutes.ReviewCentre
: state.CurrentStage switch
{
StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge,
StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge,
StoryIntelligencePipelineStages.NarrativeReview => StoryIntelligenceResumeRoutes.Narrative,
StoryIntelligencePipelineStages.NarrativeImport => StoryIntelligenceResumeRoutes.Narrative,
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
StoryIntelligencePipelineStages.SceneImport => StoryIntelligenceResumeRoutes.SceneReview,
_ => StoryIntelligenceResumeRoutes.SceneReview
};
: StoryIntelligenceResumeRoutes.SceneReview;
return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route);
}
@ -1092,6 +1080,22 @@ public sealed class OnboardingStoryIntelligenceService(
private static bool HasCompletedStage(StoryIntelligenceBookPipelineState state, string stage)
=> string.Equals(state.LastCompletedStage, stage, StringComparison.OrdinalIgnoreCase);
private static bool SceneAnalysisComplete(StoryIntelligenceBookPipelineState state)
=> HasCompletedStage(state, StoryIntelligencePipelineStages.SceneImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.CharacterReview
or StoryIntelligencePipelineStages.CharacterImport
or StoryIntelligencePipelineStages.LocationReview
or StoryIntelligencePipelineStages.LocationImport
or StoryIntelligencePipelineStages.AssetReview
or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId)
{
var state = await onboardingRepository.GetAsync(userId);

View File

@ -82,7 +82,7 @@ public sealed class StoryIntelligenceAssetImportService(
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing assets." };
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing assets." };
}
var choices = form.Assets
@ -311,14 +311,19 @@ public sealed class StoryIntelligenceAssetImportService(
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
var run = await runs.GetRunAsync(item.RunID);
if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{
continue;
}
hasCommittedScenes = true;
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes

View File

@ -184,7 +184,7 @@ public sealed class StoryIntelligenceCharacterImportService(
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing characters." };
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing characters." };
}
var choices = form.Characters
@ -416,14 +416,19 @@ public sealed class StoryIntelligenceCharacterImportService(
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
var run = await runs.GetRunAsync(item.RunID);
if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{
continue;
}
hasCommittedScenes = true;
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes

View File

@ -197,7 +197,7 @@ public sealed class StoryIntelligenceImportCommitService(
if (importScenes.Count == 0)
{
blockers.Add("No scenes are ready to create for this chapter.");
blockers.Add("No analysed scene results are available for this chapter.");
}
var readiness = run.ChapterID.HasValue

View File

@ -70,7 +70,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing knowledge." };
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing knowledge." };
}
var choices = form.Knowledge
@ -227,14 +227,19 @@ public sealed class StoryIntelligenceKnowledgeImportService(
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
var run = await runs.GetRunAsync(item.RunID);
if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{
continue;
}
hasCommittedScenes = true;
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes

View File

@ -92,7 +92,7 @@ public sealed class StoryIntelligenceLocationImportService(
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing locations." };
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing locations." };
}
var choices = form.Locations
@ -269,14 +269,19 @@ public sealed class StoryIntelligenceLocationImportService(
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
var run = await runs.GetRunAsync(item.RunID);
if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{
continue;
}
hasCommittedScenes = true;
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes

View File

@ -293,13 +293,18 @@ public sealed class StoryIntelligenceNarrativeReviewService(
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
var run = await runs.GetRunAsync(item.RunID);
if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{
continue;
}
var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes
@ -425,8 +430,14 @@ public sealed class StoryIntelligenceNarrativeReviewService(
{
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
var run = await runs.GetRunAsync(item.RunID);
if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count > 0)
{
return true;
}

View File

@ -65,7 +65,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing relationships." };
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing relationships." };
}
var choices = form.Relationships
@ -261,14 +261,19 @@ public sealed class StoryIntelligenceRelationshipImportService(
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
var run = await runs.GetRunAsync(item.RunID);
if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{
continue;
}
hasCommittedScenes = true;
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes

View File

@ -182,7 +182,7 @@ public sealed class StoryIntelligenceService(
ActiveImportProgressLabel = $"Chapter {currentChapter:N0} of {activeBatch.Items.Count:N0}",
Title = "Story Intelligence is reading",
Description = "PlotDirector is analysing your manuscript in the background.",
ButtonText = "View progress",
ButtonText = "Open book",
PipelineBooks = pipelineBooks
};
}
@ -303,7 +303,7 @@ public sealed class StoryIntelligenceService(
StoryIntelligencePipelineStages.AssetReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.LocationReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.CharacterReview => "Resolve character identities before reviewing dependent story intelligence.",
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.",
StoryIntelligencePipelineStages.SceneReview => "Story Intelligence analysis has not finished for this book.",
_ when StoryIntelligenceReadyForKnowledge(state) => "Open the Review Centre and choose which saved category to handle.",
_ when StoryIntelligenceReadyForRelationships(state) => "Open the Review Centre and choose which saved category to handle.",
_ when StoryIntelligenceReadyForAssets(state) => "Open the Review Centre and choose which saved category to handle.",

View File

@ -167,6 +167,7 @@ public sealed class StoryIntelligenceProgressViewModel
public int FailedChapterCount => Chapters.Count(chapter => chapter.IsFailed);
public int TotalDetectedScenes => Chapters.Sum(chapter => chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes ?? 0);
public int TotalCompletedScenes => Chapters.Sum(chapter => chapter.CompletedScenes ?? 0);
public int TotalAnalysedScenes => Math.Max(TotalCompletedScenes, PipelineDashboard.ScenesImported);
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;
@ -174,6 +175,8 @@ public sealed class StoryIntelligenceProgressViewModel
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 bool SceneAnalysisComplete => PipelineDashboard.SceneAnalysisComplete
|| (Chapters.Count > 0 && !HasActiveRuns && Chapters.All(chapter => chapter.IsRunComplete));
public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0;
public bool HasLocationsToReview => LocationReview.Candidates.Count > 0;
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
@ -201,6 +204,7 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
{
public int ChaptersAnalysed { get; init; }
public int ScenesImported { get; init; }
public bool SceneAnalysisComplete { get; init; }
public int CharactersIdentified { get; init; }
public int CharactersCreatedOrLinked { get; init; }
public bool CharacterStageComplete { get; init; }

View File

@ -230,8 +230,8 @@
StoryIntelligencePipelineStages.RelationshipReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.AssetReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.LocationReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.CharacterReview => "Scene creation is complete. Resolve character identities before reviewing dependent story intelligence.",
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them in PlotDirector.",
StoryIntelligencePipelineStages.CharacterReview => "Analysis is complete. Resolve character identities before reviewing dependent story intelligence.",
StoryIntelligencePipelineStages.SceneReview => "Story Intelligence analysis has not finished for this book.",
_ when StoryIntelligenceReadyForNarrative(pipeline) => "Open the Review Centre and choose which saved category to handle.",
_ when StoryIntelligenceReadyForRelationships(pipeline) => "Open the Review Centre and choose which saved category to handle.",
_ when StoryIntelligenceReadyForAssets(pipeline) => "Open the Review Centre and choose which saved category to handle.",

View File

@ -24,7 +24,7 @@
}
else
{
@:@Model.ScenesCreated.ToString("N0") scenes detected across @Model.ChaptersCreated.ToString("N0") chapters. Keep this browser workflow open to review characters.
@:@Model.ScenesCreated.ToString("N0") scenes imported across @Model.ChaptersCreated.ToString("N0") chapters. Keep this browser workflow open to review characters.
}
</p>
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))

View File

@ -27,7 +27,7 @@
<h2>Analysis has already started</h2>
<p>You can return to progress or open the Review Centre when analysis is ready.</p>
<div class="onboarding-actions">
<a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.ExistingBatchID">View progress</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.ExistingBatchID">Return to book</a>
<form asp-action="ScanManuscript" method="post">
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
</form>
@ -72,7 +72,7 @@
<div class="onboarding-analysis-flow">
<div><span>1</span><strong>Analyse chapters</strong></div>
<div><span>2</span><strong>Create scene intelligence</strong></div>
<div><span>2</span><strong>Analyse canonical scenes</strong></div>
<div><span>3</span><strong>Resolve characters</strong></div>
<div><span>4</span><strong>Open Review Centre</strong></div>
</div>

View File

@ -23,7 +23,7 @@
<h2>Analysis has already started</h2>
<p>You can return to progress or open the Review Centre when analysis is ready.</p>
<div class="onboarding-actions">
<a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.ExistingBatchID">View progress</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.ExistingBatchID">Return to book</a>
<form asp-action="ScanManuscript" method="post">
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
</form>

View File

@ -150,8 +150,8 @@
{
<a class="btn btn-primary"
data-story-review-link
asp-action="@(Model.AllCommitted && Model.CharacterGateSatisfied ? "StoryIntelligenceReviewCentre" : "StoryIntelligenceReview")"
asp-route-batchId="@Model.BatchID">@(Model.AllCommitted && Model.CharacterGateSatisfied ? "Open Review Centre" : "Create scene intelligence")</a>
asp-action="StoryIntelligenceReviewCentre"
asp-route-batchId="@Model.BatchID">Open Review Centre</a>
}
}
</div>

View File

@ -1,326 +0,0 @@
@model StoryIntelligenceProgressViewModel
@using System.Security.Claims
@inject IConfiguration Configuration
@{
ViewData["Title"] = "Create scene intelligence";
var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
var userEmail = User.FindFirstValue(ClaimTypes.Email);
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 => NeedsReviewCount(chapter));
}
<section class="onboarding-shell" aria-labelledby="story-review-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Scenes" })" />
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 2</p>
<p class="story-review-heading__phase">Create Scene Intelligence</p>
<h1 id="story-review-title">Create Scene Intelligence</h1>
<p>PlotDirector has finished reading your manuscript. Create the stored scene intelligence before opening the Review Centre.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<section class="alert alert-info">
Scene intelligence is created first. Character resolution is reviewed separately; after that, locations, assets, relationships, knowledge, and Plot Lines can be reviewed independently.
</section>
<section class="story-review-summary" aria-label="Analysis summary">
<div>
<span>Story Intelligence complete</span>
<strong>@Model.CompletedChapterCount.ToString("N0") chapter@(Model.CompletedChapterCount == 1 ? string.Empty : "s")</strong>
</div>
<div>
<span>Scenes detected</span>
<strong>@Model.TotalDetectedScenes.ToString("N0")</strong>
</div>
<div>
<span>Words analysed</span>
<strong>@Model.TotalAnalysedWords.ToString("N0")</strong>
</div>
<div>
<span>Elapsed time</span>
<strong>@FormatDuration(Model.TotalDurationMs)</strong>
</div>
<div>
<span>Estimated AI cost</span>
<strong>@FormatCost(Model.TotalEstimatedCostUSD)</strong>
</div>
<div>
<span>Needs 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))
{
<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>@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>Detected scenes</span>
<strong>@DetectedCount(chapter).ToString("N0")</strong>
</div>
<div>
<span>Ready</span>
<strong>@ReadyCount(chapter).ToString("N0")</strong>
</div>
<div>
<span>Needs review</span>
<strong>@NeedsReviewCount(chapter).ToString("N0")</strong>
</div>
<div>
<span>Rejected</span>
<strong>@RejectedCount(chapter).ToString("N0")</strong>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(chapter.CommitMessage))
{
<p class="story-review-note">@chapter.CommitMessage</p>
}
@if (chapter.Warnings.Count > 0)
{
<div class="story-review-note">
<strong>Notes</strong>
<ul>
@foreach (var warning in chapter.Warnings)
{
<li>@AuthorMessage(warning)</li>
}
</ul>
</div>
}
@if (chapter.Blockers.Count > 0)
{
<div class="story-review-note story-review-note--warning">
<strong>Before scenes can be created</strong>
<ul>
@foreach (var blocker in chapter.Blockers)
{
<li>@AuthorMessage(blocker)</li>
}
</ul>
</div>
}
@if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage))
{
<p class="story-review-note story-review-note--warning">@AuthorMessage(chapter.ErrorMessage)</p>
}
<div class="story-scene-preview-list">
@if (chapter.Scenes.Count == 0)
{
<p class="text-muted mb-0">No readable scene previews are available for this chapter.</p>
}
else
{
@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">@scene.AnalysisStatus</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>
@if (isAdmin)
{
<details class="story-review-admin">
<summary>Admin diagnostics</summary>
<dl>
<div><dt>Run ID</dt><dd>@chapter.RunID</dd></div>
<div><dt>Status</dt><dd>@chapter.Status</dd></div>
<div><dt>Stage</dt><dd>@(chapter.CurrentStage ?? "-")</dd></div>
<div><dt>Failure stage</dt><dd>@(chapter.FailureStage ?? "-")</dd></div>
<div><dt>Tokens</dt><dd>@(chapter.TotalTokens?.ToString("N0") ?? "-")</dd></div>
</dl>
<a class="btn btn-outline-secondary btn-sm" asp-controller="Admin" asp-action="StoryIntelligenceRunDetails" asp-route-id="@chapter.RunID">Open diagnostics</a>
</details>
}
</details>
}
</section>
<section class="alert alert-info">
Scene creation uses PlotDirector import safeguards. 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">Back</a>
@if (Model.HasReadyScenesToCreate)
{
<form asp-action="CommitAllStoryIntelligence" method="post" data-story-review-form>
<input type="hidden" name="batchId" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit" data-busy-label="Creating scene intelligence...">Create scene intelligence</button>
</form>
}
else if (Model.AllCommitted)
{
<a class="btn btn-primary"
asp-action="@(Model.CharacterGateSatisfied ? "StoryIntelligenceReviewCentre" : "StoryIntelligenceCharacters")"
asp-route-batchId="@Model.BatchID">@(Model.CharacterGateSatisfied ? "Open Review Centre" : "Resolve characters")</a>
}
}
</div>
</div>
</section>
@section Scripts {
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
}
@functions {
private static bool NeedsReview(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.IsFailed
|| chapter.Blockers.Count > 0
|| NeedsReviewCount(chapter) > 0
|| (!chapter.CanCommit && !chapter.HasCompletedCommit && chapter.IsRunComplete);
private static int DetectedCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes ?? 0;
private static int ReadyCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.HasCompletedCommit ? chapter.ScenesCreated : chapter.Scenes.Count(scene => !scene.HasWarnings);
private static int RejectedCount(StoryIntelligenceOnboardingChapterViewModel chapter) => 0;
private static int NeedsReviewCount(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.Scenes.Count > 0
? chapter.Scenes.Count(scene => scene.HasWarnings)
: Math.Max(0, DetectedCount(chapter) - ReadyCount(chapter) - RejectedCount(chapter));
private static string FriendlyStatus(StoryIntelligenceOnboardingChapterViewModel chapter)
{
if (chapter.HasCompletedCommit)
{
return "Scenes created";
}
if (chapter.CanCommit)
{
return "Ready";
}
return chapter.Status switch
{
StoryIntelligenceRunStatuses.Pending => "Waiting",
StoryIntelligenceRunStatuses.Running => "Reading",
StoryIntelligenceRunStatuses.Completed => "Cannot import",
StoryIntelligenceRunStatuses.CompletedWithWarnings => "Cannot import",
StoryIntelligenceRunStatuses.Failed => "Needs review",
StoryIntelligenceRunStatuses.Cancelled => "Cancelled",
_ => "Needs review"
};
}
private static string StatusClass(StoryIntelligenceOnboardingChapterViewModel chapter)
{
if (chapter.HasCompletedCommit)
{
return "story-review-status story-review-status--created";
}
if (chapter.CanCommit)
{
return "story-review-status story-review-status--ready";
}
return chapter.IsActive
? "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 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)
{
if (!confidence.HasValue)
{
return "Not available";
}
var value = Math.Clamp(confidence.Value, 0m, 1m);
return value >= 0.75m
? "High"
: value >= 0.45m
? "Medium"
: "Low";
}
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

@ -10,7 +10,7 @@
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Story Intelligence</p>
<h1 id="story-review-centre-title">Review Centre</h1>
<p>Review only the story intelligence that needs an author decision. Everything here is saved and can be picked up later.</p>
<p>Your manuscript analysis is complete. Review only the story intelligence that needs an author decision; everything here is saved and can be picked up later.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -28,9 +28,9 @@
@ReviewModule(
"Characters",
Model.CharacterNeedsAttentionCount,
Model.PipelineDashboard.CharacterStageComplete ? "Resolved" : "Needs attention",
Model.CharacterGateSatisfied ? "Resolved" : "Needs attention",
"Resolve identity questions before reviewing dependent story intelligence.",
"Review characters",
"Resolve Characters",
"StoryIntelligenceCharacters",
false)
@ -84,8 +84,16 @@
<h2>Scene Metrics</h2>
<p>Available from the imported scene analysis. Metrics are auto-applied where configured and do not require a mandatory review step.</p>
</div>
@if (Model.CharacterGateSatisfied)
{
<strong class="story-review-status story-review-status--ready">Available</strong>
<span class="story-review-centre-row__count">0 need attention</span>
<span class="story-review-centre-row__count">@Model.TotalAnalysedScenes.ToString("N0") scenes</span>
}
else
{
<strong class="story-review-status story-review-status--active">Resolve Characters first</strong>
<span class="story-review-centre-row__count">Locked until Characters are resolved</span>
}
</article>
@if (Model.AnalysisIssueCount > 0)
@ -96,7 +104,7 @@
<p>Some chapters or scenes reported analysis issues. Canonical chapters and scenes remain intact.</p>
</div>
<strong class="story-review-status story-review-status--warning">Needs attention</strong>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceReview" asp-route-batchId="@Model.BatchID">View issues</a>
<a class="btn btn-outline-primary btn-sm" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to book</a>
<span class="story-review-centre-row__count">@Model.AnalysisIssueCount.ToString("N0") need attention</span>
</article>
}
@ -126,9 +134,9 @@
: count > 0
? "story-review-status story-review-status--warning"
: "story-review-status story-review-status--ready";
var statusText = blocked ? "Blocked by character resolution" : status;
var statusText = blocked ? "Resolve Characters first" : status;
var countText = blocked
? "Resolve characters first"
? "Locked until Characters are resolved"
: count == 1
? "1 needs attention"
: $"{count:N0} need attention";