Add story intelligence review centre

This commit is contained in:
Nick Beckley 2026-08-28 16:07:35 +00:00
parent 90d8f65fcf
commit 6d2340ae46
22 changed files with 684 additions and 368 deletions

View File

@ -223,7 +223,14 @@ var tests = new (string Name, Action Test)[]
("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent),
("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink),
("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 heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls)
};
foreach (var test in tests)
@ -232,6 +239,135 @@ foreach (var test in tests)
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 StoryIntelligenceHeavyReviewModulesOfferBoundedRendering()
{
var root = PlotLineRoot();
var heavyViews = new[]
{
"StoryIntelligenceLocations.cshtml",
"StoryIntelligenceAssets.cshtml",
"StoryIntelligenceRelationships.cshtml",
"StoryIntelligenceKnowledge.cshtml",
"StoryIntelligenceNarrative.cshtml"
};
foreach (var viewName in heavyViews)
{
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName));
Assert(view.Contains("const int previewLimit = 50", StringComparison.Ordinal), $"{viewName} should cap default rendering.");
Assert(view.Contains("asp-route-view=\"all\"", StringComparison.Ordinal), $"{viewName} should provide a View All route.");
Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal), $"{viewName} should disclose hidden remaining decisions.");
}
}
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()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -115,7 +115,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
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.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), 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);
}
[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")]
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 !model.AllCommitted
? RedirectToAction(nameof(StoryIntelligenceReview), new { batchId })
if (!model.AllCommitted)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
}
return model.PipelineDashboard.CharacterStageComplete && model.CharacterReview.Candidates.Count == 0
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId })
: View(model);
}
@ -278,9 +308,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
}
return !model.PipelineDashboard.LocationStageComplete
? RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId })
: View(model);
return View(model);
}
[HttpGet("story-intelligence/relationships")]
@ -307,14 +335,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
}
if (!model.PipelineDashboard.LocationStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
return !model.PipelineDashboard.AssetStageComplete
? RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId })
: View(model);
return View(model);
}
[HttpPost("story-intelligence/cancel")]
@ -344,8 +365,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
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("Index", "Writer", new { projectId = progress.ProjectID });
return RedirectToAction(progress.PipelineDashboard.CharacterStageComplete || progress.CharacterReview.Candidates.Count == 0
? nameof(StoryIntelligenceReviewCentre)
: nameof(StoryIntelligenceCharacters), 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;
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 });
}
@ -379,7 +403,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceCharacterComplete), new { batchId = form.BatchID })
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), 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;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceLocationComplete), new { batchId = form.BatchID })
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), 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;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceAssetComplete), new { batchId = form.BatchID })
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), 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;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceRelationshipComplete), new { batchId = form.BatchID })
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), 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 });
}
if (!model.PipelineDashboard.LocationStageComplete)
{
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);
return View(model);
}
[HttpPost("story-intelligence/knowledge")]
@ -510,7 +522,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceKnowledgeComplete), new { batchId = form.BatchID })
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), 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 });
}
if (!model.PipelineDashboard.LocationStageComplete)
{
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);
return View(model);
}
[HttpPost("story-intelligence/narrative")]
@ -577,7 +572,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceNarrativeComplete), new { batchId = form.BatchID })
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), 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 });
}
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);
return model is null ? NotFound() : View(model);
}

View File

