From 0c3f2155741edb59c5b06c9bb05fe1e7a8f50e0b Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 30 Aug 2026 22:47:33 +0000 Subject: [PATCH] Fix story intelligence module completion lifecycle --- PlotLine.Tests/Program.cs | 65 +++++++ .../StoryIntelligencePersistenceModels.cs | 15 ++ .../OnboardingStoryIntelligenceService.cs | 79 ++++++-- .../StoryIntelligenceAssetImportService.cs | 9 - ...StoryIntelligenceCharacterImportService.cs | 7 - ...StoryIntelligenceKnowledgeImportService.cs | 9 - .../StoryIntelligenceLocationImportService.cs | 5 - ...ryIntelligenceRelationshipImportService.cs | 9 - ...StoryIntelligenceReviewCandidateService.cs | 48 ++++- ..._ReviewCentreCandidateLifecycleSummary.sql | 175 ++++++++++++++++++ 10 files changed, 362 insertions(+), 59 deletions(-) create mode 100644 PlotLine/Sql/179_Phase27F_ReviewCentreCandidateLifecycleSummary.sql diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 077d635..f19acaf 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -284,6 +284,7 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence completed Character review remains inspectable", StoryIntelligenceCompletedCharacterReviewRemainsInspectable), ("Story Intelligence Review Centre completed modules use canonical destinations", StoryIntelligenceReviewCentreCompletedModulesUseCanonicalDestinations), ("Story Intelligence detailed reviews read persisted candidate pages", StoryIntelligenceDetailedReviewsReadPersistedCandidatePages), + ("Story Intelligence module completion lifecycle uses persisted candidates", StoryIntelligenceModuleCompletionLifecycleUsesPersistedCandidates), ("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), @@ -1254,6 +1255,70 @@ static void StoryIntelligenceDetailedReviewsReadPersistedCandidatePages() Assert(program.Contains("IStoryIntelligenceReviewCandidateService, StoryIntelligenceReviewCandidateService", StringComparison.Ordinal), "Persisted candidate service should be registered."); } +static void StoryIntelligenceModuleCompletionLifecycleUsesPersistedCandidates() +{ + var root = PlotLineRoot(); + var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs")); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); + var persistence = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs")); + var summarySql = File.ReadAllText(Path.Combine(root, "Sql/179_Phase27F_ReviewCentreCandidateLifecycleSummary.sql")); + var characterImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + var locationImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs")); + var assetImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceAssetImportService.cs")); + var relationshipImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceRelationshipImportService.cs")); + var knowledgeImport = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceKnowledgeImportService.cs")); + + Assert(candidateService.Contains("Task CompleteModuleIfReadyAsync(OnboardingStoryIntelligenceBatch batch, string module)", StringComparison.Ordinal), "Candidate service should expose the shared persisted completion lifecycle."); + Assert(candidateService.Contains("await candidates.CompleteGenerationAsync(generation.ReviewCandidateGenerationID)", StringComparison.Ordinal), "Successful candidate preparation should finalize the persisted generation."); + Assert(candidateService.Contains("await CompleteModuleIfReadyAsync(batch, module, completedGeneration)", StringComparison.Ordinal), "Zero-candidate generation should immediately apply the same completion rule."); + Assert(candidateService.Contains("generation.PendingCount != 0", StringComparison.Ordinal), "Non-zero and paged candidate generations must not complete early."); + Assert(candidateService.Contains("StoryIntelligenceReviewCandidateGenerationStatuses.Ready", StringComparison.Ordinal), "Failed or never-run generations must not complete."); + Assert(candidateService.Contains("RecordCharacterImportAsync", StringComparison.Ordinal) + && candidateService.Contains("RecordLocationImportAsync", StringComparison.Ordinal) + && candidateService.Contains("RecordAssetImportAsync", StringComparison.Ordinal) + && candidateService.Contains("RecordRelationshipImportAsync", StringComparison.Ordinal) + && candidateService.Contains("RecordKnowledgeImportAsync", StringComparison.Ordinal), "All candidate-review modules should share the same completion writer path."); + + Assert(onboarding.Contains("var result = await characterImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Character decisions should materialise before candidate rows are marked reviewed."); + Assert(onboarding.Contains("var pendingCount = await reviewCandidates.MarkCharacterDecisionsAsync", StringComparison.Ordinal), "Character final decision should be persisted after successful import."); + Assert(onboarding.Contains("await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters)", StringComparison.Ordinal), "Final Character decision should complete from module-wide pending count."); + Assert(onboarding.Contains("await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync", StringComparison.Ordinal), "Character completion should still trigger whole-book plot synthesis."); + Assert(onboarding.Contains("var result = await locationImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Location import should not set completion directly."); + Assert(onboarding.Contains("var result = await assetImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Asset import should not set completion directly."); + Assert(onboarding.Contains("var result = await relationshipImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Relationship import should not set completion directly."); + Assert(onboarding.Contains("var result = await knowledgeImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Knowledge import should not set completion directly."); + Assert(onboarding.Contains("ReadyPendingCount(summary.LocationCandidateGenerationStatus, summary.LocationPendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Location pending count instead of an empty detailed page."); + Assert(onboarding.Contains("ReadyPendingCount(summary.AssetCandidateGenerationStatus, summary.AssetPendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Asset pending count instead of an empty detailed page."); + Assert(onboarding.Contains("ReadyPendingCount(summary.RelationshipCandidateGenerationStatus, summary.RelationshipPendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Relationship pending count instead of an empty detailed page."); + Assert(onboarding.Contains("ReadyPendingCount(summary.KnowledgeCandidateGenerationStatus, summary.KnowledgePendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Knowledge pending count instead of an empty detailed page."); + Assert(!ExtractBetween(onboarding, "private async Task GetReviewFrameAsync", "private sealed record StoryIntelligenceReviewFrame").Contains("CompleteModuleIfReadyAsync", StringComparison.Ordinal), "Review Centre GET must not mark module completion."); + + Assert(summarySql.Contains("CandidateSummary", StringComparison.Ordinal), "Review Centre summary SQL should include persisted current generation state."); + Assert(summarySql.Contains("generation.IsCurrent = 1", StringComparison.Ordinal), "Review Centre should read only current persisted candidate generations."); + Assert(summarySql.Contains("LocationPendingCount", StringComparison.Ordinal) + && summarySql.Contains("AssetPendingCount", StringComparison.Ordinal) + && summarySql.Contains("RelationshipPendingCount", StringComparison.Ordinal) + && summarySql.Contains("KnowledgePendingCount", StringComparison.Ordinal), "Summary should expose module-wide pending counts."); + Assert(!summarySql.Contains("SceneIntelligenceJson", StringComparison.Ordinal) + && !summarySql.Contains("SceneManuscriptSources", StringComparison.Ordinal), "Review Centre summary must not reconstruct candidates from scene JSON."); + Assert(persistence.Contains("public string? LocationCandidateGenerationStatus", StringComparison.Ordinal) + && persistence.Contains("public int? KnowledgePendingCount", StringComparison.Ordinal), "Persisted summary model should carry generation status and pending counts."); + + foreach (var importService in new[] { characterImport, locationImport, assetImport, relationshipImport, knowledgeImport }) + { + Assert(!importService.Contains("await pipelineState.RecordCharacterImportAsync", StringComparison.Ordinal) + && !importService.Contains("await pipelineState.RecordLocationImportAsync", StringComparison.Ordinal) + && !importService.Contains("await pipelineState.RecordAssetImportAsync", StringComparison.Ordinal) + && !importService.Contains("await pipelineState.RecordRelationshipImportAsync", StringComparison.Ordinal) + && !importService.Contains("await pipelineState.RecordKnowledgeImportAsync", StringComparison.Ordinal), "Module import services should not own durable completion writes."); + } + + Assert(onboarding.Contains("batch.LocationStageComplete = state.LocationReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should preserve completed Location state from durable timestamps."); + Assert(onboarding.Contains("batch.RelationshipStageComplete = state.RelationshipReviewCompletedUtc is not null", StringComparison.Ordinal), "Resume should preserve completed Relationship state from durable timestamps."); + Assert(onboarding.Contains("CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Knowledge)", StringComparison.Ordinal) + && !ExtractBetween(onboarding, "public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync", "public async Task GetKnowledgeImportResultAsync").Contains("StoryIntelligenceReviewModules.Relationships", StringComparison.Ordinal), "One module's final decision must not complete or reopen another module."); +} + static string ExtractAction(string value, string signature) { var start = value.IndexOf(signature, StringComparison.Ordinal); diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 46f14e4..695680a 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -164,6 +164,21 @@ public sealed class StoryIntelligenceReviewCentreSummary public DateTime? RelationshipReviewCompletedUtc { get; init; } public DateTime? KnowledgeReviewCompletedUtc { get; init; } public DateTime? NarrativeReviewCompletedUtc { get; init; } + public string? CharacterCandidateGenerationStatus { get; init; } + public int? CharacterCandidateCount { get; init; } + public int? CharacterPendingCount { get; init; } + public string? LocationCandidateGenerationStatus { get; init; } + public int? LocationCandidateCount { get; init; } + public int? LocationPendingCount { get; init; } + public string? AssetCandidateGenerationStatus { get; init; } + public int? AssetCandidateCount { get; init; } + public int? AssetPendingCount { get; init; } + public string? RelationshipCandidateGenerationStatus { get; init; } + public int? RelationshipCandidateCount { get; init; } + public int? RelationshipPendingCount { get; init; } + public string? KnowledgeCandidateGenerationStatus { get; init; } + public int? KnowledgeCandidateCount { get; init; } + public int? KnowledgePendingCount { get; init; } public int? LastRunID { get; init; } public int RunCount { get; init; } public int CompletedRunCount { get; init; } diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 79f1817..29a0f47 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -852,35 +852,35 @@ public sealed class OnboardingStoryIntelligenceService( HasCommittedScenes = hasCommittedScenes, CanImport = !characterComplete, IsComplete = characterComplete, - SummaryNeedsAttentionCount = characterComplete ? 0 : null + SummaryNeedsAttentionCount = characterComplete ? 0 : ReadyPendingCount(summary.CharacterCandidateGenerationStatus, summary.CharacterPendingCount) }, LocationReview = new StoryIntelligenceLocationReviewViewModel { HasCommittedScenes = hasCommittedScenes, CanImport = !locationComplete, IsComplete = locationComplete, - SummaryNeedsAttentionCount = locationComplete ? 0 : null + SummaryNeedsAttentionCount = locationComplete ? 0 : ReadyPendingCount(summary.LocationCandidateGenerationStatus, summary.LocationPendingCount) }, AssetReview = new StoryIntelligenceAssetReviewViewModel { HasCommittedScenes = hasCommittedScenes, CanImport = !assetComplete, IsComplete = assetComplete, - SummaryNeedsAttentionCount = assetComplete ? 0 : null + SummaryNeedsAttentionCount = assetComplete ? 0 : ReadyPendingCount(summary.AssetCandidateGenerationStatus, summary.AssetPendingCount) }, RelationshipReview = new StoryIntelligenceRelationshipReviewViewModel { HasCommittedScenes = hasCommittedScenes, CanImport = !relationshipComplete, IsComplete = relationshipComplete, - SummaryNeedsAttentionCount = relationshipComplete ? 0 : null + SummaryNeedsAttentionCount = relationshipComplete ? 0 : ReadyPendingCount(summary.RelationshipCandidateGenerationStatus, summary.RelationshipPendingCount) }, KnowledgeReview = new StoryIntelligenceKnowledgeReviewViewModel { HasCommittedScenes = hasCommittedScenes, CanImport = !knowledgeComplete, IsComplete = knowledgeComplete, - SummaryNeedsAttentionCount = knowledgeComplete ? 0 : null + SummaryNeedsAttentionCount = knowledgeComplete ? 0 : ReadyPendingCount(summary.KnowledgeCandidateGenerationStatus, summary.KnowledgePendingCount) }, NarrativeReview = new StoryIntelligenceNarrativeReviewViewModel { @@ -916,6 +916,11 @@ public sealed class OnboardingStoryIntelligenceService( return new StoryIntelligenceReviewFrame(batch, summary, model, summaryStopwatch.ElapsedMilliseconds); } + private static int? ReadyPendingCount(string? generationStatus, int? pendingCount) + => string.Equals(generationStatus, StoryIntelligenceReviewCandidateGenerationStatuses.Ready, StringComparison.OrdinalIgnoreCase) + ? pendingCount ?? 0 + : null; + private sealed record StoryIntelligenceReviewFrame( OnboardingStoryIntelligenceBatch Batch, StoryIntelligenceReviewCentreSummary Summary, @@ -984,6 +989,12 @@ public sealed class OnboardingStoryIntelligenceService( await onboarding.MarkCompletedAsync(); await reviewCandidates.PrepareCharacterCandidatesAsync(batch); + var pendingCharacterCandidates = await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters); + if (pendingCharacterCandidates == 0) + { + await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID); + await reviewCandidates.PreparePeerCandidatesAsync(batch); + } return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult { Success = true, @@ -1000,12 +1011,14 @@ public sealed class OnboardingStoryIntelligenceService( return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } - var pendingCount = await reviewCandidates.MarkCharacterDecisionsAsync(batch.BookID, form.Characters); - var result = await characterImport.ImportAsync(batch, form, pendingCount == 0); + var result = await characterImport.ImportAsync(batch, form, false); if (result.Success) { + var pendingCount = await reviewCandidates.MarkCharacterDecisionsAsync(batch.BookID, form.Characters); if (pendingCount == 0) { + await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters); + await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID); await reviewCandidates.PreparePeerCandidatesAsync(batch); } else @@ -1046,11 +1059,18 @@ public sealed class OnboardingStoryIntelligenceService( return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } - var pendingCount = await reviewCandidates.MarkLocationDecisionsAsync(batch.BookID, form.Locations); - var result = await locationImport.ImportAsync(batch, form, pendingCount == 0); + var result = await locationImport.ImportAsync(batch, form, false); if (result.Success) { - result = pendingCount == 0 ? result : WithRemainingMessage(result, pendingCount, "location"); + var pendingCount = await reviewCandidates.MarkLocationDecisionsAsync(batch.BookID, form.Locations); + if (pendingCount == 0) + { + await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Locations); + } + else + { + result = WithRemainingMessage(result, pendingCount, "location"); + } } return (await GetReviewCentreAsync(batchId), result); } @@ -1084,11 +1104,18 @@ public sealed class OnboardingStoryIntelligenceService( return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } - var pendingCount = await reviewCandidates.MarkAssetDecisionsAsync(batch.BookID, form.Assets); - var result = await assetImport.ImportAsync(batch, form, pendingCount == 0); + var result = await assetImport.ImportAsync(batch, form, false); if (result.Success) { - result = pendingCount == 0 ? result : WithRemainingMessage(result, pendingCount, "asset"); + var pendingCount = await reviewCandidates.MarkAssetDecisionsAsync(batch.BookID, form.Assets); + if (pendingCount == 0) + { + await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Assets); + } + else + { + result = WithRemainingMessage(result, pendingCount, "asset"); + } } return (await GetReviewCentreAsync(batchId), result); } @@ -1123,11 +1150,18 @@ public sealed class OnboardingStoryIntelligenceService( return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } - var pendingCount = await reviewCandidates.MarkRelationshipDecisionsAsync(batch.BookID, form.Relationships); - var result = await relationshipImport.ImportAsync(batch, form, pendingCount == 0); + var result = await relationshipImport.ImportAsync(batch, form, false); if (result.Success) { - result = pendingCount == 0 ? result : WithRemainingMessage(result, pendingCount, "relationship"); + var pendingCount = await reviewCandidates.MarkRelationshipDecisionsAsync(batch.BookID, form.Relationships); + if (pendingCount == 0) + { + await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Relationships); + } + else + { + result = WithRemainingMessage(result, pendingCount, "relationship"); + } } return (await GetReviewCentreAsync(batchId), result); } @@ -1161,11 +1195,18 @@ public sealed class OnboardingStoryIntelligenceService( return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } - var pendingCount = await reviewCandidates.MarkKnowledgeDecisionsAsync(batch.BookID, form.Knowledge); - var result = await knowledgeImport.ImportAsync(batch, form, pendingCount == 0); + var result = await knowledgeImport.ImportAsync(batch, form, false); if (result.Success) { - result = pendingCount == 0 ? result : WithRemainingMessage(result, pendingCount, "knowledge"); + var pendingCount = await reviewCandidates.MarkKnowledgeDecisionsAsync(batch.BookID, form.Knowledge); + if (pendingCount == 0) + { + await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Knowledge); + } + else + { + result = WithRemainingMessage(result, pendingCount, "knowledge"); + } } return (await GetReviewCentreAsync(batchId), result); } diff --git a/PlotLine/Services/StoryIntelligenceAssetImportService.cs b/PlotLine/Services/StoryIntelligenceAssetImportService.cs index 5b725c4..b21ffb5 100644 --- a/PlotLine/Services/StoryIntelligenceAssetImportService.cs +++ b/PlotLine/Services/StoryIntelligenceAssetImportService.cs @@ -111,10 +111,6 @@ public sealed class StoryIntelligenceAssetImportService( { batch.AssetStageComplete = completeReview; batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult(); - if (completeReview) - { - await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); - } return new StoryIntelligenceImportCommitResult { Success = true, @@ -302,11 +298,6 @@ public sealed class StoryIntelligenceAssetImportService( SceneAssetEventsCreated = sceneEventsCreated, OwnershipLinksCreated = ownershipLinksCreated }; - if (completeReview) - { - await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); - } - logger.LogInformation( "Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}", batch.BatchID, diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index 947794e..897b859 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -19,7 +19,6 @@ public sealed class StoryIntelligenceCharacterImportService( IStoryIntelligencePipelineStateService pipelineState, INameIntelligenceService nameIntelligence, ICoreImportCharacterMappingService coreImportCharacterMapping, - IWholeBookPlotIntelligenceService wholeBookPlotIntelligence, ICurrentUserService currentUser, ILogger logger) : IStoryIntelligenceCharacterImportService { @@ -384,12 +383,6 @@ public sealed class StoryIntelligenceCharacterImportService( ScenePeoplePanelsUpdated = linkedAppearances }; var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID); - if (completeReview) - { - await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID); - await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID); - } - logger.LogInformation( "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks} RemappedScenes={RemappedScenes} RemappedLinks={RemappedLinks}", batch.BatchID, diff --git a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs index 83a8d4a..bd679de 100644 --- a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs +++ b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs @@ -104,10 +104,6 @@ public sealed class StoryIntelligenceKnowledgeImportService( KnowledgeMerged = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase)), KnowledgeIgnored = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) }; - if (completeReview) - { - await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); - } return new StoryIntelligenceImportCommitResult { Success = true, @@ -215,11 +211,6 @@ public sealed class StoryIntelligenceKnowledgeImportService( KnowledgeMerged = merged, KnowledgeIgnored = ignored }; - if (completeReview) - { - await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); - } - logger.LogInformation( "Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}", batch.BatchID, diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index 4a2bbb0..784154b 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -278,11 +278,6 @@ public sealed class StoryIntelligenceLocationImportService( LocationsIgnored = ignored, SceneLocationsUpdated = sceneLocationsUpdated }; - if (completeReview) - { - await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID); - } - logger.LogInformation( "Imported Story Intelligence locations for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneLocationsUpdated={SceneLocationsUpdated}", batch.BatchID, diff --git a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs index 202fc37..605fc51 100644 --- a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs +++ b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs @@ -92,10 +92,6 @@ public sealed class StoryIntelligenceRelationshipImportService( { batch.RelationshipStageComplete = completeReview; batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult(); - if (completeReview) - { - await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); - } return new StoryIntelligenceImportCommitResult { Success = true, @@ -246,11 +242,6 @@ public sealed class StoryIntelligenceRelationshipImportService( RelationshipsIgnored = ignored, RelationshipEventsCreated = eventsCreated }; - if (completeReview) - { - await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); - } - logger.LogInformation( "Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}", batch.BatchID, diff --git a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs index 02a9b80..da3fd0b 100644 --- a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs +++ b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs @@ -20,6 +20,7 @@ public interface IStoryIntelligenceReviewCandidateService Task MarkAssetDecisionsAsync(int bookId, IEnumerable choices); Task MarkRelationshipDecisionsAsync(int bookId, IEnumerable choices); Task MarkKnowledgeDecisionsAsync(int bookId, IEnumerable choices); + Task CompleteModuleIfReadyAsync(OnboardingStoryIntelligenceBatch batch, string module); } public sealed class StoryIntelligenceReviewCandidateService( @@ -30,6 +31,7 @@ public sealed class StoryIntelligenceReviewCandidateService( IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceKnowledgeImportService knowledgeImport, ICharacterRepository characters, + IStoryIntelligencePipelineStateService pipelineState, ILogger logger) : IStoryIntelligenceReviewCandidateService { private const int MaxPageSize = 100; @@ -354,7 +356,8 @@ public sealed class StoryIntelligenceReviewCandidateService( } } - await candidates.CompleteGenerationAsync(generation.ReviewCandidateGenerationID); + var completedGeneration = await candidates.CompleteGenerationAsync(generation.ReviewCandidateGenerationID); + await CompleteModuleIfReadyAsync(batch, module, completedGeneration); logger.LogInformation( "Prepared persisted Story Intelligence review candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} Module={Module} Count={Count}", batch.BatchID, @@ -371,6 +374,49 @@ public sealed class StoryIntelligenceReviewCandidateService( } } + public async Task CompleteModuleIfReadyAsync(OnboardingStoryIntelligenceBatch batch, string module) + { + var generation = await candidates.GetCurrentGenerationAsync(batch.BookID, module); + return await CompleteModuleIfReadyAsync(batch, module, generation); + } + + private async Task CompleteModuleIfReadyAsync( + OnboardingStoryIntelligenceBatch batch, + string module, + StoryIntelligenceReviewCandidateGeneration? generation) + { + if (generation is null + || !string.Equals(generation.Status, StoryIntelligenceReviewCandidateGenerationStatuses.Ready, StringComparison.OrdinalIgnoreCase) + || generation.PendingCount != 0) + { + return generation?.PendingCount ?? 0; + } + + switch (module) + { + case StoryIntelligenceReviewModules.Characters: + await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID); + break; + case StoryIntelligenceReviewModules.Locations: + await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID); + break; + case StoryIntelligenceReviewModules.Assets: + await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); + break; + case StoryIntelligenceReviewModules.Relationships: + await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); + break; + case StoryIntelligenceReviewModules.Knowledge: + await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); + break; + default: + logger.LogWarning("Ignoring unsupported Story Intelligence review completion module {Module}. BatchID={BatchID} BookID={BookID}", module, batch.BatchID, batch.BookID); + break; + } + + return 0; + } + private async Task> LoadPageAsync(int bookId, string module, int pageNumber, int pageSize) { pageNumber = Math.Max(1, pageNumber); diff --git a/PlotLine/Sql/179_Phase27F_ReviewCentreCandidateLifecycleSummary.sql b/PlotLine/Sql/179_Phase27F_ReviewCentreCandidateLifecycleSummary.sql new file mode 100644 index 0000000..3f1a969 --- /dev/null +++ b/PlotLine/Sql/179_Phase27F_ReviewCentreCandidateLifecycleSummary.sql @@ -0,0 +1,175 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +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 + ), + CandidateSummary AS + ( + SELECT + generation.BookID, + MAX(CASE WHEN generation.Module = N'Characters' THEN generation.Status END) AS CharacterCandidateGenerationStatus, + MAX(CASE WHEN generation.Module = N'Characters' THEN generation.CandidateCount END) AS CharacterCandidateCount, + MAX(CASE WHEN generation.Module = N'Characters' THEN generation.PendingCount END) AS CharacterPendingCount, + MAX(CASE WHEN generation.Module = N'Locations' THEN generation.Status END) AS LocationCandidateGenerationStatus, + MAX(CASE WHEN generation.Module = N'Locations' THEN generation.CandidateCount END) AS LocationCandidateCount, + MAX(CASE WHEN generation.Module = N'Locations' THEN generation.PendingCount END) AS LocationPendingCount, + MAX(CASE WHEN generation.Module = N'Assets' THEN generation.Status END) AS AssetCandidateGenerationStatus, + MAX(CASE WHEN generation.Module = N'Assets' THEN generation.CandidateCount END) AS AssetCandidateCount, + MAX(CASE WHEN generation.Module = N'Assets' THEN generation.PendingCount END) AS AssetPendingCount, + MAX(CASE WHEN generation.Module = N'Relationships' THEN generation.Status END) AS RelationshipCandidateGenerationStatus, + MAX(CASE WHEN generation.Module = N'Relationships' THEN generation.CandidateCount END) AS RelationshipCandidateCount, + MAX(CASE WHEN generation.Module = N'Relationships' THEN generation.PendingCount END) AS RelationshipPendingCount, + MAX(CASE WHEN generation.Module = N'Knowledge' THEN generation.Status END) AS KnowledgeCandidateGenerationStatus, + MAX(CASE WHEN generation.Module = N'Knowledge' THEN generation.CandidateCount END) AS KnowledgeCandidateCount, + MAX(CASE WHEN generation.Module = N'Knowledge' THEN generation.PendingCount END) AS KnowledgePendingCount + FROM dbo.StoryIntelligenceReviewCandidateGenerations generation + WHERE generation.BookID = @BookID + AND generation.IsCurrent = 1 + GROUP BY generation.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 + ), + MetricValueSummary AS + ( + SELECT + chapter.BookID, + COUNT_BIG(*) AS MetricValueCount + FROM dbo.SceneMetricValues metric + INNER JOIN dbo.Scenes scene + ON scene.SceneID = metric.SceneID + INNER JOIN dbo.Chapters chapter + ON chapter.ChapterID = scene.ChapterID + WHERE chapter.BookID = @BookID + GROUP BY chapter.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, + cs.CharacterCandidateGenerationStatus, + CAST(cs.CharacterCandidateCount AS int) AS CharacterCandidateCount, + CAST(cs.CharacterPendingCount AS int) AS CharacterPendingCount, + cs.LocationCandidateGenerationStatus, + CAST(cs.LocationCandidateCount AS int) AS LocationCandidateCount, + CAST(cs.LocationPendingCount AS int) AS LocationPendingCount, + cs.AssetCandidateGenerationStatus, + CAST(cs.AssetCandidateCount AS int) AS AssetCandidateCount, + CAST(cs.AssetPendingCount AS int) AS AssetPendingCount, + cs.RelationshipCandidateGenerationStatus, + CAST(cs.RelationshipCandidateCount AS int) AS RelationshipCandidateCount, + CAST(cs.RelationshipPendingCount AS int) AS RelationshipPendingCount, + cs.KnowledgeCandidateGenerationStatus, + CAST(cs.KnowledgeCandidateCount AS int) AS KnowledgeCandidateCount, + CAST(cs.KnowledgePendingCount AS int) AS KnowledgePendingCount, + 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, + CAST(COALESCE(metrics.MetricValueCount, 0) AS int) AS MetricValueCount + 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 CandidateSummary cs + ON cs.BookID = p.BookID + LEFT JOIN NarrativeSummary ns + ON ns.BookID = p.BookID + LEFT JOIN NarrativeEventSummary nes + ON nes.BookID = p.BookID + LEFT JOIN MetricValueSummary metrics + ON metrics.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