Add story intelligence review centre

This commit is contained in:
Nick Beckley 2026-08-28 16:07:35 +00:00
parent 90d8f65fcf
commit da955db9d1
22 changed files with 580 additions and 358 deletions

View File

@ -223,7 +223,13 @@ var tests = new (string Name, Action Test)[]
("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent), ("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent),
("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink), ("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink),
("Word manuscript unlink keeps Story Intelligence data untouched", WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched), ("Word manuscript unlink keeps Story Intelligence data untouched", WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched),
("Word manuscript cleanup remains relinkable and idempotent", WordManuscriptCleanupRemainsRelinkableAndIdempotent) ("Word manuscript cleanup remains relinkable and idempotent", WordManuscriptCleanupRemainsRelinkableAndIdempotent),
("Story Intelligence Review Centre route replaces linear review resume", StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume),
("Story Intelligence Review Centre counts only needs-attention categories", StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories),
("Story Intelligence review modules are independent after character gate", StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate),
("Story Intelligence review posts do not use success interstitials", StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials),
("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard),
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls)
}; };
foreach (var test in tests) foreach (var test in tests)
@ -232,6 +238,114 @@ foreach (var test in tests)
Console.WriteLine($"PASS {test.Name}"); Console.WriteLine($"PASS {test.Name}");
} }
static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
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.");
}
static void StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories()
{
var root = PlotLineRoot();
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var centre = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
var summary = File.ReadAllText(Path.Combine(root, "Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml"));
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(!summary.Contains("Locations identified", StringComparison.Ordinal), "Summary should not present total identified rows as review workload.");
}
static void StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var locationsAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceLocations", "[HttpGet(\"story-intelligence/assets\")]");
var assetsAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceAssets", "[HttpGet(\"story-intelligence/relationships\")]");
var relationshipsAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceRelationships", "[HttpPost(\"story-intelligence/cancel\")]");
var knowledgeAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceKnowledge", "[HttpPost(\"story-intelligence/knowledge\")]");
var narrativeAction = ExtractBetween(controller, "public async Task<IActionResult> StoryIntelligenceNarrative", "[HttpPost(\"story-intelligence/narrative\")]");
Assert(!assetsAction.Contains("LocationStageComplete", StringComparison.Ordinal), "Assets should not require completed location review.");
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.");
}
static void StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials()
{
var root = PlotLineRoot();
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var postBlock = ExtractBetween(controller, "[HttpPost(\"story-intelligence/characters\")]", "[HttpGet(\"story-intelligence/characters/complete\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/locations\")]", "[HttpGet(\"story-intelligence/locations/complete\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/assets\")]", "[HttpGet(\"story-intelligence/assets/complete\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/relationships\")]", "[HttpGet(\"story-intelligence/relationships/complete\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/knowledge\")]", "[HttpGet(\"story-intelligence/knowledge/complete\")]")
+ ExtractBetween(controller, "[HttpPost(\"story-intelligence/narrative\")]", "[HttpGet(\"story-intelligence/narrative/complete\")]");
Assert(postBlock.Contains("StoryIntelligenceReviewCentre", StringComparison.Ordinal), "Review posts should return to the centre.");
Assert(!postBlock.Contains("StoryIntelligenceCharacterComplete", StringComparison.Ordinal), "Character post should not target success interstitial.");
Assert(!postBlock.Contains("StoryIntelligenceLocationComplete", StringComparison.Ordinal), "Location post should not target success interstitial.");
Assert(!postBlock.Contains("StoryIntelligenceAssetComplete", StringComparison.Ordinal), "Asset post should not target success interstitial.");
Assert(!postBlock.Contains("StoryIntelligenceRelationshipComplete", StringComparison.Ordinal), "Relationship post should not target success interstitial.");
Assert(!postBlock.Contains("StoryIntelligenceKnowledgeComplete", StringComparison.Ordinal), "Knowledge post should not target success interstitial.");
Assert(!postBlock.Contains("StoryIntelligenceNarrativeComplete", StringComparison.Ordinal), "Narrative post should not target success interstitial.");
}
static void StoryIntelligenceReviewFormsShareBusySubmitGuard()
{
var root = PlotLineRoot();
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-review-actions.js"));
var reviewViews = new[]
{
"StoryIntelligenceCharacters.cshtml",
"StoryIntelligenceLocations.cshtml",
"StoryIntelligenceAssets.cshtml",
"StoryIntelligenceRelationships.cshtml",
"StoryIntelligenceKnowledge.cshtml",
"StoryIntelligenceNarrative.cshtml"
};
Assert(script.Contains("data-story-review-form", StringComparison.Ordinal), "Shared busy helper should bind review forms.");
Assert(script.Contains("form.dataset.submitting === \"true\"", StringComparison.Ordinal), "Shared busy helper should prevent double submit.");
Assert(script.Contains("aria-busy", StringComparison.Ordinal), "Busy state should be exposed accessibly.");
foreach (var viewName in reviewViews)
{
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName));
Assert(view.Contains("data-story-review-form", StringComparison.Ordinal), $"{viewName} should opt into the shared busy helper.");
Assert(view.Contains("story-review-actions.js", StringComparison.Ordinal), $"{viewName} should load the shared busy helper.");
}
}
static void StoryIntelligenceReviewCentreAddsNoAiCalls()
{
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("StartAsync", StringComparison.Ordinal) && !centreAction.Contains("StartBookAsync", StringComparison.Ordinal), "Review Centre should not queue AI work.");
Assert(!centreAction.Contains("EnsureCanQueueAsync", StringComparison.Ordinal), "Review Centre should not perform paid AI queue checks.");
Assert(!centreView.Contains("OpenAI", StringComparison.Ordinal) && !centreView.Contains("prompt", StringComparison.OrdinalIgnoreCase), "Review Centre view should not introduce prompt work.");
}
static string PlotLineRoot()
=> Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
static string ExtractBetween(string value, string start, string end)
=> Between(value, start, end);
static void AdminStoryIntelligenceResetReturnsToDiagnosticsWithMessages() static void AdminStoryIntelligenceResetReturnsToDiagnosticsWithMessages()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -115,7 +115,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return target.Route switch return target.Route switch
{ {
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }), 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.Narrative => RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }),
@ -211,6 +212,30 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
: View(model); : View(model);
} }
[HttpGet("story-intelligence/review-centre")]
public async Task<IActionResult> StoryIntelligenceReviewCentre(Guid batchId)
{
var model = await storyIntelligence.GetProgressAsync(batchId);
if (model is null)
{
return NotFound();
}
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
}
if (!model.AllCommitted)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
}
return !model.PipelineDashboard.CharacterStageComplete
? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId })
: View(model);
}
[HttpGet("story-intelligence/characters")] [HttpGet("story-intelligence/characters")]
public async Task<IActionResult> StoryIntelligenceCharacters(Guid batchId) public async Task<IActionResult> StoryIntelligenceCharacters(Guid batchId)
{ {
@ -225,8 +250,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
} }
return !model.AllCommitted if (!model.AllCommitted)
? RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }) {
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
}
return model.PipelineDashboard.CharacterStageComplete && model.CharacterReview.Candidates.Count == 0
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId })
: View(model); : View(model);
} }
@ -278,9 +308,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
} }
return !model.PipelineDashboard.LocationStageComplete return View(model);
? RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId })
: View(model);
} }
[HttpGet("story-intelligence/relationships")] [HttpGet("story-intelligence/relationships")]
@ -307,14 +335,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
} }
if (!model.PipelineDashboard.LocationStageComplete) return View(model);
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
return !model.PipelineDashboard.AssetStageComplete
? RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId })
: View(model);
} }
[HttpPost("story-intelligence/cancel")] [HttpPost("story-intelligence/cancel")]
@ -344,8 +365,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
if (result.Success && progress.AllCommitted) if (result.Success && progress.AllCommitted)
{ {
TempData["WriterWorkspaceMessage"] = $"{progress.CommittedChapterCount} chapter(s) and {progress.Chapters.Sum(chapter => chapter.ScenesCreated)} scene(s) created from your manuscript."; return RedirectToAction(progress.PipelineDashboard.CharacterStageComplete || progress.CharacterReview.Candidates.Count == 0
return RedirectToAction("Index", "Writer", new { projectId = progress.ProjectID }); ? nameof(StoryIntelligenceReviewCentre)
: nameof(StoryIntelligenceCharacters), new { batchId });
} }
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
@ -363,7 +385,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success && progress.AllCommitted return result.Success && progress.AllCommitted
? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) ? RedirectToAction(progress.PipelineDashboard.CharacterStageComplete || progress.CharacterReview.Candidates.Count == 0
? nameof(StoryIntelligenceReviewCentre)
: nameof(StoryIntelligenceCharacters), new { batchId })
: RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); : RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
} }
@ -379,7 +403,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceCharacterComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = form.BatchID });
} }
@ -402,7 +426,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceLocationComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID });
} }
@ -425,7 +449,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceAssetComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID });
} }
@ -448,7 +472,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceRelationshipComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID });
} }
@ -483,19 +507,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
} }
if (!model.PipelineDashboard.LocationStageComplete) return View(model);
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
if (!model.PipelineDashboard.AssetStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
}
return !model.PipelineDashboard.RelationshipStageComplete
? RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId })
: View(model);
} }
[HttpPost("story-intelligence/knowledge")] [HttpPost("story-intelligence/knowledge")]
@ -510,7 +522,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceKnowledgeComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID });
} }
@ -545,24 +557,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
} }
if (!model.PipelineDashboard.LocationStageComplete) return View(model);
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
if (!model.PipelineDashboard.AssetStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
}
if (!model.PipelineDashboard.RelationshipStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId });
}
return !model.PipelineDashboard.KnowledgeStageComplete
? RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId })
: View(model);
} }
[HttpPost("story-intelligence/narrative")] [HttpPost("story-intelligence/narrative")]
@ -577,7 +572,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceNarrativeComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = form.BatchID });
} }
@ -602,31 +597,6 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
} }
if (!progress.PipelineDashboard.LocationStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
if (!progress.PipelineDashboard.AssetStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
}
if (!progress.PipelineDashboard.RelationshipStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId });
}
if (!progress.PipelineDashboard.KnowledgeStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId });
}
if (!progress.PipelineDashboard.NarrativeStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId });
}
var model = await storyIntelligence.GetCompletionAsync(batchId); var model = await storyIntelligence.GetCompletionAsync(batchId);
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }

