diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 5f26847..d922360 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -231,8 +231,11 @@ var tests = new (string Name, Action Test)[] ("Word manuscript unlink keeps Story Intelligence data untouched", WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched), ("Word manuscript cleanup remains relinkable and idempotent", WordManuscriptCleanupRemainsRelinkableAndIdempotent), ("Story Intelligence Review Centre route replaces linear review resume", StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume), + ("Story Intelligence obsolete create scene workflow is removed", StoryIntelligenceObsoleteCreateSceneWorkflowIsRemoved), ("Story Intelligence Review Centre counts only needs-attention categories", StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories), + ("Story Intelligence Review Centre opens with character gate", StoryIntelligenceReviewCentreOpensWithCharacterGate), ("Story Intelligence review modules are independent after character gate", StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate), + ("Story Intelligence downstream review uses completed scene analysis", StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis), ("Story Intelligence review posts do not use success interstitials", StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials), ("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard), ("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering), @@ -609,7 +612,7 @@ static void StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning() Assert(onboardingViewModels.Contains("public bool CanContinueReview => !IsInProgress", StringComparison.Ordinal), "Project dashboard should suppress review buttons for running analysis."); Assert(coreServices.Contains("var isInProgress = string.Equals(pipeline.Status, StoryIntelligencePipelineStatuses.InProgress", StringComparison.Ordinal), "Book action builder should inspect the pipeline status."); Assert(coreServices.Contains("CanContinue = !isInProgress", StringComparison.Ordinal), "Book action builder should block review navigation while analysis is running."); - Assert(coreServices.Contains("ActionLabel = pipeline.IsComplete ? \"Open Review Centre\" : \"Continue review\"", StringComparison.Ordinal), "Open Review Centre should be reserved for complete Story Intelligence."); + Assert(coreServices.Contains("ActionLabel = isInProgress ? \"Analysis running\" : \"Open Review Centre\"", StringComparison.Ordinal), "Open Review Centre should be available for completed analysis that still has review work."); Assert(storyService.Contains("IsInProgress = string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress", StringComparison.Ordinal), "Project dashboard pipeline rows should map in-progress state."); Assert(bookView.Contains("if (action.CanContinue)", StringComparison.Ordinal), "Book details should render the review link only when the pipeline can continue."); Assert(bookView.Contains("Analysis running", StringComparison.Ordinal), "Book details should show running status instead of a premature review-centre button."); @@ -626,8 +629,24 @@ static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume() Assert(controller.Contains("story-intelligence/review-centre", StringComparison.Ordinal), "Review Centre route should exist."); Assert(controller.Contains("StoryIntelligenceResumeRoutes.ReviewCentre => RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), "Resume routing should open Review Centre."); Assert(service.Contains("public const string ReviewCentre = \"ReviewCentre\";", StringComparison.Ordinal), "Resume route constants should include ReviewCentre."); - Assert(service.Contains("? StoryIntelligenceResumeRoutes.ReviewCentre", StringComparison.Ordinal), "Completed character resolution should resume to Review Centre."); - Assert(view.Contains("Review only the story intelligence that needs an author decision.", StringComparison.Ordinal), "Review Centre should describe needs-attention review."); + Assert(service.Contains("SceneAnalysisComplete(state)\n ? StoryIntelligenceResumeRoutes.ReviewCentre", StringComparison.Ordinal), "Completed scene analysis should resume to Review Centre."); + Assert(view.Contains("Your manuscript analysis is complete.", StringComparison.Ordinal), "Review Centre should describe completed analysis."); +} + +static void StoryIntelligenceObsoleteCreateSceneWorkflowIsRemoved() +{ + var root = PlotLineRoot(); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml")); + var overviewView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligence.cshtml")); + var reviewViewPath = Path.Combine(root, "Views/Onboarding/StoryIntelligenceReview.cshtml"); + var legacyReviewAction = ExtractBetween(controller, "public async Task StoryIntelligenceReview", "[HttpGet(\"story-intelligence/review-centre\")]"); + + Assert(!File.Exists(reviewViewPath), "Obsolete Create Scene Intelligence view should be removed."); + Assert(legacyReviewAction.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), "Legacy review URL should redirect to Review Centre after analysis."); + Assert(!legacyReviewAction.Contains("View(model)", StringComparison.Ordinal), "Legacy review URL must never render obsolete UI."); + Assert(!progressView.Contains("Create scene intelligence", StringComparison.OrdinalIgnoreCase), "Progress view should not link to old create scene workflow."); + Assert(!overviewView.Contains("Create scene intelligence", StringComparison.OrdinalIgnoreCase), "Overview should not describe old create scene workflow."); } static void StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories() @@ -640,10 +659,23 @@ static void StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories() Assert(viewModel.Contains("CharacterNeedsAttentionCount => CharacterReview.Candidates.Count", StringComparison.Ordinal), "Character count should be derived from unresolved candidates."); Assert(viewModel.Contains("LocationNeedsAttentionCount => LocationReview.Candidates.Count", StringComparison.Ordinal), "Location count should be derived from unresolved candidates."); Assert(viewModel.Contains("NarrativeNeedsAttentionCount => NarrativeReview.Candidates.Count", StringComparison.Ordinal), "Narrative count should be derived from unresolved candidates."); - Assert(centre.Contains("Scene Metrics", StringComparison.Ordinal) && centre.Contains("0 need attention", StringComparison.Ordinal), "Metrics should be available, not mandatory review."); + Assert(centre.Contains("Scene Metrics", StringComparison.Ordinal) && centre.Contains("TotalAnalysedScenes", StringComparison.Ordinal), "Metrics should report analysed scene count."); Assert(!summary.Contains("Locations identified", StringComparison.Ordinal), "Summary should not present total identified rows as review workload."); } +static void StoryIntelligenceReviewCentreOpensWithCharacterGate() +{ + var root = PlotLineRoot(); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var centreAction = ExtractBetween(controller, "public async Task StoryIntelligenceReviewCentre", "[HttpGet(\"story-intelligence/characters\")]"); + var centreView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml")); + + Assert(centreAction.Contains("return View(model);", StringComparison.Ordinal), "Review Centre should render after completed analysis."); + Assert(!centreAction.Contains("StoryIntelligenceCharacters", StringComparison.Ordinal), "Review Centre should not redirect unresolved Characters away from the centre."); + Assert(centreView.Contains("Resolve Characters first", StringComparison.Ordinal), "Locked modules should explain the character gate."); + Assert(centreView.Contains("Locked until Characters are resolved", StringComparison.Ordinal), "Dependent modules should remain visible but locked."); +} + static void StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate() { var root = PlotLineRoot(); @@ -658,7 +690,30 @@ static void StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate() Assert(!relationshipsAction.Contains("LocationStageComplete", StringComparison.Ordinal) && !relationshipsAction.Contains("AssetStageComplete", StringComparison.Ordinal), "Relationships should not require completed location or asset review."); Assert(!knowledgeAction.Contains("LocationStageComplete", StringComparison.Ordinal) && !knowledgeAction.Contains("RelationshipStageComplete", StringComparison.Ordinal), "Knowledge should not require earlier non-character reviews."); Assert(!narrativeAction.Contains("KnowledgeStageComplete", StringComparison.Ordinal) && !narrativeAction.Contains("RelationshipStageComplete", StringComparison.Ordinal), "Narrative review should not require knowledge or relationship review."); - Assert(locationsAction.Contains("CharacterStageComplete", StringComparison.Ordinal), "Dependent review modules should still respect the character gate."); + Assert(locationsAction.Contains("CharacterGateSatisfied", StringComparison.Ordinal), "Dependent review modules should still respect the character gate."); + Assert(locationsAction.Contains("RedirectToReviewCentreWithCharacterGate", StringComparison.Ordinal), "Direct dependent module URLs should return to Review Centre when the character gate is closed."); +} + +static void StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis() +{ + var root = PlotLineRoot(); + var reviewServices = new[] + { + "StoryIntelligenceCharacterImportService.cs", + "StoryIntelligenceLocationImportService.cs", + "StoryIntelligenceAssetImportService.cs", + "StoryIntelligenceRelationshipImportService.cs", + "StoryIntelligenceKnowledgeImportService.cs", + "StoryIntelligenceNarrativeReviewService.cs" + }; + + foreach (var fileName in reviewServices) + { + var service = File.ReadAllText(Path.Combine(root, "Services", fileName)); + Assert(!service.Contains("GetImportCommitAsync", StringComparison.Ordinal), $"{fileName} should not require legacy StoryIntelligenceImportCommits."); + Assert(service.Contains("StoryIntelligenceRunStatuses.Completed", StringComparison.Ordinal), $"{fileName} should use completed persisted runs as its scene-analysis prerequisite."); + Assert(service.Contains("ListSceneResultsAsync", StringComparison.Ordinal), $"{fileName} should read persisted scene analysis results."); + } } static void StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials() @@ -2946,7 +3001,6 @@ static void StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts() var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs")); var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); - var reviewView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReview.cshtml")); var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs")); Assert(commit.Contains("BuildReviewScenes(canonicalScenes, sceneResults, importScenes)", StringComparison.Ordinal), "Review confirmation should build canonical scene previews."); @@ -2956,9 +3010,7 @@ static void StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts() Assert(commit.Contains("GroupBy(result => result.SceneID!.Value)", StringComparison.Ordinal), "Duplicate mapped SI rows should collapse to one canonical scene preview."); Assert(onboardingStory.Contains("FirstConfigured(scene.Summary, scene.ParsedScene.Summary?.Short", StringComparison.Ordinal), "Onboarding preview should prefer canonical summary with legacy SI fallback."); Assert(viewModel.Contains("chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes", StringComparison.Ordinal), "Review headline total should derive from canonical scene previews when available."); - Assert(reviewView.Contains("chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes", StringComparison.Ordinal), "Per-chapter detected count should derive from canonical scene previews."); - Assert(reviewView.Contains("chapter.Scenes.Count(scene => !scene.HasWarnings)", StringComparison.Ordinal), "Ready count should not include missing or failed canonical scene analysis."); - Assert(reviewView.Contains("@scene.AnalysisStatus", StringComparison.Ordinal), "Review UI should show missing/failed analysis state on canonical scenes."); + Assert(viewModel.Contains("public int TotalAnalysedScenes => Math.Max(TotalCompletedScenes, PipelineDashboard.ScenesImported)", StringComparison.Ordinal), "Review Centre should use analysed canonical scene totals after the old scene-creation view is removed."); Assert(repository.Contains("SceneID = item.SceneID ??", StringComparison.Ordinal), "Commit should target canonical SceneID when one exists."); } @@ -5015,7 +5067,7 @@ static void StoryIntelligenceNarrativeStageFollowsKnowledge() Assert(pipeline.Contains("RecordNarrativeImportAsync", StringComparison.Ordinal), "Narrative import should mark the pipeline complete."); Assert(pipelineConstraint.Contains("N'NarrativeReview'", StringComparison.Ordinal) && pipelineConstraint.Contains("N'NarrativeImport'", StringComparison.Ordinal), "Database pipeline constraint should allow narrative stages."); Assert(knowledgeImport.Contains("hasUndecidedCandidates", StringComparison.Ordinal), "Knowledge review should be able to advance when prior decisions already imported every candidate."); - Assert(onboarding.Contains("StoryIntelligenceResumeRoutes.Narrative", StringComparison.Ordinal), "Resume logic should route to narrative review."); + Assert(onboarding.Contains("SceneAnalysisComplete(state)\n ? StoryIntelligenceResumeRoutes.ReviewCentre", StringComparison.Ordinal), "Resume logic should route completed analysis to Review Centre."); Assert(controller.Contains("StoryIntelligenceNarrative", StringComparison.Ordinal), "Onboarding controller should expose the narrative review stage."); Assert(knowledgeComplete.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "Knowledge completion should lead to narrative review."); } diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 108c1f8..f6d06fd 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -117,13 +117,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard { StoryIntelligenceResumeRoutes.ReviewCentre => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }), - StoryIntelligenceResumeRoutes.Narrative => RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = target.BatchID }), - StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }), - StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }), - StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }), - StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }), - StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }), - _ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID }) + StoryIntelligenceResumeRoutes.Narrative => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }), + StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }), + StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }), + StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }), + StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }), + StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = target.BatchID }), + _ => RedirectToAction("Details", "Books", new { id = bookId }) }; } @@ -192,7 +192,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard public async Task StoryIntelligenceProgress(Guid batchId) { var model = await storyIntelligence.GetProgressAsync(batchId); - return model is null ? NotFound() : View(model); + if (model is null) + { + return NotFound(); + } + + return model.SceneAnalysisComplete + ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }) + : RedirectToBook(model); } [HttpGet("story-intelligence/review")] @@ -206,12 +213,12 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard if (model.HasActiveRuns) { - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + return RedirectToBook(model); } - return model.AllCommitted - ? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) - : View(model); + return model.SceneAnalysisComplete + ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }) + : RedirectToBook(model); } [HttpGet("story-intelligence/review-centre")] @@ -225,17 +232,15 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard if (model.HasActiveRuns) { - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + return RedirectToBook(model); } - if (!model.AllCommitted) + if (!model.SceneAnalysisComplete) { - return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + return RedirectToBook(model); } - return !model.PipelineDashboard.CharacterStageComplete - ? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) - : View(model); + return View(model); } [HttpGet("story-intelligence/characters")] @@ -249,15 +254,15 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard if (model.HasActiveRuns) { - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + return RedirectToBook(model); } - if (!model.AllCommitted) + if (!model.SceneAnalysisComplete) { - return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } - return model.PipelineDashboard.CharacterStageComplete && model.CharacterReview.Candidates.Count == 0 + return model.CharacterGateSatisfied && model.CharacterReview.Candidates.Count == 0 ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }) : View(model); } @@ -273,16 +278,16 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard if (model.HasActiveRuns) { - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + return RedirectToBook(model); } - if (!model.AllCommitted) + if (!model.SceneAnalysisComplete) { - return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } - return !model.PipelineDashboard.CharacterStageComplete - ? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) + return !model.CharacterGateSatisfied + ? RedirectToReviewCentreWithCharacterGate(batchId) : View(model); } @@ -297,17 +302,17 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard if (model.HasActiveRuns) { - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + return RedirectToBook(model); } - if (!model.AllCommitted) + if (!model.SceneAnalysisComplete) { - return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } - if (!model.PipelineDashboard.CharacterStageComplete) + if (!model.CharacterGateSatisfied) { - return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); + return RedirectToReviewCentreWithCharacterGate(batchId); } return View(model); @@ -324,17 +329,17 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard if (model.HasActiveRuns) { - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + return RedirectToBook(model); } - if (!model.AllCommitted) + if (!model.SceneAnalysisComplete) { - return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } - if (!model.PipelineDashboard.CharacterStageComplete) + if (!model.CharacterGateSatisfied) { - return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); + return RedirectToReviewCentreWithCharacterGate(batchId); } return View(model); @@ -351,7 +356,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard } TempData["OnboardingStoryIntelligenceMessage"] = "Analysis cancellation requested."; - return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + return RedirectToBook(progress); } [HttpPost("story-intelligence/commit")] @@ -365,14 +370,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; - if (result.Success && progress.AllCommitted) - { - return RedirectToAction(progress.PipelineDashboard.CharacterStageComplete || progress.CharacterReview.Candidates.Count == 0 - ? nameof(StoryIntelligenceReviewCentre) - : nameof(StoryIntelligenceCharacters), new { batchId }); - } - - return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [HttpPost("story-intelligence/commit-all")] @@ -386,11 +384,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; - return result.Success && progress.AllCommitted - ? RedirectToAction(progress.PipelineDashboard.CharacterStageComplete || progress.CharacterReview.Candidates.Count == 0 - ? nameof(StoryIntelligenceReviewCentre) - : nameof(StoryIntelligenceCharacters), new { batchId }) - : RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } [HttpPost("story-intelligence/characters")] @@ -496,17 +490,17 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard if (model.HasActiveRuns) { - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + return RedirectToBook(model); } - if (!model.AllCommitted) + if (!model.SceneAnalysisComplete) { - return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } - if (!model.PipelineDashboard.CharacterStageComplete) + if (!model.CharacterGateSatisfied) { - return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); + return RedirectToReviewCentreWithCharacterGate(batchId); } return View(model); @@ -546,17 +540,17 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard if (model.HasActiveRuns) { - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + return RedirectToBook(model); } - if (!model.AllCommitted) + if (!model.SceneAnalysisComplete) { - return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } - if (!model.PipelineDashboard.CharacterStageComplete) + if (!model.CharacterGateSatisfied) { - return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); + return RedirectToReviewCentreWithCharacterGate(batchId); } return View(model); @@ -594,15 +588,24 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return NotFound(); } - if (!progress.PipelineDashboard.CharacterStageComplete) + if (!progress.CharacterGateSatisfied) { - return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); } var model = await storyIntelligence.GetCompletionAsync(batchId); return model is null ? NotFound() : View(model); } + private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model) + => RedirectToAction("Details", "Books", new { id = model.BookID }); + + private IActionResult RedirectToReviewCentreWithCharacterGate(Guid batchId) + { + TempData["OnboardingStoryIntelligenceMessage"] = "Resolve Characters before reviewing this category."; + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId }); + } + [HttpPost("scan-review")] [RequestSizeLimit(25 * 1024 * 1024)] [RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)] diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 15a57fa..aa4f342 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -1382,13 +1382,19 @@ public sealed class BookService( } var isInProgress = string.Equals(pipeline.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase); + var needsCharacterReview = string.Equals(pipeline.CurrentReviewStage, StoryIntelligencePipelineStages.CharacterReview, StringComparison.OrdinalIgnoreCase) + || string.Equals(pipeline.CurrentStage, StoryIntelligencePipelineStages.CharacterReview, StringComparison.OrdinalIgnoreCase); return new StoryIntelligenceBookActionViewModel { HasCanonicalSceneSources = hasCanonicalSceneSources, CanStart = false, CanContinue = !isInProgress, - StatusLabel = pipeline.IsComplete ? "Complete" : isInProgress ? "Analysis in progress" : pipeline.Status, - ActionLabel = pipeline.IsComplete ? "Open Review Centre" : "Continue review", + StatusLabel = pipeline.IsComplete + ? "Review complete" + : isInProgress + ? "Analysis in progress" + : needsCharacterReview ? "Character review required" : "Needs review", + ActionLabel = isInProgress ? "Analysis running" : "Open Review Centre", Description = pipeline.IsComplete ? "Story Intelligence review is saved for this book." : isInProgress diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index c7da453..9198193 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -664,7 +664,8 @@ public sealed class OnboardingStoryIntelligenceService( PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel { ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), - ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated), + ScenesImported = Math.Max(chapters.Sum(chapter => chapter.ScenesCreated), chapters.Sum(chapter => chapter.CompletedScenes ?? 0)), + SceneAnalysisComplete = pipeline is not null && SceneAnalysisComplete(pipeline), CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count, CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked), CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete, @@ -1069,22 +1070,9 @@ public sealed class OnboardingStoryIntelligenceService( await batchStore.SaveAsync(batch); - var route = characterStageComplete + var route = SceneAnalysisComplete(state) ? StoryIntelligenceResumeRoutes.ReviewCentre - : state.CurrentStage switch - { - StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships, - StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships, - StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge, - StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge, - StoryIntelligencePipelineStages.NarrativeReview => StoryIntelligenceResumeRoutes.Narrative, - StoryIntelligencePipelineStages.NarrativeImport => StoryIntelligenceResumeRoutes.Narrative, - StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, - StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters, - StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview, - StoryIntelligencePipelineStages.SceneImport => StoryIntelligenceResumeRoutes.SceneReview, - _ => StoryIntelligenceResumeRoutes.SceneReview - }; + : StoryIntelligenceResumeRoutes.SceneReview; return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route); } @@ -1092,6 +1080,22 @@ public sealed class OnboardingStoryIntelligenceService( private static bool HasCompletedStage(StoryIntelligenceBookPipelineState state, string stage) => string.Equals(state.LastCompletedStage, stage, StringComparison.OrdinalIgnoreCase); + private static bool SceneAnalysisComplete(StoryIntelligenceBookPipelineState state) + => HasCompletedStage(state, StoryIntelligencePipelineStages.SceneImport) + || state.CurrentStage is StoryIntelligencePipelineStages.CharacterReview + or StoryIntelligencePipelineStages.CharacterImport + or StoryIntelligencePipelineStages.LocationReview + or StoryIntelligencePipelineStages.LocationImport + or StoryIntelligencePipelineStages.AssetReview + or StoryIntelligencePipelineStages.AssetImport + or StoryIntelligencePipelineStages.RelationshipReview + or StoryIntelligencePipelineStages.RelationshipImport + or StoryIntelligencePipelineStages.KnowledgeReview + or StoryIntelligencePipelineStages.KnowledgeImport + or StoryIntelligencePipelineStages.NarrativeReview + or StoryIntelligencePipelineStages.NarrativeImport + or StoryIntelligencePipelineStages.Complete; + private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId) { var state = await onboardingRepository.GetAsync(userId); diff --git a/PlotLine/Services/StoryIntelligenceAssetImportService.cs b/PlotLine/Services/StoryIntelligenceAssetImportService.cs index 041154d..f901150 100644 --- a/PlotLine/Services/StoryIntelligenceAssetImportService.cs +++ b/PlotLine/Services/StoryIntelligenceAssetImportService.cs @@ -82,7 +82,7 @@ public sealed class StoryIntelligenceAssetImportService( var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) { - return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing assets." }; + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing assets." }; } var choices = form.Assets @@ -311,14 +311,19 @@ public sealed class StoryIntelligenceAssetImportService( foreach (var item in batch.Items) { - var commit = await runs.GetImportCommitAsync(item.RunID); - if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + var run = await runs.GetRunAsync(item.RunID); + if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) + { + continue; + } + + var sceneResults = await runs.ListSceneResultsAsync(item.RunID); + if (sceneResults.Count == 0) { continue; } hasCommittedScenes = true; - var sceneResults = await runs.ListSceneResultsAsync(item.RunID); var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue) ? chapterScenes diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index c03dc77..c20d093 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -184,7 +184,7 @@ public sealed class StoryIntelligenceCharacterImportService( var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) { - return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing characters." }; + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing characters." }; } var choices = form.Characters @@ -416,14 +416,19 @@ public sealed class StoryIntelligenceCharacterImportService( foreach (var item in batch.Items) { - var commit = await runs.GetImportCommitAsync(item.RunID); - if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + var run = await runs.GetRunAsync(item.RunID); + if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) + { + continue; + } + + var sceneResults = await runs.ListSceneResultsAsync(item.RunID); + if (sceneResults.Count == 0) { continue; } hasCommittedScenes = true; - var sceneResults = await runs.ListSceneResultsAsync(item.RunID); var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue) ? chapterScenes diff --git a/PlotLine/Services/StoryIntelligenceImportCommitService.cs b/PlotLine/Services/StoryIntelligenceImportCommitService.cs index e094a8b..fdcebee 100644 --- a/PlotLine/Services/StoryIntelligenceImportCommitService.cs +++ b/PlotLine/Services/StoryIntelligenceImportCommitService.cs @@ -197,7 +197,7 @@ public sealed class StoryIntelligenceImportCommitService( if (importScenes.Count == 0) { - blockers.Add("No scenes are ready to create for this chapter."); + blockers.Add("No analysed scene results are available for this chapter."); } var readiness = run.ChapterID.HasValue diff --git a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs index b07fa08..3131ff8 100644 --- a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs +++ b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs @@ -70,7 +70,7 @@ public sealed class StoryIntelligenceKnowledgeImportService( var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates); if (!data.HasCommittedScenes) { - return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing knowledge." }; + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing knowledge." }; } var choices = form.Knowledge @@ -227,14 +227,19 @@ public sealed class StoryIntelligenceKnowledgeImportService( foreach (var item in batch.Items) { - var commit = await runs.GetImportCommitAsync(item.RunID); - if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + var run = await runs.GetRunAsync(item.RunID); + if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) + { + continue; + } + + var sceneResults = await runs.ListSceneResultsAsync(item.RunID); + if (sceneResults.Count == 0) { continue; } hasCommittedScenes = true; - var sceneResults = await runs.ListSceneResultsAsync(item.RunID); var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue) ? chapterScenes diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index 530cf0a..3e65293 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -92,7 +92,7 @@ public sealed class StoryIntelligenceLocationImportService( var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) { - return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing locations." }; + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing locations." }; } var choices = form.Locations @@ -269,14 +269,19 @@ public sealed class StoryIntelligenceLocationImportService( foreach (var item in batch.Items) { - var commit = await runs.GetImportCommitAsync(item.RunID); - if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + var run = await runs.GetRunAsync(item.RunID); + if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) + { + continue; + } + + var sceneResults = await runs.ListSceneResultsAsync(item.RunID); + if (sceneResults.Count == 0) { continue; } hasCommittedScenes = true; - var sceneResults = await runs.ListSceneResultsAsync(item.RunID); var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue) ? chapterScenes diff --git a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs index ec5850d..15b4505 100644 --- a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs +++ b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs @@ -293,13 +293,18 @@ public sealed class StoryIntelligenceNarrativeReviewService( foreach (var item in batch.Items) { - var commit = await runs.GetImportCommitAsync(item.RunID); - if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + var run = await runs.GetRunAsync(item.RunID); + if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) { continue; } var sceneResults = await runs.ListSceneResultsAsync(item.RunID); + if (sceneResults.Count == 0) + { + continue; + } + var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue) ? chapterScenes @@ -425,8 +430,14 @@ public sealed class StoryIntelligenceNarrativeReviewService( { foreach (var item in batch.Items) { - var commit = await runs.GetImportCommitAsync(item.RunID); - if (commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + var run = await runs.GetRunAsync(item.RunID); + if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) + { + continue; + } + + var sceneResults = await runs.ListSceneResultsAsync(item.RunID); + if (sceneResults.Count > 0) { return true; } diff --git a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs index 49b946c..d974234 100644 --- a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs +++ b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs @@ -65,7 +65,7 @@ public sealed class StoryIntelligenceRelationshipImportService( var data = await BuildCandidateDataAsync(batch); if (!data.HasCommittedScenes) { - return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing relationships." }; + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing relationships." }; } var choices = form.Relationships @@ -261,14 +261,19 @@ public sealed class StoryIntelligenceRelationshipImportService( foreach (var item in batch.Items) { - var commit = await runs.GetImportCommitAsync(item.RunID); - if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + var run = await runs.GetRunAsync(item.RunID); + if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) + { + continue; + } + + var sceneResults = await runs.ListSceneResultsAsync(item.RunID); + if (sceneResults.Count == 0) { continue; } hasCommittedScenes = true; - var sceneResults = await runs.ListSceneResultsAsync(item.RunID); var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue) ? chapterScenes diff --git a/PlotLine/Services/StoryIntelligenceService.cs b/PlotLine/Services/StoryIntelligenceService.cs index 9d46b55..2d2d707 100644 --- a/PlotLine/Services/StoryIntelligenceService.cs +++ b/PlotLine/Services/StoryIntelligenceService.cs @@ -182,7 +182,7 @@ public sealed class StoryIntelligenceService( ActiveImportProgressLabel = $"Chapter {currentChapter:N0} of {activeBatch.Items.Count:N0}", Title = "Story Intelligence is reading", Description = "PlotDirector is analysing your manuscript in the background.", - ButtonText = "View progress", + ButtonText = "Open book", PipelineBooks = pipelineBooks }; } @@ -303,7 +303,7 @@ public sealed class StoryIntelligenceService( StoryIntelligencePipelineStages.AssetReview => "Open the Review Centre and choose which saved category to handle.", StoryIntelligencePipelineStages.LocationReview => "Open the Review Centre and choose which saved category to handle.", StoryIntelligencePipelineStages.CharacterReview => "Resolve character identities before reviewing dependent story intelligence.", - StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.", + StoryIntelligencePipelineStages.SceneReview => "Story Intelligence analysis has not finished for this book.", _ when StoryIntelligenceReadyForKnowledge(state) => "Open the Review Centre and choose which saved category to handle.", _ when StoryIntelligenceReadyForRelationships(state) => "Open the Review Centre and choose which saved category to handle.", _ when StoryIntelligenceReadyForAssets(state) => "Open the Review Centre and choose which saved category to handle.", diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 686cefa..6106389 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -167,6 +167,7 @@ public sealed class StoryIntelligenceProgressViewModel public int FailedChapterCount => Chapters.Count(chapter => chapter.IsFailed); public int TotalDetectedScenes => Chapters.Sum(chapter => chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes ?? 0); public int TotalCompletedScenes => Chapters.Sum(chapter => chapter.CompletedScenes ?? 0); + public int TotalAnalysedScenes => Math.Max(TotalCompletedScenes, PipelineDashboard.ScenesImported); public int TotalFailedScenes => Chapters.Sum(chapter => chapter.FailedScenes ?? 0); public int TotalAnalysedWords => Chapters.Sum(chapter => chapter.SourceWordCount ?? 0); public decimal? TotalEstimatedCostUSD => Chapters.Any(chapter => chapter.EstimatedCostUSD.HasValue) ? Chapters.Sum(chapter => chapter.EstimatedCostUSD ?? 0m) : null; @@ -174,6 +175,8 @@ public sealed class StoryIntelligenceProgressViewModel public bool HasActiveRuns => Chapters.Any(chapter => chapter.IsActive); public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit); public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit); + public bool SceneAnalysisComplete => PipelineDashboard.SceneAnalysisComplete + || (Chapters.Count > 0 && !HasActiveRuns && Chapters.All(chapter => chapter.IsRunComplete)); public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0; public bool HasLocationsToReview => LocationReview.Candidates.Count > 0; public bool HasAssetsToReview => AssetReview.Candidates.Count > 0; @@ -201,6 +204,7 @@ public sealed class StoryIntelligencePipelineDashboardViewModel { public int ChaptersAnalysed { get; init; } public int ScenesImported { get; init; } + public bool SceneAnalysisComplete { get; init; } public int CharactersIdentified { get; init; } public int CharactersCreatedOrLinked { get; init; } public bool CharacterStageComplete { get; init; } diff --git a/PlotLine/Views/Books/Details.cshtml b/PlotLine/Views/Books/Details.cshtml index 53d0b05..aefbd00 100644 --- a/PlotLine/Views/Books/Details.cshtml +++ b/PlotLine/Views/Books/Details.cshtml @@ -230,8 +230,8 @@ StoryIntelligencePipelineStages.RelationshipReview => "Open the Review Centre and choose which saved category to handle.", StoryIntelligencePipelineStages.AssetReview => "Open the Review Centre and choose which saved category to handle.", StoryIntelligencePipelineStages.LocationReview => "Open the Review Centre and choose which saved category to handle.", - StoryIntelligencePipelineStages.CharacterReview => "Scene creation is complete. Resolve character identities before reviewing dependent story intelligence.", - StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them in PlotDirector.", + StoryIntelligencePipelineStages.CharacterReview => "Analysis is complete. Resolve character identities before reviewing dependent story intelligence.", + StoryIntelligencePipelineStages.SceneReview => "Story Intelligence analysis has not finished for this book.", _ when StoryIntelligenceReadyForNarrative(pipeline) => "Open the Review Centre and choose which saved category to handle.", _ when StoryIntelligenceReadyForRelationships(pipeline) => "Open the Review Centre and choose which saved category to handle.", _ when StoryIntelligenceReadyForAssets(pipeline) => "Open the Review Centre and choose which saved category to handle.", diff --git a/PlotLine/Views/Onboarding/BuildComplete.cshtml b/PlotLine/Views/Onboarding/BuildComplete.cshtml index 10e755a..a99f655 100644 --- a/PlotLine/Views/Onboarding/BuildComplete.cshtml +++ b/PlotLine/Views/Onboarding/BuildComplete.cshtml @@ -22,10 +22,10 @@ { @:This manuscript structure has already been created in PlotDirector, so nothing was duplicated. Review characters to complete the core import. } - else - { - @:@Model.ScenesCreated.ToString("N0") scenes detected across @Model.ChaptersCreated.ToString("N0") chapters. Keep this browser workflow open to review characters. - } + else + { + @:@Model.ScenesCreated.ToString("N0") scenes imported across @Model.ChaptersCreated.ToString("N0") chapters. Keep this browser workflow open to review characters. + }

@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning)) { diff --git a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml index 349cd48..d5cdd90 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml @@ -27,7 +27,7 @@

Analysis has already started

You can return to progress or open the Review Centre when analysis is ready.

- View progress + Return to book
@@ -72,7 +72,7 @@
1Analyse chapters
-
2Create scene intelligence
+
2Analyse canonical scenes
3Resolve characters
4Open Review Centre
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceContext.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceContext.cshtml index 4cafae1..652a99a 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceContext.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceContext.cshtml @@ -23,7 +23,7 @@

Analysis has already started

You can return to progress or open the Review Centre when analysis is ready.

- View progress + Return to book
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml index e8b6c2b..70429b7 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml @@ -150,8 +150,8 @@ { @(Model.AllCommitted && Model.CharacterGateSatisfied ? "Open Review Centre" : "Create scene intelligence") + asp-action="StoryIntelligenceReviewCentre" + asp-route-batchId="@Model.BatchID">Open Review Centre } }
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml deleted file mode 100644 index 2eda890..0000000 --- a/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml +++ /dev/null @@ -1,326 +0,0 @@ -@model StoryIntelligenceProgressViewModel -@using System.Security.Claims -@inject IConfiguration Configuration -@{ - ViewData["Title"] = "Create scene intelligence"; - var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get() ?? []; - var userEmail = User.FindFirstValue(ClaimTypes.Email); - var isAdmin = !string.IsNullOrWhiteSpace(userEmail) - && adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase)); - var chaptersNeedingReview = Model.Chapters.Count(NeedsReview); - var warningsCount = Model.Chapters.Sum(chapter => NeedsReviewCount(chapter)); -} - -
-
- -
-