@ -1385,10 +1385,10 @@ public sealed class BookService(
HasCanonicalSceneSources = hasCanonicalSceneSources,
CanStart = false,
StatusLabel = pipeline.IsComplete ? "Complete" : pipeline.Status,
ActionLabel = pipeline.IsComplete ? "View Story Intelligence Summary" : "Continue Story Intelligence",
ActionLabel = "Open Review Centre",
Description = pipeline.IsComplete
? "All currently available Story Intelligence stages are complete."
: "Story Intelligence has saved progress for this book."
? "Story Intelligence review is saved 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);
var route = narrativeStageComplete
? StoryIntelligenceResumeRoutes.Complete
: knowledgeStageComplete
? StoryIntelligenceResumeRoutes.Narrative
: relationshipStageComplete
? StoryIntelligenceResumeRoutes.Knowledge
: assetStageComplete
? StoryIntelligenceResumeRoutes.Relationships
: locationStageComplete
? StoryIntelligenceResumeRoutes.Assets
: characterStageComplete
? StoryIntelligenceResumeRoutes.Locations
: state.CurrentStage switch
var route = characterStageComplete
? StoryIntelligenceResumeRoutes.ReviewCentre
: state.CurrentStage switch
{
StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
@ -1540,6 +1530,7 @@ public sealed class StoryIntelligenceNarrativeImportBatchResult
public static class StoryIntelligenceResumeRoutes
{
public const string SceneReview = "SceneReview";
public const string ReviewCentre = "ReviewCentre";
public const string Characters = "Characters";
public const string Locations = "Locations";
public const string Assets = "Assets";

View File

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

View File

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

View File

@ -25,9 +25,9 @@
{
<section class="onboarding-story-card">
<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">
<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">
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
</form>
@ -55,7 +55,7 @@
<div>
<span>Review</span>
<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>
@ -72,9 +72,9 @@
<div class="onboarding-analysis-flow">
<div><span>1</span><strong>Analyse chapters</strong></div>
<div><span>2</span><strong>Review scenes</strong></div>
<div><span>3</span><strong>Review story entities</strong></div>
<div><span>4</span><strong>Build database</strong></div>
<div><span>2</span><strong>Create scene intelligence</strong></div>
<div><span>3</span><strong>Resolve characters</strong></div>
<div><span>4</span><strong>Open Review Centre</strong></div>
</div>
<div class="onboarding-actions">

View File

@ -1,6 +1,12 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review assets";
const int previewLimit = 50;
var showAll = string.Equals(Context.Request.Query["view"], "all", StringComparison.OrdinalIgnoreCase);
var assetCandidates = showAll
? Model.AssetReview.Candidates.ToList()
: Model.AssetReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.AssetReview.Candidates.Count - assetCandidates.Count);
}
<section class="onboarding-shell" aria-labelledby="story-asset-title">
@ -9,9 +15,8 @@
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</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>
<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>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -33,22 +38,26 @@
<p class="mb-0">Assets are already up to date, or there were no story-significant asset suggestions ready to import.</p>
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Relationships</strong><span>Next</span></article>
@FutureStage("Review Knowledge")
@FutureStage("Review Continuity")
@FutureStage("Timeline Events")
@FutureStage("Relationships")
@FutureStage("Knowledge")
@FutureStage("Plot Lines & Threads")
@FutureStage("Scene Metrics")
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a>
<form asp-action="ImportStoryIntelligenceAssets" method="post">
<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" data-story-review-form>
<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>
</div>
}
else
{
<div class="story-review-card-actions">
@if (hiddenCandidateCount > 0)
{
<a class="btn btn-outline-secondary btn-sm" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID" asp-route-view="all">View all @Model.AssetReview.Candidates.Count.ToString("N0")</a>
}
<button class="btn btn-outline-primary btn-sm" type="button" data-asset-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="ignore-all">Ignore all</button>
@ -56,12 +65,12 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="collapse">Collapse all</button>
</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" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.AssetReview.Candidates.Count; i++)
@for (var i = 0; i < assetCandidates.Count; i++)
{
var candidate = Model.AssetReview.Candidates[i];
var candidate = assetCandidates[i];
<details class="story-character-card" open data-asset-card>
<summary>
<span>
@ -145,8 +154,12 @@
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a>
<button class="btn btn-primary" type="submit">Create assets</button>
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">@hiddenCandidateCount.ToString("N0") more asset decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch.</span>
}
<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" data-busy-label="Saving assets...">Save asset decisions</button>
</div>
</form>
}
@ -222,11 +235,12 @@
cards().forEach(updateCard);
})();
</script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
}
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
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">
<p class="eyebrow">Phase 3</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>
<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>
@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>
</div>
<div class="story-future-stage-grid">
@FutureStage("Review Locations")
@FutureStage("Review Assets")
@FutureStage("Review Relationships")
@FutureStage("Review Knowledge")
@FutureStage("Locations")
@FutureStage("Assets")
@FutureStage("Relationships")
@FutureStage("Knowledge")
</div>
<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-primary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Continue</a>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
</div>
}
else
@ -59,7 +57,7 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button>
</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" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.CharacterReview.Candidates.Count; i++)
@ -140,6 +138,7 @@
<div data-character-alias-panel hidden>
<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>
<option value="">Choose character...</option>
@foreach (var target in Model.CharacterReview.AliasTargetOptions)
@ -154,8 +153,8 @@
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReview" asp-route-batchId="@Model.BatchID">Back to scenes</a>
<button class="btn btn-primary" type="submit">Create characters</button>
<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" data-busy-label="Saving characters...">Save character decisions</button>
</div>
</form>
}
@ -237,6 +236,7 @@
cards().forEach(updateCard);
})();
</script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
}
@functions {
@ -246,5 +246,5 @@
=> string.Equals(candidate.DefaultAction, action, StringComparison.OrdinalIgnoreCase);
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">
<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">
<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">
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
</form>

View File

@ -1,6 +1,12 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review knowledge";
const int previewLimit = 50;
var showAll = string.Equals(Context.Request.Query["view"], "all", StringComparison.OrdinalIgnoreCase);
var knowledgeCandidates = showAll
? Model.KnowledgeReview.Candidates.ToList()
: Model.KnowledgeReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.KnowledgeReview.Candidates.Count - knowledgeCandidates.Count);
}
<section class="onboarding-shell" aria-labelledby="story-knowledge-title">
@ -9,9 +15,8 @@
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</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>
<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>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -33,20 +38,24 @@
<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 class="story-future-stage-grid">
@FutureStage("Review Continuity")
@FutureStage("Timeline Events")
@FutureStage("Plot Lines & Threads")
@FutureStage("Scene Metrics")
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Back to relationships</a>
<form asp-action="ImportStoryIntelligenceKnowledge" method="post">
<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" data-story-review-form>
<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>
</div>
}
else
{
<div class="story-review-card-actions">
@if (hiddenCandidateCount > 0)
{
<a class="btn btn-outline-secondary btn-sm" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID" asp-route-view="all">View all @Model.KnowledgeReview.Candidates.Count.ToString("N0")</a>
}
<button class="btn btn-outline-primary btn-sm" type="button" data-knowledge-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="ignore-all">Ignore all</button>
@ -54,12 +63,12 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="collapse">Collapse all</button>
</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" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.KnowledgeReview.Candidates.Count; i++)
@for (var i = 0; i < knowledgeCandidates.Count; i++)
{
var candidate = Model.KnowledgeReview.Candidates[i];
var candidate = knowledgeCandidates[i];
<details class="story-character-card" open data-knowledge-card>
<summary>
<span>
@ -149,8 +158,12 @@
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Back to relationships</a>
<button class="btn btn-primary" type="submit">Create knowledge</button>
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">@hiddenCandidateCount.ToString("N0") more knowledge decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch.</span>
}
<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" data-busy-label="Saving knowledge...">Save knowledge decisions</button>
</div>
</form>
}
@ -226,11 +239,12 @@
cards().forEach(updateCard);
})();
</script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
}
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
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