View File

@ -1385,10 +1385,10 @@ public sealed class BookService(
HasCanonicalSceneSources = hasCanonicalSceneSources, HasCanonicalSceneSources = hasCanonicalSceneSources,
CanStart = false, CanStart = false,
StatusLabel = pipeline.IsComplete ? "Complete" : pipeline.Status, StatusLabel = pipeline.IsComplete ? "Complete" : pipeline.Status,
ActionLabel = pipeline.IsComplete ? "View Story Intelligence Summary" : "Continue Story Intelligence", ActionLabel = "Open Review Centre",
Description = pipeline.IsComplete Description = pipeline.IsComplete
? "All currently available Story Intelligence stages are complete." ? "Story Intelligence review is saved for this book."
: "Story Intelligence has saved progress for this book." : "Story Intelligence has saved review categories for this book."
}; };
} }

View File

@ -1069,19 +1069,9 @@ public sealed class OnboardingStoryIntelligenceService(
await batchStore.SaveAsync(batch); await batchStore.SaveAsync(batch);
var route = narrativeStageComplete var route = characterStageComplete
? StoryIntelligenceResumeRoutes.Complete ? StoryIntelligenceResumeRoutes.ReviewCentre
: knowledgeStageComplete : state.CurrentStage switch
? StoryIntelligenceResumeRoutes.Narrative
: relationshipStageComplete
? StoryIntelligenceResumeRoutes.Knowledge
: assetStageComplete
? StoryIntelligenceResumeRoutes.Relationships
: locationStageComplete
? StoryIntelligenceResumeRoutes.Assets
: characterStageComplete
? StoryIntelligenceResumeRoutes.Locations
: state.CurrentStage switch
{ {
StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships, StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships, StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
@ -1540,6 +1530,7 @@ public sealed class StoryIntelligenceNarrativeImportBatchResult
public static class StoryIntelligenceResumeRoutes public static class StoryIntelligenceResumeRoutes
{ {
public const string SceneReview = "SceneReview"; public const string SceneReview = "SceneReview";
public const string ReviewCentre = "ReviewCentre";
public const string Characters = "Characters"; public const string Characters = "Characters";
public const string Locations = "Locations"; public const string Locations = "Locations";
public const string Assets = "Assets"; public const string Assets = "Assets";

View File

@ -194,11 +194,11 @@ public sealed class StoryIntelligenceService(
return new StoryIntelligenceDashboardViewModel return new StoryIntelligenceDashboardViewModel
{ {
ShouldShow = true, ShouldShow = true,
Title = nextPipeline.IsComplete ? "Story Intelligence Complete" : "Continue Story Intelligence", Title = "Story Intelligence Review Centre",
Description = nextPipeline.IsComplete Description = nextPipeline.IsComplete
? $"{nextPipeline.BookTitle} has completed the currently available Story Intelligence stages." ? $"{nextPipeline.BookTitle} has saved Story Intelligence review."
: $"{nextPipeline.BookTitle} is waiting at the next Story Intelligence review stage.", : $"{nextPipeline.BookTitle} has saved Story Intelligence review categories.",
ButtonText = nextPipeline.IsComplete ? "View summary" : "Continue Story Intelligence", ButtonText = "Open Review Centre",
PipelineBooks = pipelineBooks PipelineBooks = pipelineBooks
}; };
} }
@ -264,21 +264,21 @@ public sealed class StoryIntelligenceService(
{ {
if (state.IsComplete) if (state.IsComplete)
{ {
return "Story Intelligence Complete"; return "Story Intelligence Review Saved";
} }
return state.CurrentReviewStage switch return state.CurrentReviewStage switch
{ {
StoryIntelligencePipelineStages.KnowledgeReview => "Waiting for Knowledge Review", StoryIntelligencePipelineStages.KnowledgeReview => "Review Centre",
StoryIntelligencePipelineStages.RelationshipReview => "Waiting for Relationship Review", StoryIntelligencePipelineStages.RelationshipReview => "Review Centre",
StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review", StoryIntelligencePipelineStages.AssetReview => "Review Centre",
StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review", StoryIntelligencePipelineStages.LocationReview => "Review Centre",
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review", StoryIntelligencePipelineStages.CharacterReview => "Character Resolution Needed",
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review", StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
_ when StoryIntelligenceReadyForKnowledge(state) => "Waiting for Knowledge Review", _ when StoryIntelligenceReadyForKnowledge(state) => "Review Centre",
_ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review", _ when StoryIntelligenceReadyForRelationships(state) => "Review Centre",
_ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review", _ when StoryIntelligenceReadyForAssets(state) => "Review Centre",
_ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review", _ when StoryIntelligenceReadyForLocations(state) => "Review Centre",
_ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase) _ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase)
? "Story Intelligence In Progress" ? "Story Intelligence In Progress"
: "Story Intelligence Needs Review" : "Story Intelligence Needs Review"
@ -287,20 +287,20 @@ public sealed class StoryIntelligenceService(
private static string PipelineDescription(StoryIntelligenceBookPipelineState state) private static string PipelineDescription(StoryIntelligenceBookPipelineState state)
=> state.IsComplete => state.IsComplete
? "All currently available Story Intelligence stages are complete." ? "Story Intelligence review is saved."
: state.CurrentReviewStage switch : state.CurrentReviewStage switch
{ {
StoryIntelligencePipelineStages.KnowledgeReview => "Review detected knowledge changes and decide what to create or link.", StoryIntelligencePipelineStages.KnowledgeReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.", StoryIntelligencePipelineStages.RelationshipReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.", StoryIntelligencePipelineStages.AssetReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.", StoryIntelligencePipelineStages.LocationReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.CharacterReview => "Review detected characters and decide what to create or link.", StoryIntelligencePipelineStages.CharacterReview => "Resolve character identities before reviewing dependent story intelligence.",
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.", StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.",
_ when StoryIntelligenceReadyForKnowledge(state) => "Review detected knowledge changes and decide what to create or link.", _ when StoryIntelligenceReadyForKnowledge(state) => "Open the Review Centre and choose which saved category to handle.",
_ when StoryIntelligenceReadyForRelationships(state) => "Review detected relationships and decide what to create or link.", _ when StoryIntelligenceReadyForRelationships(state) => "Open the Review Centre and choose which saved category to handle.",
_ when StoryIntelligenceReadyForAssets(state) => "Review detected assets and decide what to create or link.", _ when StoryIntelligenceReadyForAssets(state) => "Open the Review Centre and choose which saved category to handle.",
_ when StoryIntelligenceReadyForLocations(state) => "Review detected locations and decide what to create or link.", _ when StoryIntelligenceReadyForLocations(state) => "Open the Review Centre and choose which saved category to handle.",
_ => "Continue from the next Story Intelligence stage." _ => "Open the Review Centre and choose which saved category to handle."
}; };
private static bool StoryIntelligenceReadyForKnowledge(StoryIntelligenceBookPipelineState state) private static bool StoryIntelligenceReadyForKnowledge(StoryIntelligenceBookPipelineState state)

View File

@ -180,6 +180,21 @@ public sealed class StoryIntelligenceProgressViewModel
public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0; public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0;
public bool HasKnowledgeToReview => KnowledgeReview.Candidates.Count > 0; public bool HasKnowledgeToReview => KnowledgeReview.Candidates.Count > 0;
public bool HasNarrativeToReview => NarrativeReview.Candidates.Count > 0; public bool HasNarrativeToReview => NarrativeReview.Candidates.Count > 0;
public int CharacterNeedsAttentionCount => CharacterReview.Candidates.Count;
public int LocationNeedsAttentionCount => LocationReview.Candidates.Count;
public int AssetNeedsAttentionCount => AssetReview.Candidates.Count;
public int RelationshipNeedsAttentionCount => RelationshipReview.Candidates.Count;
public int KnowledgeNeedsAttentionCount => KnowledgeReview.Candidates.Count;
public int NarrativeNeedsAttentionCount => NarrativeReview.Candidates.Count;
public int AnalysisIssueCount => Chapters.Count(chapter => chapter.IsFailed) + Chapters.Sum(chapter => chapter.FailedScenes ?? 0);
public bool CharacterGateSatisfied => PipelineDashboard.CharacterStageComplete || CharacterNeedsAttentionCount == 0;
public int TotalReviewNeedsAttention => CharacterNeedsAttentionCount
+ LocationNeedsAttentionCount
+ AssetNeedsAttentionCount
+ RelationshipNeedsAttentionCount
+ KnowledgeNeedsAttentionCount
+ NarrativeNeedsAttentionCount
+ AnalysisIssueCount;
} }
public sealed class StoryIntelligencePipelineDashboardViewModel public sealed class StoryIntelligencePipelineDashboardViewModel

View File

@ -209,22 +209,22 @@
{ {
if (pipeline.IsComplete) if (pipeline.IsComplete)
{ {
return "All currently available Story Intelligence stages are complete."; return "Story Intelligence review is saved for this book.";
} }
return pipeline.CurrentReviewStage switch return pipeline.CurrentReviewStage switch
{ {
StoryIntelligencePipelineStages.NarrativeReview => "Review detected Plot Lines and Threads before adding them to the story database.", StoryIntelligencePipelineStages.NarrativeReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.", StoryIntelligencePipelineStages.RelationshipReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.", StoryIntelligencePipelineStages.AssetReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.", StoryIntelligencePipelineStages.LocationReview => "Open the Review Centre and choose which saved category to handle.",
StoryIntelligencePipelineStages.CharacterReview => "Scene creation is complete. Review detected characters to continue building the story database.", 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.SceneReview => "Review prepared scenes before creating them in PlotDirector.",
_ when StoryIntelligenceReadyForNarrative(pipeline) => "Review detected Plot Lines and Threads before adding them to the story database.", _ when StoryIntelligenceReadyForNarrative(pipeline) => "Open the Review Centre and choose which saved category to handle.",
_ when StoryIntelligenceReadyForRelationships(pipeline) => "Review detected relationships and decide what to create or link.", _ when StoryIntelligenceReadyForRelationships(pipeline) => "Open the Review Centre and choose which saved category to handle.",
_ when StoryIntelligenceReadyForAssets(pipeline) => "Review detected assets and decide what to create or link.", _ when StoryIntelligenceReadyForAssets(pipeline) => "Open the Review Centre and choose which saved category to handle.",
_ when StoryIntelligenceReadyForLocations(pipeline) => "Review detected locations and decide what to create or link.", _ when StoryIntelligenceReadyForLocations(pipeline) => "Open the Review Centre and choose which saved category to handle.",
_ => "PlotDirector will resume from the next Story Intelligence stage." _ => "PlotDirector will open the Review Centre for this book."
}; };
} }

View File

@ -25,9 +25,9 @@
{ {
<section class="onboarding-story-card"> <section class="onboarding-story-card">
<h2>Analysis has already started</h2> <h2>Analysis has already started</h2>
<p>You can continue reviewing progress for the approved chapters.</p> <p>You can return to progress or open the Review Centre when analysis is ready.</p>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-primary" asp-action="StoryIntelligenceReview" asp-route-batchId="@Model.ExistingBatchID">Continue pipeline</a> <a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.ExistingBatchID">View progress</a>
<form asp-action="ScanManuscript" method="post"> <form asp-action="ScanManuscript" method="post">
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button> <button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
</form> </form>
@ -55,7 +55,7 @@
<div> <div>
<span>Review</span> <span>Review</span>
<strong>You approve creation</strong> <strong>You approve creation</strong>
<p>Scenes are created only after the analysis completes and you choose Create scenes.</p> <p>After analysis, unresolved character identities are handled first, then optional review categories are available later.</p>
</div> </div>
</div> </div>
@ -72,9 +72,9 @@
<div class="onboarding-analysis-flow"> <div class="onboarding-analysis-flow">
<div><span>1</span><strong>Analyse chapters</strong></div> <div><span>1</span><strong>Analyse chapters</strong></div>
<div><span>2</span><strong>Review scenes</strong></div> <div><span>2</span><strong>Create scene intelligence</strong></div>
<div><span>3</span><strong>Review story entities</strong></div> <div><span>3</span><strong>Resolve characters</strong></div>
<div><span>4</span><strong>Build database</strong></div> <div><span>4</span><strong>Open Review Centre</strong></div>
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">

View File

@ -9,9 +9,8 @@
<div class="onboarding-copy story-review-heading"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p> <p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p> <p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 4 of 5</p>
<h1 id="story-asset-title">Review Assets</h1> <h1 id="story-asset-title">Review Assets</h1>
<p>Approve the important objects PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p> <p>Review important object suggestions that need a create, link, alias, or ignore decision.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -33,16 +32,16 @@
<p class="mb-0">Assets are already up to date, or there were no story-significant asset suggestions ready to import.</p> <p class="mb-0">Assets are already up to date, or there were no story-significant asset suggestions ready to import.</p>
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Relationships</strong><span>Next</span></article> @FutureStage("Relationships")
@FutureStage("Review Knowledge") @FutureStage("Knowledge")
@FutureStage("Review Continuity") @FutureStage("Plot Lines & Threads")
@FutureStage("Timeline Events") @FutureStage("Scene Metrics")
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<form asp-action="ImportStoryIntelligenceAssets" method="post"> <form asp-action="ImportStoryIntelligenceAssets" method="post" data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit">Continue</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving assets...">Mark reviewed</button>
</form> </form>
</div> </div>
} }
@ -56,7 +55,7 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="collapse">Collapse all</button>
</div> </div>
<form asp-action="ImportStoryIntelligenceAssets" method="post" data-asset-review-form> <form asp-action="ImportStoryIntelligenceAssets" method="post" data-asset-review-form data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid"> <div class="story-character-card-grid">
@for (var i = 0; i < Model.AssetReview.Candidates.Count; i++) @for (var i = 0; i < Model.AssetReview.Candidates.Count; i++)
@ -145,8 +144,8 @@
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit">Create assets</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving assets...">Save asset decisions</button>
</div> </div>
</form> </form>
} }
@ -222,11 +221,12 @@
cards().forEach(updateCard); cards().forEach(updateCard);
})(); })();
</script> </script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
} }
@functions { @functions {
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 Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label) private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>"); => new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Available from Review Centre</span></article>");
} }

