Speed up review centre summary

This commit is contained in:
Nick Beckley 2026-08-30 20:08:17 +00:00
parent 5a7b8c79c7
commit a1cba8a057
9 changed files with 397 additions and 29 deletions

View File

@ -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<IActionResult> StoryIntelligenceReviewCentre", "[HttpGet(\"story-intelligence/characters\")]");
var centreSummary = ExtractBetween(service, "public async Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync", "public async Task<StoryIntelligenceProgressViewModel?> 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");

View File

@ -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<OnboardingController> logger) : Controller
{
[HttpGet("")]
public async Task<IActionResult> Index()
@ -289,7 +291,8 @@ public sealed class OnboardingController(
[HttpGet("story-intelligence/review-centre")]
public async Task<IActionResult> 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);
}

View File

@ -9,6 +9,7 @@ public interface IStoryIntelligencePipelineRepository
Task<StoryIntelligenceBookPipelineState?> GetByIdForUserAsync(int importSessionId, int userId);
Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId);
Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId);
Task<StoryIntelligenceReviewCentreSummary?> GetReviewCentreSummaryAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
@ -46,6 +47,15 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceReviewCentreSummary?> GetReviewCentreSummaryAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceReviewCentreSummary>(
"dbo.StoryIntelligenceReviewCentre_GetSummary",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -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; }

View File

@ -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<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceProgressViewModel?> 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);

View File

@ -6,6 +6,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligencePipelineStateService
{
Task<StoryIntelligenceBookPipelineState?> GetForBookAsync(int bookId, int userId);
Task<StoryIntelligenceReviewCentreSummary?> GetReviewCentreSummaryAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState?> 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<StoryIntelligenceReviewCentreSummary?> GetReviewCentreSummaryAsync(int bookId, int userId)
=> pipelines.GetReviewCentreSummaryAsync(bookId, userId);
public Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId)
=> pipelines.ListForUserAsync(userId);

View File

@ -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

View File

@ -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<StoryIntelligenceNarrativeReviewCandidateViewModel> Candidates { get; init; } = [];
public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> PlotLineCandidates { get; init; } = [];
public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> 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<StoryIntelligenceLocationReviewCandidateViewModel> 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<StoryIntelligenceAssetReviewCandidateViewModel> 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<SelectListItem> RelationshipTypeOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> 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<SelectListItem> KnowledgeStateOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceKnowledgeReviewCandidateViewModel> Candidates { get; init; } = [];
}

View File

@ -94,8 +94,8 @@
</section>
@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 @@
</form>
""";
}
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"