@ -1,6 +1,12 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review locations";
const int previewLimit = 50;
var showAll = string.Equals(Context.Request.Query["view"], "all", StringComparison.OrdinalIgnoreCase);
var locationCandidates = showAll
? Model.LocationReview.Candidates.ToList()
: Model.LocationReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.LocationReview.Candidates.Count - locationCandidates.Count);
}
<section class="onboarding-shell" aria-labelledby="story-location-title">
@ -9,9 +15,8 @@
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</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>
<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>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -33,19 +38,22 @@
<p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p>
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Assets</strong><span>Next</span></article>
@FutureStage("Review Relationships")
@FutureStage("Review Knowledge")
@FutureStage("Review Continuity")
@FutureStage("Assets")
@FutureStage("Relationships")
@FutureStage("Knowledge")
@FutureStage("Plot Lines & Threads")
</div>
<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-primary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Continue</a>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
</div>
}
else
{
<div class="story-review-card-actions">
@if (hiddenCandidateCount > 0)
{
<a class="btn btn-outline-secondary btn-sm" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-view="all">View all @Model.LocationReview.Candidates.Count.ToString("N0")</a>
}
<button class="btn btn-outline-primary btn-sm" type="button" data-location-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="ignore-all">Ignore all</button>
@ -53,12 +61,12 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
</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" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.LocationReview.Candidates.Count; i++)
@for (var i = 0; i < locationCandidates.Count; i++)
{
var candidate = Model.LocationReview.Candidates[i];
var candidate = locationCandidates[i];
<details class="story-character-card" open data-location-card>
<summary>
<span>
@ -134,8 +142,12 @@
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a>
<button class="btn btn-primary" type="submit">Create locations</button>
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">@hiddenCandidateCount.ToString("N0") more location decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch.</span>
}
<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" data-busy-label="Saving locations...">Save location decisions</button>
</div>
</form>
}
@ -211,11 +223,12 @@
cards().forEach(updateCard);
})();
</script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
}
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
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