View File

@ -9,9 +9,8 @@
<div class="onboarding-copy story-review-heading"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p> <p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p> <p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 2 of 4</p>
<h1 id="story-character-title">Review Characters</h1> <h1 id="story-character-title">Review Characters</h1>
<p>Approve the characters PlotDirector should create or link from your imported scenes. You stay in control of every character record.</p> <p>Resolve character identity questions before reviewing the rest of the story intelligence.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -39,14 +38,13 @@
<p class="mb-0">Characters are already up to date, or there were no character suggestions ready to import.</p> <p class="mb-0">Characters are already up to date, or there were no character suggestions ready to import.</p>
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
@FutureStage("Review Locations") @FutureStage("Locations")
@FutureStage("Review Assets") @FutureStage("Assets")
@FutureStage("Review Relationships") @FutureStage("Relationships")
@FutureStage("Review Knowledge") @FutureStage("Knowledge")
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReview" asp-route-batchId="@Model.BatchID">Back to scenes</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Continue</a>
</div> </div>
} }
else else
@ -59,7 +57,7 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button>
</div> </div>
<form asp-action="ImportStoryIntelligenceCharacters" method="post" data-character-review-form> <form asp-action="ImportStoryIntelligenceCharacters" method="post" data-character-review-form data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid"> <div class="story-character-card-grid">
@for (var i = 0; i < Model.CharacterReview.Candidates.Count; i++) @for (var i = 0; i < Model.CharacterReview.Candidates.Count; i++)
@ -140,6 +138,7 @@
<div data-character-alias-panel hidden> <div data-character-alias-panel hidden>
<label class="form-label" for="character-alias-target-@i">Alias target</label> <label class="form-label" for="character-alias-target-@i">Alias target</label>
<p class="form-text">Treats both identities as the same physical character and keeps this manuscript name as an alias/reference.</p>
<select id="character-alias-target-@i" class="form-select" name="Characters[@i].AliasTargetCharacterID" data-character-alias-target> <select id="character-alias-target-@i" class="form-select" name="Characters[@i].AliasTargetCharacterID" data-character-alias-target>
<option value="">Choose character...</option> <option value="">Choose character...</option>
@foreach (var target in Model.CharacterReview.AliasTargetOptions) @foreach (var target in Model.CharacterReview.AliasTargetOptions)
@ -154,8 +153,8 @@
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReview" asp-route-batchId="@Model.BatchID">Back to scenes</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit">Create characters</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving characters...">Save character decisions</button>
</div> </div>
</form> </form>
} }
@ -237,6 +236,7 @@
cards().forEach(updateCard); cards().forEach(updateCard);
})(); })();
</script> </script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
} }
@functions { @functions {
@ -246,5 +246,5 @@
=> string.Equals(candidate.DefaultAction, action, StringComparison.OrdinalIgnoreCase); => string.Equals(candidate.DefaultAction, action, StringComparison.OrdinalIgnoreCase);
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label) private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>"); => new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Available from Review Centre</span></article>");
} }