Phase 2

-

Create Scene Intelligence

-

Create Scene Intelligence

-

PlotDirector has finished reading your manuscript. Create the stored scene intelligence before opening the Review Centre.

-
- - @if (TempData["OnboardingStoryIntelligenceError"] is string error) - { -
@error
- } - @if (TempData["OnboardingStoryIntelligenceMessage"] is string message) - { -
@message
- } - -
- Scene intelligence is created first. Character resolution is reviewed separately; after that, locations, assets, relationships, knowledge, and Plot Lines can be reviewed independently. -
- -
-
- Story Intelligence complete - @Model.CompletedChapterCount.ToString("N0") chapter@(Model.CompletedChapterCount == 1 ? string.Empty : "s") -
-
- Scenes detected - @Model.TotalDetectedScenes.ToString("N0") -
-
- Words analysed - @Model.TotalAnalysedWords.ToString("N0") -
-
- Elapsed time - @FormatDuration(Model.TotalDurationMs) -
-
- Estimated AI cost - @FormatCost(Model.TotalEstimatedCostUSD) -
-
- Needs review - @warningsCount.ToString("N0") -
-
- -
- @foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber)) - { -
- -
-

Chapter @chapter.ChapterNumber

-

@chapter.ChapterTitle

-

@DetectedCount(chapter).ToString("N0") scene@(DetectedCount(chapter) == 1 ? string.Empty : "s") detected / @ReadyCount(chapter).ToString("N0") ready to create

-
- @FriendlyStatus(chapter) -
- -
-
- Detected scenes - @DetectedCount(chapter).ToString("N0") -
-
- Ready - @ReadyCount(chapter).ToString("N0") -
-
- Needs review - @NeedsReviewCount(chapter).ToString("N0") -
-
- Rejected - @RejectedCount(chapter).ToString("N0") -
-
- - @if (!string.IsNullOrWhiteSpace(chapter.CommitMessage)) - { -

@chapter.CommitMessage

- } - - @if (chapter.Warnings.Count > 0) - { -
- Notes -
    - @foreach (var warning in chapter.Warnings) - { -
  • @AuthorMessage(warning)
  • - } -
-
- } - - @if (chapter.Blockers.Count > 0) - { -
- Before scenes can be created -
    - @foreach (var blocker in chapter.Blockers) - { -
  • @AuthorMessage(blocker)
  • - } -
-
- } - - @if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage)) - { -

@AuthorMessage(chapter.ErrorMessage)

- } - -
- @if (chapter.Scenes.Count == 0) - { -

No readable scene previews are available for this chapter.

- } - else - { - @foreach (var scene in chapter.Scenes) - { -
-
- Scene @scene.SceneNumber.ToString("N0") - @if (scene.HasWarnings) - { - @scene.AnalysisStatus - } -
-

@scene.Summary

-
-
POV
@Display(scene.Pov)
-
Setting
@Display(scene.Setting)
-
Confidence
@FormatConfidence(scene.Confidence)
-
-
- } - } -
- - @if (isAdmin) - { -
- Admin diagnostics -
-
Run ID
@chapter.RunID
-
Status
@chapter.Status
-
Stage
@(chapter.CurrentStage ?? "-")
-
Failure stage
@(chapter.FailureStage ?? "-")
-
Tokens
@(chapter.TotalTokens?.ToString("N0") ?? "-")
-
- Open diagnostics -
- } -
- } -
- -
- Scene creation uses PlotDirector import safeguards. Duplicate imports are blocked, and failures roll back safely. -
- -
- @if (Model.BatchID.HasValue) - { - Back - @if (Model.HasReadyScenesToCreate) - { -
- - -
- } - else if (Model.AllCommitted) - { - @(Model.CharacterGateSatisfied ? "Open Review Centre" : "Resolve characters") - } - } -
-
-
- -@section Scripts { - -} - -@functions { - private static bool NeedsReview(StoryIntelligenceOnboardingChapterViewModel chapter) - => chapter.IsFailed - || chapter.Blockers.Count > 0 - || NeedsReviewCount(chapter) > 0 - || (!chapter.CanCommit && !chapter.HasCompletedCommit && chapter.IsRunComplete); - - private static int DetectedCount(StoryIntelligenceOnboardingChapterViewModel chapter) - => chapter.Scenes.Count > 0 ? chapter.Scenes.Count : chapter.TotalDetectedScenes ?? 0; - - private static int ReadyCount(StoryIntelligenceOnboardingChapterViewModel chapter) - => chapter.HasCompletedCommit ? chapter.ScenesCreated : chapter.Scenes.Count(scene => !scene.HasWarnings); - - private static int RejectedCount(StoryIntelligenceOnboardingChapterViewModel chapter) => 0; - - private static int NeedsReviewCount(StoryIntelligenceOnboardingChapterViewModel chapter) - => chapter.Scenes.Count > 0 - ? chapter.Scenes.Count(scene => scene.HasWarnings) - : Math.Max(0, DetectedCount(chapter) - ReadyCount(chapter) - RejectedCount(chapter)); - - private static string FriendlyStatus(StoryIntelligenceOnboardingChapterViewModel chapter) - { - if (chapter.HasCompletedCommit) - { - return "Scenes created"; - } - - if (chapter.CanCommit) - { - return "Ready"; - } - - return chapter.Status switch - { - StoryIntelligenceRunStatuses.Pending => "Waiting", - StoryIntelligenceRunStatuses.Running => "Reading", - StoryIntelligenceRunStatuses.Completed => "Cannot import", - StoryIntelligenceRunStatuses.CompletedWithWarnings => "Cannot import", - StoryIntelligenceRunStatuses.Failed => "Needs review", - StoryIntelligenceRunStatuses.Cancelled => "Cancelled", - _ => "Needs review" - }; - } - - private static string StatusClass(StoryIntelligenceOnboardingChapterViewModel chapter) - { - if (chapter.HasCompletedCommit) - { - return "story-review-status story-review-status--created"; - } - - if (chapter.CanCommit) - { - return "story-review-status story-review-status--ready"; - } - - return chapter.IsActive - ? "story-review-status story-review-status--active" - : "story-review-status story-review-status--warning"; - } - - private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value; - - private static string AuthorMessage(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return "This chapter needs review before scenes can be created."; - } - - var message = value.Trim(); - if (message.Contains("JSON", StringComparison.OrdinalIgnoreCase) - || message.Contains("parsed", StringComparison.OrdinalIgnoreCase) - || message.Contains("parser", StringComparison.OrdinalIgnoreCase) - || message.Contains("deserial", StringComparison.OrdinalIgnoreCase)) - { - return "One or more scene suggestions could not be prepared automatically and need review."; - } - - if (message.Contains("existing authored scenes", StringComparison.OrdinalIgnoreCase) - || message.Contains("already contains scenes", StringComparison.OrdinalIgnoreCase)) - { - return "This chapter already contains scenes. Choose an empty chapter before creating scenes from this analysis."; - } - - return message; - } - - private static string FormatConfidence(decimal? confidence) - { - if (!confidence.HasValue) - { - return "Not available"; - } - - var value = Math.Clamp(confidence.Value, 0m, 1m); - return value >= 0.75m - ? "High" - : value >= 0.45m - ? "Medium" - : "Low"; - } - - private static string FormatCost(decimal? cost) - => cost.HasValue ? $"About ${cost.Value:0.00}" : "Not available"; - - private static string FormatDuration(long milliseconds) - { - var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)); - if (elapsed.TotalHours >= 1) - { - return $"{Math.Ceiling(elapsed.TotalHours):N0} hr"; - } - - return elapsed.TotalMinutes >= 1 - ? $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min" - : $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec"; - } -} diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceReviewCentre.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceReviewCentre.cshtml index 1d8c2e6..7787e0a 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceReviewCentre.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceReviewCentre.cshtml @@ -10,7 +10,7 @@