@ -1,6 +1,12 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review Plot Lines & Threads";
const int previewLimit = 50;
var showAll = string.Equals(Context.Request.Query["view"], "all", StringComparison.OrdinalIgnoreCase);
var narrativeCandidates = showAll
? Model.NarrativeReview.Candidates.ToList()
: Model.NarrativeReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.NarrativeReview.Candidates.Count - narrativeCandidates.Count);
}
<section class="onboarding-shell" aria-labelledby="story-narrative-title">
@ -9,9 +15,8 @@
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</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>
<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>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -31,20 +36,26 @@
<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>
</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" />
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID">Back to knowledge</a>
<button class="btn btn-primary" type="submit">Complete narrative review</button>
<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" data-busy-label="Saving plot lines...">Mark reviewed</button>
</form>
}
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" />
@if (hiddenCandidateCount > 0)
{
<div class="story-review-card-actions">
<a class="btn btn-outline-secondary btn-sm" asp-action="StoryIntelligenceNarrative" asp-route-batchId="@Model.BatchID" asp-route-view="all">View all @Model.NarrativeReview.Candidates.Count.ToString("N0")</a>
</div>
}
<div class="story-character-card-grid">
@for (var i = 0; i < Model.NarrativeReview.Candidates.Count; i++)
@for (var i = 0; i < narrativeCandidates.Count; i++)
{
var candidate = Model.NarrativeReview.Candidates[i];
var candidate = narrativeCandidates[i];
<details class="story-character-card" open>
<summary>
<span>
@ -145,10 +156,18 @@
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID">Back to knowledge</a>
<button class="btn btn-primary" type="submit">Apply Plot Lines & Threads</button>
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">@hiddenCandidateCount.ToString("N0") more narrative decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch.</span>
}
<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" data-busy-label="Saving plot lines...">Save Plot Lines & Threads</button>
</div>
</form>
}
</div>
</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"
data-story-review-link
asp-action="StoryIntelligenceReview"
asp-route-batchId="@Model.BatchID">Review scenes</a>
asp-action="@(Model.AllCommitted && Model.CharacterGateSatisfied ? "StoryIntelligenceReviewCentre" : "StoryIntelligenceReview")"
asp-route-batchId="@Model.BatchID">@(Model.AllCommitted && Model.CharacterGateSatisfied ? "Open Review Centre" : "Create scene intelligence")</a>
}
}
</div>

View File