View File

@ -21,9 +21,9 @@
{ {
<section class="onboarding-story-card"> <section class="onboarding-story-card">
<h2>Analysis has already started</h2> <h2>Analysis has already started</h2>
<p>You can continue reviewing progress for the approved chapters.</p> <p>You can return to progress or open the Review Centre when analysis is ready.</p>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-primary" asp-action="StoryIntelligenceReview" asp-route-batchId="@Model.ExistingBatchID">Continue pipeline</a> <a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.ExistingBatchID">View progress</a>
<form asp-action="ScanManuscript" method="post"> <form asp-action="ScanManuscript" method="post">
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button> <button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
</form> </form>

View File

@ -9,9 +9,8 @@
<div class="onboarding-copy story-review-heading"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p> <p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p> <p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 6 of 6</p>
<h1 id="story-knowledge-title">Review Knowledge</h1> <h1 id="story-knowledge-title">Review Knowledge</h1>
<p>Approve the facts, discoveries, beliefs and suspicions PlotDirector should add to the existing Knowledge Explorer.</p> <p>Review facts, discoveries, beliefs, and suspicions that need an author decision.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -33,14 +32,14 @@
<p class="mb-0">Knowledge is already up to date, or there were no knowledge changes ready to import from the stored scene analysis.</p> <p class="mb-0">Knowledge is already up to date, or there were no knowledge changes ready to import from the stored scene analysis.</p>
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
@FutureStage("Review Continuity") @FutureStage("Plot Lines & Threads")
@FutureStage("Timeline Events") @FutureStage("Scene Metrics")
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Back to relationships</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<form asp-action="ImportStoryIntelligenceKnowledge" method="post"> <form asp-action="ImportStoryIntelligenceKnowledge" method="post" data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit">Continue</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving knowledge...">Mark reviewed</button>
</form> </form>
</div> </div>
} }
@ -54,7 +53,7 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="collapse">Collapse all</button>
</div> </div>
<form asp-action="ImportStoryIntelligenceKnowledge" method="post" data-knowledge-review-form> <form asp-action="ImportStoryIntelligenceKnowledge" method="post" data-knowledge-review-form data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid"> <div class="story-character-card-grid">
@for (var i = 0; i < Model.KnowledgeReview.Candidates.Count; i++) @for (var i = 0; i < Model.KnowledgeReview.Candidates.Count; i++)
@ -149,8 +148,8 @@
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Back to relationships</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit">Create knowledge</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving knowledge...">Save knowledge decisions</button>
</div> </div>
</form> </form>
} }
@ -226,11 +225,12 @@
cards().forEach(updateCard); cards().forEach(updateCard);
})(); })();
</script> </script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
} }
@functions { @functions {
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 Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label) private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>"); => new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Available from Review Centre</span></article>");
} }