Story Intelligence

Review Centre

-

Review only the story intelligence that needs an author decision. Everything here is saved and can be picked up later.

+

Your manuscript analysis is complete. Review only the story intelligence that needs an author decision; everything here is saved and can be picked up later.

@if (TempData["OnboardingStoryIntelligenceError"] is string error) @@ -28,9 +28,9 @@ @ReviewModule( "Characters", Model.CharacterNeedsAttentionCount, - Model.PipelineDashboard.CharacterStageComplete ? "Resolved" : "Needs attention", + Model.CharacterGateSatisfied ? "Resolved" : "Needs attention", "Resolve identity questions before reviewing dependent story intelligence.", - "Review characters", + "Resolve Characters", "StoryIntelligenceCharacters", false) @@ -84,8 +84,16 @@

Scene Metrics

Available from the imported scene analysis. Metrics are auto-applied where configured and do not require a mandatory review step.

- Available - 0 need attention + @if (Model.CharacterGateSatisfied) + { + Available + @Model.TotalAnalysedScenes.ToString("N0") scenes + } + else + { + Resolve Characters first + Locked until Characters are resolved + } @if (Model.AnalysisIssueCount > 0) @@ -96,7 +104,7 @@

Some chapters or scenes reported analysis issues. Canonical chapters and scenes remain intact.

Needs attention - View issues + Return to book @Model.AnalysisIssueCount.ToString("N0") need attention } @@ -126,9 +134,9 @@ : count > 0 ? "story-review-status story-review-status--warning" : "story-review-status story-review-status--ready"; - var statusText = blocked ? "Blocked by character resolution" : status; + var statusText = blocked ? "Resolve Characters first" : status; var countText = blocked - ? "Resolve characters first" + ? "Locked until Characters are resolved" : count == 1 ? "1 needs attention" : $"{count:N0} need attention";