@ -1,6 +1,12 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review relationships";
const int previewLimit = 50;
var showAll = string.Equals(Context.Request.Query["view"], "all", StringComparison.OrdinalIgnoreCase);
var relationshipCandidates = showAll
? Model.RelationshipReview.Candidates.ToList()
: Model.RelationshipReview.Candidates.Take(previewLimit).ToList();
var hiddenCandidateCount = Math.Max(0, Model.RelationshipReview.Candidates.Count - relationshipCandidates.Count);
}
<section class="onboarding-shell" aria-labelledby="story-relationship-title">
@ -9,9 +15,8 @@
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</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>
<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>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -33,21 +38,25 @@
<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 class="story-future-stage-grid">
@FutureStage("Review Knowledge")
@FutureStage("Review Continuity")
@FutureStage("Timeline Events")
@FutureStage("Knowledge")
@FutureStage("Plot Lines & Threads")
@FutureStage("Scene Metrics")
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
<form asp-action="ImportStoryIntelligenceRelationships" method="post">
<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" data-story-review-form>
<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>
</div>
}
else
{
<div class="story-review-card-actions">
@if (hiddenCandidateCount > 0)
{
<a class="btn btn-outline-secondary btn-sm" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID" asp-route-view="all">View all @Model.RelationshipReview.Candidates.Count.ToString("N0")</a>
}
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="ignore-all">Ignore all</button>
@ -55,12 +64,12 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="collapse">Collapse all</button>
</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" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.RelationshipReview.Candidates.Count; i++)
@for (var i = 0; i < relationshipCandidates.Count; i++)
{
var candidate = Model.RelationshipReview.Candidates[i];
var candidate = relationshipCandidates[i];
<details class="story-character-card" open data-relationship-card>
<summary>
<span>
@ -160,8 +169,12 @@
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
<button class="btn btn-primary" type="submit">Create relationships</button>
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">@hiddenCandidateCount.ToString("N0") more relationship decision@(hiddenCandidateCount == 1 ? string.Empty : "s") available after this batch.</span>
}
<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" data-busy-label="Saving relationships...">Save relationship decisions</button>
</div>
</form>
}
@ -237,11 +250,12 @@
cards().forEach(updateCard);
})();
</script>
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
}
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
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
@inject IConfiguration Configuration
@{
ViewData["Title"] = "Review scenes";
ViewData["Title"] = "Create scene intelligence";
var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
var userEmail = User.FindFirstValue(ClaimTypes.Email);
var isAdmin = !string.IsNullOrWhiteSpace(userEmail)
@ -15,11 +15,10 @@
<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 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 1 of 4</p>
<h1 id="story-review-title">Review Scenes</h1>
<p>PlotDirector has finished reading your manuscript. Review the detected scenes before creating them in your story database.</p>
<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)
@ -32,7 +31,7 @@
}
<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 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>
@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" />
<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>
}
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>
</section>
@section Scripts {
<script src="~/js/story-review-actions.js" asp-append-version="true"></script>
}
@functions {
private static bool NeedsReview(StoryIntelligenceOnboardingChapterViewModel chapter)
=> 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
@{
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 activePhase = isComplete ? 4 : isReviewPhase ? 3 : isAnalyse ? 2 : 1;
var progressPercent = isReviewPhase
? Math.Clamp(((currentReviewIndex + 1) * 100) / implementedReviewStages.Count, 0, 100)
: isComplete ? 100 : 100;
var currentStageNumber = isReviewPhase ? currentReviewIndex + 1 : isComplete ? implementedReviewStages.Count : 0;
var isSceneImport = string.Equals(Model.CurrentStage, "Review Scenes", StringComparison.OrdinalIgnoreCase);
var isReviewCentre = string.Equals(Model.CurrentStage, "Review Centre", StringComparison.OrdinalIgnoreCase);
var phase = isAnalyse ? 2 : isSceneImport ? 2 : 3;
var progressPercent = isAnalyse ? 50 : isSceneImport ? 75 : 100;
}
<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">
<p class="eyebrow">Phase @activePhase</p>
<h2>@CurrentPhaseTitle(activePhase)</h2>
@if (isReviewPhase)
{
<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>
}
<p class="eyebrow">Phase @phase</p>
<h2>@CurrentPhaseTitle()</h2>
<p>@CurrentPhaseDescription()</p>
</div>
<div class="onboarding-progress story-pipeline-progress" aria-label="@CurrentPhaseTitle(activePhase) progress">
<span>@ProgressLabel(activePhase, Model.CurrentStage)</span>
<div class="onboarding-progress story-pipeline-progress" aria-label="@CurrentPhaseTitle() progress">
<span>@ProgressLabel()</span>
<div class="onboarding-progress-track">
<span style="width:@progressPercent%"></span>
</div>
</div>
<ol class="story-pipeline-phase-list">
<li class="@(activePhase > 1 ? "is-complete" : activePhase == 1 ? "is-current" : "is-upcoming")">
<strong>Prepare Manuscript</strong>
<span>7 completed steps</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 class="is-complete">
<strong>Core Import</strong>
<span>Book structure created from Word</span>
</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>
<span>Story Intelligence Analysis</span>
<span>Scene intelligence saved to PlotDirector</span>
</li>
<li class="@(activePhase > 3 ? "is-complete" : activePhase == 3 ? "is-current" : "is-upcoming")">
<strong>Review Story Intelligence</strong>
<span>@ReviewSummary(isReviewPhase, currentStageNumber, implementedReviewStages.Count)</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 class="@(!isAnalyse && !isSceneImport ? "is-current" : "is-upcoming")">
<strong>Review Centre</strong>
<span>Independent optional review categories</span>
</li>
</ol>
</section>
@functions {
private sealed record PipelineStage(string StageName, string Label, bool IsImplemented);
private static string CurrentPhaseTitle(int phase)
=> phase switch
private string CurrentPhaseTitle()
=> Model.CurrentStage switch
{
1 => "Prepare Manuscript",
2 => "Analyse Manuscript",
3 => "Review Story Intelligence",
_ => "Build Story Database"
"Analyse Manuscript" => "Analyse Manuscript",
"Review Scenes" => "Create Scene Intelligence",
"Review Centre" => "Review Centre",
_ => "Review Story Intelligence"
};
private static string ProgressLabel(int phase, string currentStage)
=> phase switch
private string CurrentPhaseDescription()
=> Model.CurrentStage switch
{
3 => currentStage,
4 => "Finalising import",
_ => "Completed"
"Analyse Manuscript" => "PlotDirector is reading saved canonical scenes.",
"Review Scenes" => "Create the stored scene intelligence before opening review categories.",
"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)
=> isReviewPhase
? $"{Math.Max(0, currentStageNumber - 1)} of {implementedStageCount} review stages completed"
: $"{implementedStageCount} review stages completed";
private static string StageMarker(string stageClass, bool isImplemented)
=> !isImplemented ? "○" : stageClass == "is-current" ? "►" : stageClass == "is-complete" ? "✓" : "○";
private string ProgressLabel()
=> Model.CurrentStage switch
{
"Analyse Manuscript" => "Analysis running",
"Review Scenes" => "Scene intelligence",
"Review Centre" => "Review Centre",
_ => Model.CurrentStage
};
}

View File

@ -1,60 +1,44 @@
@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>
<span>Chapters analysed</span>
<strong>&check; @Model.ChaptersAnalysed.ToString("N0")</strong>
<strong>@Model.ChaptersAnalysed.ToString("N0")</strong>
</div>
<div>
<span>Scenes imported</span>
<strong>&check; @Model.ScenesImported.ToString("N0")</strong>
<strong>@Model.ScenesImported.ToString("N0")</strong>
</div>
<div>
<span>Characters identified</span>
<strong>@Model.CharactersIdentified.ToString("N0")</strong>
<span>Characters</span>
<strong>@(Model.CharacterStageComplete ? "Resolved" : "Needs attention")</strong>
</div>
<div>
<span>Characters created / linked</span>
<strong>@(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review")</strong>
<span>Locations</span>
<strong>@StageStatus(Model.LocationStageComplete)</strong>
</div>
<div>
<span>Locations identified</span>
<strong>@Model.LocationsIdentified.ToString("N0")</strong>
<span>Assets</span>
<strong>@StageStatus(Model.AssetStageComplete)</strong>
</div>
<div>
<span>Locations created / linked</span>
<strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong>
<span>Relationships</span>
<strong>@StageStatus(Model.RelationshipStageComplete)</strong>
</div>
<div>
<span>Assets identified</span>
<strong>@Model.AssetsIdentified.ToString("N0")</strong>
<span>Knowledge</span>
<strong>@StageStatus(Model.KnowledgeStageComplete)</strong>
</div>
<div>
<span>Assets created / linked</span>
<strong>@(Model.AssetStageComplete ? $"Done: {Model.AssetsCreatedOrLinked:N0}" : "In review")</strong>
<span>Plot Lines & Threads</span>
<strong>@StageStatus(Model.NarrativeStageComplete)</strong>
</div>
<div>
<span>Relationships identified</span>
<strong>@Model.RelationshipsIdentified.ToString("N0")</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>
<span>Scene metrics</span>
<strong>Available</strong>
</div>
</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;
}
.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 {
display: grid;
gap: .65rem;
@ -2010,6 +2042,7 @@ summary.story-review-chapter-heading {
.story-review-summary,
.story-review-scene-panel,
.story-character-card__body dl,
.story-review-centre-row,
.story-future-stage-grid,
.story-scene-preview dl,
.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");
});
});
});
})();