View File

@ -9,9 +9,8 @@
<div class="onboarding-copy story-review-heading"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p> <p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p> <p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 3 of 4</p>
<h1 id="story-location-title">Review Locations</h1> <h1 id="story-location-title">Review Locations</h1>
<p>Approve the places PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p> <p>Review place suggestions that need a create, link, alias, or ignore decision.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -33,14 +32,13 @@
<p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p> <p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p>
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Assets</strong><span>Next</span></article> @FutureStage("Assets")
@FutureStage("Review Relationships") @FutureStage("Relationships")
@FutureStage("Review Knowledge") @FutureStage("Knowledge")
@FutureStage("Review Continuity") @FutureStage("Plot Lines & Threads")
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Continue</a>
</div> </div>
} }
else else
@ -53,7 +51,7 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
</div> </div>
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form> <form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid"> <div class="story-character-card-grid">
@for (var i = 0; i < Model.LocationReview.Candidates.Count; i++) @for (var i = 0; i < Model.LocationReview.Candidates.Count; i++)
@ -134,8 +132,8 @@
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit">Create locations</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving locations...">Save location decisions</button>
</div> </div>
</form> </form>
} }
@ -211,11 +209,12 @@
cards().forEach(updateCard); cards().forEach(updateCard);
})(); })();
</script> </script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
} }
@functions { @functions {
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 Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label) private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>"); => new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Available from Review Centre</span></article>");
} }

View File

@ -9,9 +9,8 @@
<div class="onboarding-copy story-review-heading"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p> <p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p> <p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 7 of 7</p>
<h1 id="story-narrative-title">Review Plot Lines & Threads</h1> <h1 id="story-narrative-title">Review Plot Lines & Threads</h1>
<p>Approve recurring narrative arcs and useful thread candidates before PlotDirector adds them to the canonical story database.</p> <p>Review recurring narrative arcs and thread candidates from the discovered hierarchy.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -31,15 +30,15 @@
<strong>No Plot Line or Thread candidates are waiting.</strong> <strong>No Plot Line or Thread candidates are waiting.</strong>
<p class="mb-0">PlotDirector did not find recurring narrative candidates that require approval, or this review has already been completed.</p> <p class="mb-0">PlotDirector did not find recurring narrative candidates that require approval, or this review has already been completed.</p>
</div> </div>
<form asp-action="ImportStoryIntelligenceNarrative" method="post" class="onboarding-actions"> <form asp-action="ImportStoryIntelligenceNarrative" method="post" class="onboarding-actions" data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID">Back to knowledge</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit">Complete narrative review</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving plot lines...">Mark reviewed</button>
</form> </form>
} }
else else
{ {
<form asp-action="ImportStoryIntelligenceNarrative" method="post"> <form asp-action="ImportStoryIntelligenceNarrative" method="post" data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid"> <div class="story-character-card-grid">
@for (var i = 0; i < Model.NarrativeReview.Candidates.Count; i++) @for (var i = 0; i < Model.NarrativeReview.Candidates.Count; i++)
@ -145,10 +144,14 @@
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID">Back to knowledge</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit">Apply Plot Lines & Threads</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving plot lines...">Save Plot Lines & Threads</button>
</div> </div>
</form> </form>
} }
</div> </div>
</section> </section>
@section Scripts {
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
}

View File

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

View File

@ -9,9 +9,8 @@
<div class="onboarding-copy story-review-heading"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p> <p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p> <p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 5 of 5</p>
<h1 id="story-relationship-title">Review Relationships</h1> <h1 id="story-relationship-title">Review Relationships</h1>
<p>Approve the character relationships PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p> <p>Review relationship suggestions by character pair and decide what should become canonical.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -33,15 +32,15 @@
<p class="mb-0">Relationships are already up to date, or there were no relationship suggestions ready to import from the stored scene analysis.</p> <p class="mb-0">Relationships are already up to date, or there were no relationship suggestions ready to import from the stored scene analysis.</p>
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
@FutureStage("Review Knowledge") @FutureStage("Knowledge")
@FutureStage("Review Continuity") @FutureStage("Plot Lines & Threads")
@FutureStage("Timeline Events") @FutureStage("Scene Metrics")
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<form asp-action="ImportStoryIntelligenceRelationships" method="post"> <form asp-action="ImportStoryIntelligenceRelationships" method="post" data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit">Continue</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving relationships...">Mark reviewed</button>
</form> </form>
</div> </div>
} }
@ -55,7 +54,7 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="collapse">Collapse all</button>
</div> </div>
<form asp-action="ImportStoryIntelligenceRelationships" method="post" data-relationship-review-form> <form asp-action="ImportStoryIntelligenceRelationships" method="post" data-relationship-review-form data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" /> <input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid"> <div class="story-character-card-grid">
@for (var i = 0; i < Model.RelationshipReview.Candidates.Count; i++) @for (var i = 0; i < Model.RelationshipReview.Candidates.Count; i++)
@ -160,8 +159,8 @@
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<button class="btn btn-primary" type="submit">Create relationships</button> <button class="btn btn-primary" type="submit" data-busy-label="Saving relationships...">Save relationship decisions</button>
</div> </div>
</form> </form>
} }
@ -237,11 +236,12 @@
cards().forEach(updateCard); cards().forEach(updateCard);
})(); })();
</script> </script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
} }
@functions { @functions {
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 Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label) private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>"); => new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Available from Review Centre</span></article>");
} }

