From a1cba8a0576346a11b79f740bd6918ff53271579 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 30 Aug 2026 20:08:17 +0000 Subject: [PATCH] Speed up review centre summary --- PlotLine.Tests/Program.cs | 34 ++++- PlotLine/Controllers/OnboardingController.cs | 16 ++- .../StoryIntelligencePipelineRepository.cs | 10 ++ .../StoryIntelligencePersistenceModels.cs | 37 +++++ .../OnboardingStoryIntelligenceService.cs | 134 ++++++++++++++++++ .../StoryIntelligencePipelineStateService.cs | 4 + ...C_StoryIntelligenceReviewCentreSummary.sql | 132 +++++++++++++++++ PlotLine/ViewModels/OnboardingViewModels.cs | 42 +++--- .../StoryIntelligenceReviewCentre.cshtml | 17 ++- 9 files changed, 397 insertions(+), 29 deletions(-) create mode 100644 PlotLine/Sql/176_Phase27C_StoryIntelligenceReviewCentreSummary.sql diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 17747b1..c9f3cbc 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -278,6 +278,7 @@ var tests = new (string Name, Action Test)[] ("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), + ("Story Intelligence Review Centre uses bounded summary query", StoryIntelligenceReviewCentreUsesBoundedSummaryQuery), ("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution), ("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals), ("Story Intelligence recovered provider failures stay operational", StoryIntelligenceRecoveredProviderFailuresStayOperational), @@ -720,9 +721,9 @@ static void StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories() 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(viewModel.Contains("CharacterNeedsAttentionCount => CharacterReview.SummaryNeedsAttentionCount ?? CharacterReview.Candidates.Count", StringComparison.Ordinal), "Character count should prefer lightweight summary counts."); + Assert(viewModel.Contains("LocationNeedsAttentionCount => LocationReview.SummaryNeedsAttentionCount ?? LocationReview.Candidates.Count", StringComparison.Ordinal), "Location count should prefer lightweight summary counts."); + Assert(viewModel.Contains("NarrativeNeedsAttentionCount => NarrativeReview.SummaryNeedsAttentionCount ?? NarrativeReview.Candidates.Count", StringComparison.Ordinal), "Narrative count should prefer lightweight summary counts."); 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."); } @@ -1048,6 +1049,33 @@ static void StoryIntelligenceReviewCentreAddsNoAiCalls() Assert(!centreView.Contains("OpenAI", StringComparison.Ordinal) && !centreView.Contains("prompt", StringComparison.OrdinalIgnoreCase), "Review Centre view should not introduce prompt work."); } +static void StoryIntelligenceReviewCentreUsesBoundedSummaryQuery() +{ + var root = PlotLineRoot(); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); + var pipelineRepository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs")); + var pipelineService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs")); + var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/176_Phase27C_StoryIntelligenceReviewCentreSummary.sql")); + var centreAction = ExtractBetween(controller, "public async Task StoryIntelligenceReviewCentre", "[HttpGet(\"story-intelligence/characters\")]"); + var centreSummary = ExtractBetween(service, "public async Task GetReviewCentreAsync", "public async Task CancelAsync"); + + Assert(centreAction.Contains("GetReviewCentreAsync(batchId)", StringComparison.Ordinal), "Review Centre GET should use the bounded summary path."); + Assert(!centreAction.Contains("GetProgressAsync(batchId)", StringComparison.Ordinal), "Review Centre GET should not build the full progress/candidate model."); + Assert(centreSummary.Contains("GetReviewCentreSummaryAsync(batch.BookID, batch.UserID)", StringComparison.Ordinal), "Review Centre summary should use one aggregate DB read."); + Assert(!centreSummary.Contains("BuildReviewAsync", StringComparison.Ordinal), "Review Centre summary should not call detailed module candidate builders."); + Assert(!centreSummary.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Review Centre summary should not generate narrative candidates."); + Assert(!centreSummary.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Review Centre summary should not parse scene intelligence results."); + Assert(!centreSummary.Contains("ListByChapterAsync", StringComparison.Ordinal), "Review Centre summary should not rebuild scene object graphs."); + Assert(pipelineRepository.Contains("StoryIntelligenceReviewCentre_GetSummary", StringComparison.Ordinal), "Repository should call the Review Centre aggregate procedure."); + Assert(pipelineService.Contains("GetReviewCentreSummaryAsync(int bookId, int userId)", StringComparison.Ordinal), "Pipeline service should expose summary hydration."); + Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCentre_GetSummary", StringComparison.Ordinal), "Migration should create the aggregate summary procedure."); + Assert(migration.Contains("IX_StoryIntelligenceNarrativeCandidates_Book_Status_Type", StringComparison.Ordinal), "Narrative candidate counts should have a supporting index."); + Assert(!migration.Contains("ParsedJson", StringComparison.Ordinal) && !migration.Contains("OPENJSON", StringComparison.Ordinal), "Summary query should not parse Scene Intelligence JSON."); + Assert(viewModel.Contains("SummaryNeedsAttentionCount", StringComparison.Ordinal), "Tile counts should support lightweight summary values without full candidate objects."); +} + static string PlotLineRoot() => Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 90b219f..e35737d 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; +using System.Diagnostics; namespace PlotLine.Controllers; @@ -12,7 +13,8 @@ public sealed class OnboardingController( IOnboardingService onboarding, IOnboardingStoryIntelligenceService storyIntelligence, IWholeBookPlotIntelligenceService wholeBookPlotIntelligence, - ICurrentUserService currentUser) : Controller + ICurrentUserService currentUser, + ILogger logger) : Controller { [HttpGet("")] public async Task Index() @@ -289,7 +291,8 @@ public sealed class OnboardingController( [HttpGet("story-intelligence/review-centre")] public async Task StoryIntelligenceReviewCentre(Guid batchId) { - var model = await storyIntelligence.GetProgressAsync(batchId); + var stopwatch = Stopwatch.StartNew(); + var model = await storyIntelligence.GetReviewCentreAsync(batchId); if (model is null) { return NotFound(); @@ -305,6 +308,15 @@ public sealed class OnboardingController( return RedirectToBook(model); } + logger.LogInformation( + "Story Intelligence Review Centre GET rendered. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} ElapsedMs={ElapsedMs} ActiveRuns={ActiveRuns} SceneAnalysisComplete={SceneAnalysisComplete}", + batchId, + model.ProjectID, + model.BookID, + stopwatch.ElapsedMilliseconds, + model.HasActiveRuns, + model.SceneAnalysisComplete); + return View(nameof(StoryIntelligenceReviewCentre), model); } diff --git a/PlotLine/Data/StoryIntelligencePipelineRepository.cs b/PlotLine/Data/StoryIntelligencePipelineRepository.cs index bb65ffa..7a82d5a 100644 --- a/PlotLine/Data/StoryIntelligencePipelineRepository.cs +++ b/PlotLine/Data/StoryIntelligencePipelineRepository.cs @@ -9,6 +9,7 @@ public interface IStoryIntelligencePipelineRepository Task GetByIdForUserAsync(int importSessionId, int userId); Task GetByBookAsync(int bookId); Task GetByBookForUserAsync(int bookId, int userId); + Task GetReviewCentreSummaryAsync(int bookId, int userId); Task> ListForUserAsync(int userId); Task UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request); Task> ListCommittedRunsByBookAsync(int bookId, int userId); @@ -46,6 +47,15 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co commandType: CommandType.StoredProcedure); } + public async Task GetReviewCentreSummaryAsync(int bookId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceReviewCentre_GetSummary", + new { BookID = bookId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + public async Task> ListForUserAsync(int userId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index b9249af..98cefa8 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -144,6 +144,43 @@ public sealed class StoryIntelligenceBookPipelineSaveRequest public bool MarkNarrativeReviewComplete { get; init; } } +public sealed class StoryIntelligenceReviewCentreSummary +{ + public int StoryIntelligenceBookPipelineID { get; init; } + public int ProjectID { get; init; } + public string ProjectName { get; init; } = string.Empty; + public int BookID { get; init; } + public string BookTitle { get; init; } = string.Empty; + public string? BookSubtitle { get; init; } + public string BookDisplayTitle => BookTitleFormatter.DisplayTitle(BookTitle, BookSubtitle); + public string CurrentStage { get; init; } = StoryIntelligencePipelineStages.SceneReview; + public string? LastCompletedStage { get; init; } + public string? CurrentReviewStage { get; init; } + public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview; + public DateTime? CompletedUtc { get; init; } + public DateTime? CharacterReviewCompletedUtc { get; init; } + public DateTime? LocationReviewCompletedUtc { get; init; } + public DateTime? AssetReviewCompletedUtc { get; init; } + public DateTime? RelationshipReviewCompletedUtc { get; init; } + public DateTime? KnowledgeReviewCompletedUtc { get; init; } + public DateTime? NarrativeReviewCompletedUtc { get; init; } + public int? LastRunID { get; init; } + public int RunCount { get; init; } + public int CompletedRunCount { get; init; } + public int ActiveRunCount { get; init; } + public int FailedRunCount { get; init; } + public int TotalDetectedScenes { get; init; } + public int CompletedScenes { get; init; } + public int FailedScenes { get; init; } + public int WholeBookPlotSynthesisRunID { get; init; } + public string? WholeBookPlotSynthesisStatus { get; init; } + public string? WholeBookPlotSynthesisErrorMessage { get; init; } + public int NarrativeCandidateCount { get; init; } + public int NarrativePendingCount { get; init; } + public int NarrativeThreadCount { get; init; } + public int NarrativeEventCount { get; init; } +} + public sealed class StoryIntelligenceCommittedRunSummary { public int StoryIntelligenceRunID { get; init; } diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index fc57600..c3312ff 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics; using System.Text.Json; using PlotLine.Data; using PlotLine.Models; @@ -11,6 +12,7 @@ public interface IOnboardingStoryIntelligenceService Task GetOverviewAsync(); Task StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null); Task GetProgressAsync(Guid batchId); + Task GetReviewCentreAsync(Guid batchId); Task CancelAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId); @@ -719,6 +721,122 @@ public sealed class OnboardingStoryIntelligenceService( }; } + public async Task GetReviewCentreAsync(Guid batchId) + { + var totalStopwatch = Stopwatch.StartNew(); + var batch = await batchStore.GetAsync(RequireUserId(), batchId); + if (batch is null) + { + return null; + } + + var summaryStopwatch = Stopwatch.StartNew(); + var summary = await pipelineState.GetReviewCentreSummaryAsync(batch.BookID, batch.UserID); + summaryStopwatch.Stop(); + if (summary is null) + { + return null; + } + + var characterComplete = summary.CharacterReviewCompletedUtc is not null; + var locationComplete = summary.LocationReviewCompletedUtc is not null; + var assetComplete = summary.AssetReviewCompletedUtc is not null; + var relationshipComplete = summary.RelationshipReviewCompletedUtc is not null; + var knowledgeComplete = summary.KnowledgeReviewCompletedUtc is not null; + var narrativeComplete = summary.NarrativeReviewCompletedUtc is not null; + var sceneAnalysisComplete = SceneAnalysisComplete(summary); + var narrativePendingCount = narrativeComplete ? 0 : summary.NarrativePendingCount; + var hasCommittedScenes = summary.CompletedRunCount > 0 || summary.CompletedScenes > 0; + + var model = new StoryIntelligenceProgressViewModel + { + BatchID = batch.BatchID, + PreviewID = batch.PreviewID, + ImportSessionID = summary.StoryIntelligenceBookPipelineID, + ProjectID = summary.ProjectID, + BookID = summary.BookID, + ProjectName = summary.ProjectName, + BookTitle = summary.BookDisplayTitle, + CharacterReview = new StoryIntelligenceCharacterReviewViewModel + { + HasCommittedScenes = hasCommittedScenes, + CanImport = !characterComplete, + IsComplete = characterComplete, + SummaryNeedsAttentionCount = characterComplete ? 0 : null + }, + LocationReview = new StoryIntelligenceLocationReviewViewModel + { + HasCommittedScenes = hasCommittedScenes, + CanImport = !locationComplete, + IsComplete = locationComplete, + SummaryNeedsAttentionCount = locationComplete ? 0 : null + }, + AssetReview = new StoryIntelligenceAssetReviewViewModel + { + HasCommittedScenes = hasCommittedScenes, + CanImport = !assetComplete, + IsComplete = assetComplete, + SummaryNeedsAttentionCount = assetComplete ? 0 : null + }, + RelationshipReview = new StoryIntelligenceRelationshipReviewViewModel + { + HasCommittedScenes = hasCommittedScenes, + CanImport = !relationshipComplete, + IsComplete = relationshipComplete, + SummaryNeedsAttentionCount = relationshipComplete ? 0 : null + }, + KnowledgeReview = new StoryIntelligenceKnowledgeReviewViewModel + { + HasCommittedScenes = hasCommittedScenes, + CanImport = !knowledgeComplete, + IsComplete = knowledgeComplete, + SummaryNeedsAttentionCount = knowledgeComplete ? 0 : null + }, + NarrativeReview = new StoryIntelligenceNarrativeReviewViewModel + { + HasCommittedScenes = hasCommittedScenes, + CanImport = narrativePendingCount > 0, + IsComplete = narrativeComplete, + SummaryNeedsAttentionCount = narrativePendingCount + }, + PlotSynthesis = new WholeBookPlotSynthesisStatusViewModel + { + WholeBookPlotSynthesisRunID = summary.WholeBookPlotSynthesisRunID == 0 ? null : summary.WholeBookPlotSynthesisRunID, + Status = summary.WholeBookPlotSynthesisStatus ?? string.Empty, + CandidateCount = summary.NarrativeCandidateCount, + ThreadCount = summary.NarrativeThreadCount, + EventCount = summary.NarrativeEventCount, + ErrorMessage = summary.WholeBookPlotSynthesisErrorMessage + }, + PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel + { + ChaptersAnalysed = summary.CompletedRunCount, + ScenesImported = summary.CompletedScenes, + SceneAnalysisComplete = sceneAnalysisComplete, + CharacterStageComplete = characterComplete, + LocationStageComplete = locationComplete, + AssetStageComplete = assetComplete, + RelationshipStageComplete = relationshipComplete, + KnowledgeStageComplete = knowledgeComplete, + NarrativeCandidatesIdentified = summary.NarrativeCandidateCount, + NarrativeStageComplete = narrativeComplete + } + }; + + logger.LogInformation( + "Story Intelligence Review Centre summary built. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} SummaryMs={SummaryMs} TotalMs={TotalMs} ActiveRunCount={ActiveRunCount} NarrativeCandidates={NarrativeCandidateCount} NarrativePending={NarrativePendingCount}", + batch.BatchID, + summary.ProjectID, + summary.BookID, + summaryStopwatch.ElapsedMilliseconds, + totalStopwatch.ElapsedMilliseconds, + summary.ActiveRunCount, + summary.NarrativeCandidateCount, + summary.NarrativePendingCount); + + return model; + } + public async Task CancelAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); @@ -1086,6 +1204,22 @@ public sealed class OnboardingStoryIntelligenceService( or StoryIntelligencePipelineStages.NarrativeImport or StoryIntelligencePipelineStages.Complete; + private static bool SceneAnalysisComplete(StoryIntelligenceReviewCentreSummary state) + => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.SceneImport, StringComparison.OrdinalIgnoreCase) + || 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/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs index 36824e2..e14a290 100644 --- a/PlotLine/Services/StoryIntelligencePipelineStateService.cs +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -6,6 +6,7 @@ namespace PlotLine.Services; public interface IStoryIntelligencePipelineStateService { Task GetForBookAsync(int bookId, int userId); + Task GetReviewCentreSummaryAsync(int bookId, int userId); Task> ListForUserAsync(int userId); Task EnsureForBookAsync(int bookId, int userId); Task RecordChapterAnalysisQueuedAsync(int projectId, int bookId, int runId); @@ -29,6 +30,9 @@ public sealed class StoryIntelligencePipelineStateService( => await pipelines.GetByBookForUserAsync(bookId, userId) ?? await EnsureForBookAsync(bookId, userId); + public Task GetReviewCentreSummaryAsync(int bookId, int userId) + => pipelines.GetReviewCentreSummaryAsync(bookId, userId); + public Task> ListForUserAsync(int userId) => pipelines.ListForUserAsync(userId); diff --git a/PlotLine/Sql/176_Phase27C_StoryIntelligenceReviewCentreSummary.sql b/PlotLine/Sql/176_Phase27C_StoryIntelligenceReviewCentreSummary.sql new file mode 100644 index 0000000..35b2968 --- /dev/null +++ b/PlotLine/Sql/176_Phase27C_StoryIntelligenceReviewCentreSummary.sql @@ -0,0 +1,132 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF NOT EXISTS +( + SELECT 1 + FROM sys.indexes + WHERE name = N'IX_StoryIntelligenceNarrativeCandidates_Book_Status_Type' + AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidates') +) +BEGIN + CREATE INDEX IX_StoryIntelligenceNarrativeCandidates_Book_Status_Type + ON dbo.StoryIntelligenceNarrativeCandidates(BookID, Status, CandidateType) + INCLUDE (WholeBookPlotSynthesisRunID, AcceptedPlotLineID, AcceptedPlotThreadID); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCentre_GetSummary + @BookID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + ;WITH RunSummary AS + ( + SELECT + r.BookID, + COUNT_BIG(*) AS RunCount, + SUM(CASE WHEN r.Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END) AS CompletedRunCount, + SUM(CASE WHEN r.Status IN (N'Pending', N'Running') THEN 1 ELSE 0 END) AS ActiveRunCount, + SUM(CASE WHEN r.Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) AS FailedRunCount, + SUM(COALESCE(r.TotalDetectedScenes, 0)) AS TotalDetectedScenes, + SUM(COALESCE(r.CompletedScenes, 0)) AS CompletedScenes, + SUM(COALESCE(r.FailedScenes, 0)) AS FailedScenes + FROM dbo.StoryIntelligenceRuns r + WHERE r.BookID = @BookID + GROUP BY r.BookID + ), + NarrativeSummary AS + ( + SELECT + nc.BookID, + COUNT_BIG(*) AS NarrativeCandidateCount, + SUM(CASE WHEN nc.Status = N'Pending' THEN 1 ELSE 0 END) AS NarrativePendingCount, + SUM(CASE WHEN nc.CandidateType = N'Thread' THEN 1 ELSE 0 END) AS NarrativeThreadCount + FROM dbo.StoryIntelligenceNarrativeCandidates nc + WHERE nc.BookID = @BookID + AND nc.WholeBookPlotSynthesisRunID IS NOT NULL + GROUP BY nc.BookID + ), + NarrativeEventSummary AS + ( + SELECT + nc.BookID, + COUNT_BIG(*) AS NarrativeEventCount + FROM dbo.StoryIntelligenceNarrativeCandidateEvents ev + INNER JOIN dbo.StoryIntelligenceNarrativeCandidates nc + ON nc.NarrativeCandidateID = ev.NarrativeCandidateID + WHERE nc.BookID = @BookID + AND nc.WholeBookPlotSynthesisRunID IS NOT NULL + GROUP BY nc.BookID + ), + CurrentPlotRun AS + ( + SELECT TOP (1) + run.BookID, + run.WholeBookPlotSynthesisRunID, + run.Status AS WholeBookPlotSynthesisStatus, + run.ErrorMessage AS WholeBookPlotSynthesisErrorMessage + FROM dbo.WholeBookPlotSynthesisRuns run + WHERE run.BookID = @BookID + AND run.IsCurrent = 1 + ORDER BY run.WholeBookPlotSynthesisRunID DESC + ) + SELECT + p.StoryIntelligenceBookPipelineID, + p.ProjectID, + project.ProjectName, + p.BookID, + book.BookTitle, + book.Subtitle AS BookSubtitle, + p.CurrentStage, + p.LastCompletedStage, + p.CurrentReviewStage, + p.Status, + p.CompletedUtc, + p.CharacterReviewCompletedUtc, + p.LocationReviewCompletedUtc, + p.AssetReviewCompletedUtc, + p.RelationshipReviewCompletedUtc, + p.KnowledgeReviewCompletedUtc, + p.NarrativeReviewCompletedUtc, + p.LastRunID, + CAST(COALESCE(rs.RunCount, 0) AS int) AS RunCount, + CAST(COALESCE(rs.CompletedRunCount, 0) AS int) AS CompletedRunCount, + CAST(COALESCE(rs.ActiveRunCount, 0) AS int) AS ActiveRunCount, + CAST(COALESCE(rs.FailedRunCount, 0) AS int) AS FailedRunCount, + CAST(COALESCE(rs.TotalDetectedScenes, 0) AS int) AS TotalDetectedScenes, + CAST(COALESCE(rs.CompletedScenes, 0) AS int) AS CompletedScenes, + CAST(COALESCE(rs.FailedScenes, 0) AS int) AS FailedScenes, + plot.WholeBookPlotSynthesisRunID, + plot.WholeBookPlotSynthesisStatus, + plot.WholeBookPlotSynthesisErrorMessage, + CAST(COALESCE(ns.NarrativeCandidateCount, 0) AS int) AS NarrativeCandidateCount, + CAST(COALESCE(ns.NarrativePendingCount, 0) AS int) AS NarrativePendingCount, + CAST(COALESCE(ns.NarrativeThreadCount, 0) AS int) AS NarrativeThreadCount, + CAST(COALESCE(nes.NarrativeEventCount, 0) AS int) AS NarrativeEventCount + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.Projects project + ON project.ProjectID = p.ProjectID + INNER JOIN dbo.Books book + ON book.BookID = p.BookID + INNER JOIN dbo.ProjectUserAccess access + ON access.ProjectID = p.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + LEFT JOIN RunSummary rs + ON rs.BookID = p.BookID + LEFT JOIN NarrativeSummary ns + ON ns.BookID = p.BookID + LEFT JOIN NarrativeEventSummary nes + ON nes.BookID = p.BookID + LEFT JOIN CurrentPlotRun plot + ON plot.BookID = p.BookID + WHERE p.BookID = @BookID + AND book.IsArchived = 0 + AND project.IsArchived = 0; +END; +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 343db52..011e13f 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -193,26 +193,26 @@ public sealed class StoryIntelligenceProgressViewModel 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; - 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 bool HasCharactersToReview => CharacterNeedsAttentionCount.GetValueOrDefault() > 0; + public bool HasLocationsToReview => LocationNeedsAttentionCount.GetValueOrDefault() > 0; + public bool HasAssetsToReview => AssetNeedsAttentionCount.GetValueOrDefault() > 0; + public bool HasRelationshipsToReview => RelationshipNeedsAttentionCount.GetValueOrDefault() > 0; + public bool HasKnowledgeToReview => KnowledgeNeedsAttentionCount.GetValueOrDefault() > 0; + public bool HasNarrativeToReview => NarrativeNeedsAttentionCount.GetValueOrDefault() > 0; + public int? CharacterNeedsAttentionCount => CharacterReview.SummaryNeedsAttentionCount ?? CharacterReview.Candidates.Count; + public int? LocationNeedsAttentionCount => LocationReview.SummaryNeedsAttentionCount ?? LocationReview.Candidates.Count; + public int? AssetNeedsAttentionCount => AssetReview.SummaryNeedsAttentionCount ?? AssetReview.Candidates.Count; + public int? RelationshipNeedsAttentionCount => RelationshipReview.SummaryNeedsAttentionCount ?? RelationshipReview.Candidates.Count; + public int? KnowledgeNeedsAttentionCount => KnowledgeReview.SummaryNeedsAttentionCount ?? KnowledgeReview.Candidates.Count; + public int? NarrativeNeedsAttentionCount => NarrativeReview.SummaryNeedsAttentionCount ?? 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 + public int TotalReviewNeedsAttention => (CharacterNeedsAttentionCount ?? 0) + + (LocationNeedsAttentionCount ?? 0) + + (AssetNeedsAttentionCount ?? 0) + + (RelationshipNeedsAttentionCount ?? 0) + + (KnowledgeNeedsAttentionCount ?? 0) + + (NarrativeNeedsAttentionCount ?? 0) + AnalysisIssueCount; } @@ -260,6 +260,7 @@ public sealed class StoryIntelligenceNarrativeReviewViewModel public bool CanImport { get; init; } public bool HasCommittedScenes { get; init; } public bool IsComplete { get; init; } + public int? SummaryNeedsAttentionCount { get; init; } public IReadOnlyList Candidates { get; init; } = []; public IReadOnlyList PlotLineCandidates { get; init; } = []; public IReadOnlyList UnassignedThreadCandidates { get; init; } = []; @@ -362,6 +363,7 @@ public sealed class StoryIntelligenceCharacterReviewViewModel public bool HasCommittedScenes { get; init; } public int AlreadyLinkedCount { get; init; } public bool IsComplete { get; init; } + public int? SummaryNeedsAttentionCount { get; init; } public int GenericReferenceCount { get; init; } public int ExistingMatchCount { get; init; } public int NewCandidateCount { get; init; } @@ -437,6 +439,7 @@ public sealed class StoryIntelligenceLocationReviewViewModel public bool HasCommittedScenes { get; init; } public int AlreadyLinkedCount { get; init; } public bool IsComplete { get; init; } + public int? SummaryNeedsAttentionCount { get; init; } public IReadOnlyList Candidates { get; init; } = []; } @@ -486,6 +489,7 @@ public sealed class StoryIntelligenceAssetReviewViewModel public bool HasCommittedScenes { get; init; } public int AlreadyLinkedCount { get; init; } public bool IsComplete { get; init; } + public int? SummaryNeedsAttentionCount { get; init; } public IReadOnlyList Candidates { get; init; } = []; } @@ -535,6 +539,7 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel public bool HasCommittedScenes { get; init; } public int AlreadyLinkedCount { get; init; } public bool IsComplete { get; init; } + public int? SummaryNeedsAttentionCount { get; init; } public IReadOnlyList RelationshipTypeOptions { get; init; } = []; public IReadOnlyList Candidates { get; init; } = []; } @@ -586,6 +591,7 @@ public sealed class StoryIntelligenceKnowledgeReviewViewModel public bool HasCommittedScenes { get; init; } public int AlreadyLinkedCount { get; init; } public bool IsComplete { get; init; } + public int? SummaryNeedsAttentionCount { get; init; } public IReadOnlyList KnowledgeStateOptions { get; init; } = []; public IReadOnlyList Candidates { get; init; } = []; } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceReviewCentre.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceReviewCentre.cshtml index 89d5830..ad77980 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceReviewCentre.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceReviewCentre.cshtml @@ -94,8 +94,8 @@ @functions { - private string ModuleAction(string title, int count, bool complete) - => count > 0 && !complete ? $"Review {title}" : $"View {title}"; + private string ModuleAction(string title, int? count, bool complete) + => count.GetValueOrDefault() > 0 || (!complete && !count.HasValue) ? $"Review {title}" : $"View {title}"; private Microsoft.AspNetCore.Html.IHtmlContent ReviewPlotSynthesis() { @@ -134,7 +134,7 @@ """; } - else if (Model.PlotSynthesis.IsCompleted && Model.NarrativeNeedsAttentionCount > 0) + else if (Model.PlotSynthesis.IsCompleted && Model.NarrativeNeedsAttentionCount.GetValueOrDefault() > 0) { status = $"{Model.NarrativeNeedsAttentionCount:N0} need attention"; statusClass = "story-review-centre-tile__status story-review-centre-tile__status--warning"; @@ -168,7 +168,7 @@ """); } - private string StatusText(string title, int count, bool complete, bool blocked) + private string StatusText(string title, int? count, bool complete, bool blocked) { if (blocked) { @@ -185,6 +185,11 @@ return "Complete"; } + if (!count.HasValue) + { + return "Ready"; + } + return count switch { 0 => "No review needed", @@ -217,7 +222,7 @@ private Microsoft.AspNetCore.Html.IHtmlContent ReviewModule( string title, - int count, + int? count, bool complete, bool blocked, string description, @@ -228,7 +233,7 @@ var status = StatusText(title, count, complete, blocked); var statusClass = blocked ? "story-review-centre-tile__status story-review-centre-tile__status--locked" - : count > 0 && !complete + : (count.GetValueOrDefault() > 0 || (!complete && !count.HasValue)) ? "story-review-centre-tile__status story-review-centre-tile__status--warning" : "story-review-centre-tile__status story-review-centre-tile__status--ready"; var tileClass = "story-review-centre-tile"