View File

@ -2,7 +2,7 @@
@using System.Security.Claims @using System.Security.Claims
@inject IConfiguration Configuration @inject IConfiguration Configuration
@{ @{
ViewData["Title"] = "Review scenes"; ViewData["Title"] = "Create scene intelligence";
var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? []; var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
var userEmail = User.FindFirstValue(ClaimTypes.Email); var userEmail = User.FindFirstValue(ClaimTypes.Email);
var isAdmin = !string.IsNullOrWhiteSpace(userEmail) var isAdmin = !string.IsNullOrWhiteSpace(userEmail)
@ -15,11 +15,10 @@
<div class="onboarding-panel onboarding-review-panel story-review-page"> <div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Scenes" })" /> <partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Scenes" })" />
<div class="onboarding-copy story-review-heading"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p> <p class="eyebrow">Phase 2</p>
<p class="story-review-heading__phase">Review Story Intelligence</p> <p class="story-review-heading__phase">Create Scene Intelligence</p>
<p class="story-review-heading__stage">Stage 1 of 4</p> <h1 id="story-review-title">Create Scene Intelligence</h1>
<h1 id="story-review-title">Review Scenes</h1> <p>PlotDirector has finished reading your manuscript. Create the stored scene intelligence before opening the Review Centre.</p>
<p>PlotDirector has finished reading your manuscript. Review the detected scenes before creating them in your story database.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -32,7 +31,7 @@
} }
<section class="alert alert-info"> <section class="alert alert-info">
Scene creation runs first. Character import is reviewed separately and only creates or links approved characters. Locations, assets, relationships and knowledge remain for later Story Intelligence phases. 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>
<section class="story-review-summary" aria-label="Analysis summary"> <section class="story-review-summary" aria-label="Analysis summary">
@ -186,20 +185,26 @@
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.BatchID">Back</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.BatchID">Back</a>
@if (Model.HasReadyScenesToCreate) @if (Model.HasReadyScenesToCreate)
{ {
<form asp-action="CommitAllStoryIntelligence" method="post"> <form asp-action="CommitAllStoryIntelligence" method="post" data-story-review-form>
<input type="hidden" name="batchId" value="@Model.BatchID" /> <input type="hidden" name="batchId" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit">Create PlotDirector Scenes</button> <button class="btn btn-primary" type="submit" data-busy-label="Creating scene intelligence...">Create scene intelligence</button>
</form> </form>
} }
else if (Model.AllCommitted) else if (Model.AllCommitted)
{ {
<a class="btn btn-primary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Review characters</a> <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>
</div> </div>
</section> </section>
@section Scripts {
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
}
@functions { @functions {
private static bool NeedsReview(StoryIntelligenceOnboardingChapterViewModel chapter) private static bool NeedsReview(StoryIntelligenceOnboardingChapterViewModel chapter)
=> chapter.IsFailed => chapter.IsFailed

View File

@ -0,0 +1,152 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Story Intelligence Review Centre";
}
<section class="onboarding-shell" aria-labelledby="story-review-centre-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Centre" })" />
<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>
</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>
}
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" />
<section class="story-review-centre-list" aria-label="Story Intelligence review categories">
@ReviewModule(
"Characters",
Model.CharacterNeedsAttentionCount,
Model.PipelineDashboard.CharacterStageComplete ? "Resolved" : "Needs attention",
"Resolve identity questions before reviewing dependent story intelligence.",
"Review characters",
"StoryIntelligenceCharacters",
false)
@ReviewModule(
"Locations",
Model.LocationNeedsAttentionCount,
Model.PipelineDashboard.LocationStageComplete ? "Reviewed" : AttentionLabel(Model.LocationNeedsAttentionCount),
"Places found in the manuscript that may be worth creating, linking, or ignoring.",
"Review locations",
"StoryIntelligenceLocations",
!Model.CharacterGateSatisfied)
@ReviewModule(
"Assets",
Model.AssetNeedsAttentionCount,
Model.PipelineDashboard.AssetStageComplete ? "Reviewed" : AttentionLabel(Model.AssetNeedsAttentionCount),
"Important objects and possessions detected from generic evidence such as recurrence, ownership, and scene prominence.",
"Review assets",
"StoryIntelligenceAssets",
!Model.CharacterGateSatisfied)
@ReviewModule(
"Relationships",
Model.RelationshipNeedsAttentionCount,
Model.PipelineDashboard.RelationshipStageComplete ? "Reviewed" : AttentionLabel(Model.RelationshipNeedsAttentionCount),
"Relationship candidates grouped by the canonical character identities involved.",
"Review relationships",
"StoryIntelligenceRelationships",
!Model.CharacterGateSatisfied)
@ReviewModule(
"Knowledge",
Model.KnowledgeNeedsAttentionCount,
Model.PipelineDashboard.KnowledgeStageComplete ? "Reviewed" : AttentionLabel(Model.KnowledgeNeedsAttentionCount),
"Facts, discoveries, beliefs, and suspicions that need a create, link, merge, or ignore decision.",
"Review knowledge",
"StoryIntelligenceKnowledge",
!Model.CharacterGateSatisfied)
@ReviewModule(
"Plot Lines & Threads",
Model.NarrativeNeedsAttentionCount,
Model.PipelineDashboard.NarrativeStageComplete ? "Reviewed" : AttentionLabel(Model.NarrativeNeedsAttentionCount),
"Recurring arcs and threads organised by the narrative hierarchy discovered from scene evidence.",
"Review plot lines",
"StoryIntelligenceNarrative",
!Model.CharacterGateSatisfied)
<article class="story-review-centre-row">
<div>
<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>
<strong class="story-review-status story-review-status--ready">Available</strong>
<span class="story-review-centre-row__count">0 need attention</span>
</article>
@if (Model.AnalysisIssueCount > 0)
{
<article class="story-review-centre-row">
<div>
<h2>Analysis Issues</h2>
<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>
<span class="story-review-centre-row__count">@Model.AnalysisIssueCount.ToString("N0") need attention</span>
</article>
}
</section>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to book</a>
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open writer workspace</a>
</div>
</div>
</section>
@functions {
private string AttentionLabel(int count) => count == 0 ? "No outstanding decisions" : "Needs attention";
private Microsoft.AspNetCore.Html.IHtmlContent ReviewModule(
string title,
int count,
string status,
string description,
string actionLabel,
string action,
bool blocked)
{
var statusClass = blocked
? "story-review-status story-review-status--active"
: 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 countText = blocked
? "Resolve characters first"
: count == 1
? "1 needs attention"
: $"{count:N0} need attention";
var disabled = blocked ? " disabled aria-disabled=\"true\"" : string.Empty;
var href = blocked
? "#"
: Url.Action(action, "Onboarding", new { batchId = Model.BatchID }) ?? "#";
return new Microsoft.AspNetCore.Html.HtmlString($$"""
<article class="story-review-centre-row">
<div>
<h2>{{System.Net.WebUtility.HtmlEncode(title)}}</h2>
<p>{{System.Net.WebUtility.HtmlEncode(description)}}</p>
</div>
<strong class="{{statusClass}}">{{System.Net.WebUtility.HtmlEncode(statusText)}}</strong>
<a class="btn btn-outline-primary btn-sm{{(blocked ? " disabled" : string.Empty)}}" href="{{href}}"{{disabled}}>{{System.Net.WebUtility.HtmlEncode(actionLabel)}}</a>
<span class="story-review-centre-row__count">{{System.Net.WebUtility.HtmlEncode(countText)}}</span>
</article>
""");
}
}

View File

@ -1,130 +1,67 @@
@model StoryIntelligencePipelineHeaderViewModel @model StoryIntelligencePipelineHeaderViewModel
@{ @{
var reviewStages = new[]
{
new PipelineStage("Review Scenes", "Scenes", true),
new PipelineStage("Review Characters", "Characters", true),
new PipelineStage("Review Locations", "Locations", true),
new PipelineStage("Review Assets", "Assets", true),
new PipelineStage("Review Relationships", "Relationships", true),
new PipelineStage("Review Knowledge", "Knowledge", true)
};
var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList();
var currentReviewIndex = implementedReviewStages.FindIndex(stage => string.Equals(stage.StageName, Model.CurrentStage, StringComparison.OrdinalIgnoreCase));
var isReviewPhase = currentReviewIndex >= 0;
var isComplete = string.Equals(Model.CurrentStage, "Complete", StringComparison.OrdinalIgnoreCase);
var isAnalyse = string.Equals(Model.CurrentStage, "Analyse Manuscript", StringComparison.OrdinalIgnoreCase); var isAnalyse = string.Equals(Model.CurrentStage, "Analyse Manuscript", StringComparison.OrdinalIgnoreCase);
var activePhase = isComplete ? 4 : isReviewPhase ? 3 : isAnalyse ? 2 : 1; var isSceneImport = string.Equals(Model.CurrentStage, "Review Scenes", StringComparison.OrdinalIgnoreCase);
var progressPercent = isReviewPhase var isReviewCentre = string.Equals(Model.CurrentStage, "Review Centre", StringComparison.OrdinalIgnoreCase);
? Math.Clamp(((currentReviewIndex + 1) * 100) / implementedReviewStages.Count, 0, 100) var phase = isAnalyse ? 2 : isSceneImport ? 2 : 3;
: isComplete ? 100 : 100; var progressPercent = isAnalyse ? 50 : isSceneImport ? 75 : 100;
var currentStageNumber = isReviewPhase ? currentReviewIndex + 1 : isComplete ? implementedReviewStages.Count : 0;
} }
<section class="story-pipeline-header" aria-label="Story Intelligence pipeline"> <section class="story-pipeline-header" aria-label="Story Intelligence progress">
<div class="story-pipeline-current"> <div class="story-pipeline-current">
<p class="eyebrow">Phase @activePhase</p> <p class="eyebrow">Phase @phase</p>
<h2>@CurrentPhaseTitle(activePhase)</h2> <h2>@CurrentPhaseTitle()</h2>
@if (isReviewPhase) <p>@CurrentPhaseDescription()</p>
{
<p>Stage @currentStageNumber of @implementedReviewStages.Count</p>
}
else if (isComplete)
{
<p>Final import and completion</p>
}
else if (isAnalyse)
{
<p>Story Intelligence Analysis</p>
}
else
{
<p>Prepare the manuscript for analysis</p>
}
</div> </div>
<div class="onboarding-progress story-pipeline-progress" aria-label="@CurrentPhaseTitle(activePhase) progress"> <div class="onboarding-progress story-pipeline-progress" aria-label="@CurrentPhaseTitle() progress">
<span>@ProgressLabel(activePhase, Model.CurrentStage)</span> <span>@ProgressLabel()</span>
<div class="onboarding-progress-track"> <div class="onboarding-progress-track">
<span style="width:@progressPercent%"></span> <span style="width:@progressPercent%"></span>
</div> </div>
</div> </div>
<ol class="story-pipeline-phase-list"> <ol class="story-pipeline-phase-list">
<li class="@(activePhase > 1 ? "is-complete" : activePhase == 1 ? "is-current" : "is-upcoming")"> <li class="is-complete">
<strong>Prepare Manuscript</strong> <strong>Core Import</strong>
<span>7 completed steps</span> <span>Book structure created from Word</span>
<ul>
<li>Welcome</li>
<li>Writing Preferences</li>
<li>Project</li>
<li>Book</li>
<li>Connect Word</li>
<li>Scan Manuscript</li>
<li>Review Chapters</li>
</ul>
</li> </li>
<li class="@(activePhase > 2 ? "is-complete" : activePhase == 2 ? "is-current" : "is-upcoming")"> <li class="@(isAnalyse || isSceneImport ? "is-current" : "is-complete")">
<strong>Analyse Manuscript</strong> <strong>Analyse Manuscript</strong>
<span>Story Intelligence Analysis</span> <span>Scene intelligence saved to PlotDirector</span>
</li> </li>
<li class="@(activePhase > 3 ? "is-complete" : activePhase == 3 ? "is-current" : "is-upcoming")"> <li class="@(!isAnalyse && !isSceneImport ? "is-current" : "is-upcoming")">
<strong>Review Story Intelligence</strong> <strong>Review Centre</strong>
<span>@ReviewSummary(isReviewPhase, currentStageNumber, implementedReviewStages.Count)</span> <span>Independent optional review categories</span>
<ul class="story-pipeline-review-list">
@foreach (var stage in reviewStages)
{
var index = implementedReviewStages.FindIndex(item => string.Equals(item.StageName, stage.StageName, StringComparison.OrdinalIgnoreCase));
var stageClass = !stage.IsImplemented
? "is-upcoming"
: isComplete || index < currentReviewIndex
? "is-complete"
: index == currentReviewIndex
? "is-current"
: "is-upcoming";
<li class="@stageClass">
<span aria-hidden="true">@StageMarker(stageClass, stage.IsImplemented)</span>
<strong>@stage.Label</strong>
@if (!stage.IsImplemented)
{
<em>Coming Soon</em>
}
</li>
}
</ul>
</li>
<li class="@(activePhase == 4 ? "is-current" : "is-upcoming")">
<strong>Build Story Database</strong>
<span>Final import and completion</span>
</li> </li>
</ol> </ol>
</section> </section>
@functions { @functions {
private sealed record PipelineStage(string StageName, string Label, bool IsImplemented); private string CurrentPhaseTitle()
=> Model.CurrentStage switch
private static string CurrentPhaseTitle(int phase)
=> phase switch
{ {
1 => "Prepare Manuscript", "Analyse Manuscript" => "Analyse Manuscript",
2 => "Analyse Manuscript", "Review Scenes" => "Create Scene Intelligence",
3 => "Review Story Intelligence", "Review Centre" => "Review Centre",
_ => "Build Story Database" _ => "Review Story Intelligence"
}; };
private static string ProgressLabel(int phase, string currentStage) private string CurrentPhaseDescription()
=> phase switch => Model.CurrentStage switch
{ {
3 => currentStage, "Analyse Manuscript" => "PlotDirector is reading saved canonical scenes.",
4 => "Finalising import", "Review Scenes" => "Create the stored scene intelligence before opening review categories.",
_ => "Completed" "Review Centre" => "Choose the review category you want to handle now.",
_ => "This category can be reviewed independently after character resolution."
}; };
private static string ReviewSummary(bool isReviewPhase, int currentStageNumber, int implementedStageCount) private string ProgressLabel()
=> isReviewPhase => Model.CurrentStage switch
? $"{Math.Max(0, currentStageNumber - 1)} of {implementedStageCount} review stages completed" {
: $"{implementedStageCount} review stages completed"; "Analyse Manuscript" => "Analysis running",
"Review Scenes" => "Scene intelligence",
private static string StageMarker(string stageClass, bool isImplemented) "Review Centre" => "Review Centre",
=> !isImplemented ? "○" : stageClass == "is-current" ? "►" : stageClass == "is-complete" ? "✓" : "○"; _ => Model.CurrentStage
};
} }

View File

@ -1,60 +1,44 @@
@model StoryIntelligencePipelineDashboardViewModel @model StoryIntelligencePipelineDashboardViewModel
<section class="story-review-summary story-pipeline-summary" aria-label="Story Intelligence progress summary"> <section class="story-review-summary story-pipeline-summary" aria-label="Story Intelligence review summary">
<div> <div>
<span>Chapters analysed</span> <span>Chapters analysed</span>
<strong>&check; @Model.ChaptersAnalysed.ToString("N0")</strong> <strong>@Model.ChaptersAnalysed.ToString("N0")</strong>
</div> </div>
<div> <div>
<span>Scenes imported</span> <span>Scenes imported</span>
<strong>&check; @Model.ScenesImported.ToString("N0")</strong> <strong>@Model.ScenesImported.ToString("N0")</strong>
</div> </div>
<div> <div>
<span>Characters identified</span> <span>Characters</span>
<strong>@Model.CharactersIdentified.ToString("N0")</strong> <strong>@(Model.CharacterStageComplete ? "Resolved" : "Needs attention")</strong>
</div> </div>
<div> <div>
<span>Characters created / linked</span> <span>Locations</span>
<strong>@(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review")</strong> <strong>@StageStatus(Model.LocationStageComplete)</strong>
</div> </div>
<div> <div>
<span>Locations identified</span> <span>Assets</span>
<strong>@Model.LocationsIdentified.ToString("N0")</strong> <strong>@StageStatus(Model.AssetStageComplete)</strong>
</div> </div>
<div> <div>
<span>Locations created / linked</span> <span>Relationships</span>
<strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong> <strong>@StageStatus(Model.RelationshipStageComplete)</strong>
</div> </div>
<div> <div>
<span>Assets identified</span> <span>Knowledge</span>
<strong>@Model.AssetsIdentified.ToString("N0")</strong> <strong>@StageStatus(Model.KnowledgeStageComplete)</strong>
</div> </div>
<div> <div>
<span>Assets created / linked</span> <span>Plot Lines & Threads</span>
<strong>@(Model.AssetStageComplete ? $"Done: {Model.AssetsCreatedOrLinked:N0}" : "In review")</strong> <strong>@StageStatus(Model.NarrativeStageComplete)</strong>
</div> </div>
<div> <div>
<span>Relationships identified</span> <span>Scene metrics</span>
<strong>@Model.RelationshipsIdentified.ToString("N0")</strong> <strong>Available</strong>
</div>
<div>
<span>Relationships created / linked</span>
<strong>@(Model.RelationshipStageComplete ? $"Done: {Model.RelationshipsCreatedOrLinked:N0}" : "In review")</strong>
</div>
<div>
<span>Knowledge identified</span>
<strong>@Model.KnowledgeIdentified.ToString("N0")</strong>
</div>
<div>
<span>Knowledge created / linked</span>
<strong>@(Model.KnowledgeStageComplete ? $"Done: {Model.KnowledgeCreatedOrLinked:N0}" : "In review")</strong>
</div>
<div>
<span>Plot Lines / Threads found</span>
<strong>@Model.NarrativeCandidatesIdentified.ToString("N0")</strong>
</div>
<div>
<span>Plot Lines / Threads applied</span>
<strong>@(Model.NarrativeStageComplete ? $"Done: {Model.NarrativeCandidatesApplied:N0}" : "In review")</strong>
</div> </div>
</section> </section>
@functions {
private static string StageStatus(bool complete) => complete ? "Reviewed" : "Optional";
}

View File

@ -1384,6 +1384,38 @@ summary.story-review-chapter-heading {
margin-top: .2rem; margin-top: .2rem;
} }
.story-review-centre-list {
display: grid;
gap: .65rem;
}
.story-review-centre-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: .75rem 1rem;
align-items: center;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: .85rem 1rem;
background: rgba(255, 255, 255, .64);
}
.story-review-centre-row h2 {
margin: 0;
font-size: 1.05rem;
}
.story-review-centre-row p {
margin: .2rem 0 0;
color: var(--bs-secondary-color);
}
.story-review-centre-row__count {
grid-column: 1 / -1;
color: var(--bs-secondary-color);
font-weight: 700;
}
.story-scene-preview-list { .story-scene-preview-list {
display: grid; display: grid;
gap: .65rem; gap: .65rem;
@ -2010,6 +2042,7 @@ summary.story-review-chapter-heading {
.story-review-summary, .story-review-summary,
.story-review-scene-panel, .story-review-scene-panel,
.story-character-card__body dl, .story-character-card__body dl,
.story-review-centre-row,
.story-future-stage-grid, .story-future-stage-grid,
.story-scene-preview dl, .story-scene-preview dl,
.story-live-current, .story-live-current,

View File

@ -0,0 +1,19 @@
(function () {
const forms = document.querySelectorAll("[data-story-review-form]");
forms.forEach((form) => {
form.addEventListener("submit", (event) => {
if (form.dataset.submitting === "true") {
event.preventDefault();
return;
}
form.dataset.submitting = "true";
form.querySelectorAll("button[type='submit']").forEach((button) => {
button.dataset.originalLabel = button.textContent.trim();
button.textContent = button.dataset.busyLabel || "Saving...";
button.disabled = true;
button.setAttribute("aria-busy", "true");
});
});
});
})();