Fix story intelligence module completion lifecycle

This commit is contained in:
Nick Beckley 2026-08-30 22:47:33 +00:00
parent 0839b8e13d
commit 0c3f215574
10 changed files with 362 additions and 59 deletions

View File

@ -284,6 +284,7 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence completed Character review remains inspectable", StoryIntelligenceCompletedCharacterReviewRemainsInspectable), ("Story Intelligence completed Character review remains inspectable", StoryIntelligenceCompletedCharacterReviewRemainsInspectable),
("Story Intelligence Review Centre completed modules use canonical destinations", StoryIntelligenceReviewCentreCompletedModulesUseCanonicalDestinations), ("Story Intelligence Review Centre completed modules use canonical destinations", StoryIntelligenceReviewCentreCompletedModulesUseCanonicalDestinations),
("Story Intelligence detailed reviews read persisted candidate pages", StoryIntelligenceDetailedReviewsReadPersistedCandidatePages), ("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 Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution),
("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals), ("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals),
("Story Intelligence recovered provider failures stay operational", StoryIntelligenceRecoveredProviderFailuresStayOperational), ("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."); 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<int> 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<StoryIntelligenceReviewFrame?> 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<StoryIntelligenceKnowledgeImportResultViewModel?> 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) static string ExtractAction(string value, string signature)
{ {
var start = value.IndexOf(signature, StringComparison.Ordinal); var start = value.IndexOf(signature, StringComparison.Ordinal);

View File

@ -164,6 +164,21 @@ public sealed class StoryIntelligenceReviewCentreSummary
public DateTime? RelationshipReviewCompletedUtc { get; init; } public DateTime? RelationshipReviewCompletedUtc { get; init; }
public DateTime? KnowledgeReviewCompletedUtc { get; init; } public DateTime? KnowledgeReviewCompletedUtc { get; init; }
public DateTime? NarrativeReviewCompletedUtc { 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? LastRunID { get; init; }
public int RunCount { get; init; } public int RunCount { get; init; }
public int CompletedRunCount { get; init; } public int CompletedRunCount { get; init; }

View File

@ -852,35 +852,35 @@ public sealed class OnboardingStoryIntelligenceService(
HasCommittedScenes = hasCommittedScenes, HasCommittedScenes = hasCommittedScenes,
CanImport = !characterComplete, CanImport = !characterComplete,
IsComplete = characterComplete, IsComplete = characterComplete,
SummaryNeedsAttentionCount = characterComplete ? 0 : null SummaryNeedsAttentionCount = characterComplete ? 0 : ReadyPendingCount(summary.CharacterCandidateGenerationStatus, summary.CharacterPendingCount)
}, },
LocationReview = new StoryIntelligenceLocationReviewViewModel LocationReview = new StoryIntelligenceLocationReviewViewModel
{ {
HasCommittedScenes = hasCommittedScenes, HasCommittedScenes = hasCommittedScenes,
CanImport = !locationComplete, CanImport = !locationComplete,
IsComplete = locationComplete, IsComplete = locationComplete,
SummaryNeedsAttentionCount = locationComplete ? 0 : null SummaryNeedsAttentionCount = locationComplete ? 0 : ReadyPendingCount(summary.LocationCandidateGenerationStatus, summary.LocationPendingCount)
}, },
AssetReview = new StoryIntelligenceAssetReviewViewModel AssetReview = new StoryIntelligenceAssetReviewViewModel
{ {
HasCommittedScenes = hasCommittedScenes, HasCommittedScenes = hasCommittedScenes,
CanImport = !assetComplete, CanImport = !assetComplete,
IsComplete = assetComplete, IsComplete = assetComplete,
SummaryNeedsAttentionCount = assetComplete ? 0 : null SummaryNeedsAttentionCount = assetComplete ? 0 : ReadyPendingCount(summary.AssetCandidateGenerationStatus, summary.AssetPendingCount)
}, },
RelationshipReview = new StoryIntelligenceRelationshipReviewViewModel RelationshipReview = new StoryIntelligenceRelationshipReviewViewModel
{ {
HasCommittedScenes = hasCommittedScenes, HasCommittedScenes = hasCommittedScenes,
CanImport = !relationshipComplete, CanImport = !relationshipComplete,
IsComplete = relationshipComplete, IsComplete = relationshipComplete,
SummaryNeedsAttentionCount = relationshipComplete ? 0 : null SummaryNeedsAttentionCount = relationshipComplete ? 0 : ReadyPendingCount(summary.RelationshipCandidateGenerationStatus, summary.RelationshipPendingCount)
}, },
KnowledgeReview = new StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview = new StoryIntelligenceKnowledgeReviewViewModel
{ {
HasCommittedScenes = hasCommittedScenes, HasCommittedScenes = hasCommittedScenes,
CanImport = !knowledgeComplete, CanImport = !knowledgeComplete,
IsComplete = knowledgeComplete, IsComplete = knowledgeComplete,
SummaryNeedsAttentionCount = knowledgeComplete ? 0 : null SummaryNeedsAttentionCount = knowledgeComplete ? 0 : ReadyPendingCount(summary.KnowledgeCandidateGenerationStatus, summary.KnowledgePendingCount)
}, },
NarrativeReview = new StoryIntelligenceNarrativeReviewViewModel NarrativeReview = new StoryIntelligenceNarrativeReviewViewModel
{ {
@ -916,6 +916,11 @@ public sealed class OnboardingStoryIntelligenceService(
return new StoryIntelligenceReviewFrame(batch, summary, model, summaryStopwatch.ElapsedMilliseconds); 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( private sealed record StoryIntelligenceReviewFrame(
OnboardingStoryIntelligenceBatch Batch, OnboardingStoryIntelligenceBatch Batch,
StoryIntelligenceReviewCentreSummary Summary, StoryIntelligenceReviewCentreSummary Summary,
@ -984,6 +989,12 @@ public sealed class OnboardingStoryIntelligenceService(
await onboarding.MarkCompletedAsync(); await onboarding.MarkCompletedAsync();
await reviewCandidates.PrepareCharacterCandidatesAsync(batch); 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 return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult
{ {
Success = true, 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." }); 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, false);
var result = await characterImport.ImportAsync(batch, form, pendingCount == 0);
if (result.Success) if (result.Success)
{ {
var pendingCount = await reviewCandidates.MarkCharacterDecisionsAsync(batch.BookID, form.Characters);
if (pendingCount == 0) if (pendingCount == 0)
{ {
await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters);
await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID);
await reviewCandidates.PreparePeerCandidatesAsync(batch); await reviewCandidates.PreparePeerCandidatesAsync(batch);
} }
else else
@ -1046,11 +1059,18 @@ public sealed class OnboardingStoryIntelligenceService(
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); 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, false);
var result = await locationImport.ImportAsync(batch, form, pendingCount == 0);
if (result.Success) 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); 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." }); 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, false);
var result = await assetImport.ImportAsync(batch, form, pendingCount == 0);
if (result.Success) 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); 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." }); 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, false);
var result = await relationshipImport.ImportAsync(batch, form, pendingCount == 0);
if (result.Success) 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); 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." }); 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, false);
var result = await knowledgeImport.ImportAsync(batch, form, pendingCount == 0);
if (result.Success) 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); return (await GetReviewCentreAsync(batchId), result);
} }

View File

@ -111,10 +111,6 @@ public sealed class StoryIntelligenceAssetImportService(
{ {
batch.AssetStageComplete = completeReview; batch.AssetStageComplete = completeReview;
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult(); batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult();
if (completeReview)
{
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
}
return new StoryIntelligenceImportCommitResult return new StoryIntelligenceImportCommitResult
{ {
Success = true, Success = true,
@ -302,11 +298,6 @@ public sealed class StoryIntelligenceAssetImportService(
SceneAssetEventsCreated = sceneEventsCreated, SceneAssetEventsCreated = sceneEventsCreated,
OwnershipLinksCreated = ownershipLinksCreated OwnershipLinksCreated = ownershipLinksCreated
}; };
if (completeReview)
{
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
}
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}", "Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}",
batch.BatchID, batch.BatchID,

View File

@ -19,7 +19,6 @@ public sealed class StoryIntelligenceCharacterImportService(
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
INameIntelligenceService nameIntelligence, INameIntelligenceService nameIntelligence,
ICoreImportCharacterMappingService coreImportCharacterMapping, ICoreImportCharacterMappingService coreImportCharacterMapping,
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
ICurrentUserService currentUser, ICurrentUserService currentUser,
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
{ {
@ -384,12 +383,6 @@ public sealed class StoryIntelligenceCharacterImportService(
ScenePeoplePanelsUpdated = linkedAppearances ScenePeoplePanelsUpdated = linkedAppearances
}; };
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID); 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( 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}", "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, batch.BatchID,

View File

@ -104,10 +104,6 @@ public sealed class StoryIntelligenceKnowledgeImportService(
KnowledgeMerged = batch.KnowledgeDecisions.Count(decision => string.Equals(decision.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase)), 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)) 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 return new StoryIntelligenceImportCommitResult
{ {
Success = true, Success = true,
@ -215,11 +211,6 @@ public sealed class StoryIntelligenceKnowledgeImportService(
KnowledgeMerged = merged, KnowledgeMerged = merged,
KnowledgeIgnored = ignored KnowledgeIgnored = ignored
}; };
if (completeReview)
{
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
}
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}", "Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}",
batch.BatchID, batch.BatchID,

View File

@ -278,11 +278,6 @@ public sealed class StoryIntelligenceLocationImportService(
LocationsIgnored = ignored, LocationsIgnored = ignored,
SceneLocationsUpdated = sceneLocationsUpdated SceneLocationsUpdated = sceneLocationsUpdated
}; };
if (completeReview)
{
await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID);
}
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence locations for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneLocationsUpdated={SceneLocationsUpdated}", "Imported Story Intelligence locations for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneLocationsUpdated={SceneLocationsUpdated}",
batch.BatchID, batch.BatchID,

View File

@ -92,10 +92,6 @@ public sealed class StoryIntelligenceRelationshipImportService(
{ {
batch.RelationshipStageComplete = completeReview; batch.RelationshipStageComplete = completeReview;
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult(); batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult();
if (completeReview)
{
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
}
return new StoryIntelligenceImportCommitResult return new StoryIntelligenceImportCommitResult
{ {
Success = true, Success = true,
@ -246,11 +242,6 @@ public sealed class StoryIntelligenceRelationshipImportService(
RelationshipsIgnored = ignored, RelationshipsIgnored = ignored,
RelationshipEventsCreated = eventsCreated RelationshipEventsCreated = eventsCreated
}; };
if (completeReview)
{
await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
}
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}", "Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}",
batch.BatchID, batch.BatchID,

View File

@ -20,6 +20,7 @@ public interface IStoryIntelligenceReviewCandidateService
Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices); Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices);
Task<int> MarkRelationshipDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices); Task<int> MarkRelationshipDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices);
Task<int> MarkKnowledgeDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices); Task<int> MarkKnowledgeDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices);
Task<int> CompleteModuleIfReadyAsync(OnboardingStoryIntelligenceBatch batch, string module);
} }
public sealed class StoryIntelligenceReviewCandidateService( public sealed class StoryIntelligenceReviewCandidateService(
@ -30,6 +31,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport, IStoryIntelligenceKnowledgeImportService knowledgeImport,
ICharacterRepository characters, ICharacterRepository characters,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService
{ {
private const int MaxPageSize = 100; 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( logger.LogInformation(
"Prepared persisted Story Intelligence review candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} Module={Module} Count={Count}", "Prepared persisted Story Intelligence review candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} Module={Module} Count={Count}",
batch.BatchID, batch.BatchID,
@ -371,6 +374,49 @@ public sealed class StoryIntelligenceReviewCandidateService(
} }
} }
public async Task<int> CompleteModuleIfReadyAsync(OnboardingStoryIntelligenceBatch batch, string module)
{
var generation = await candidates.GetCurrentGenerationAsync(batch.BookID, module);
return await CompleteModuleIfReadyAsync(batch, module, generation);
}
private async Task<int> 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<CandidatePage<TCandidate>> LoadPageAsync<TCandidate>(int bookId, string module, int pageNumber, int pageSize) private async Task<CandidatePage<TCandidate>> LoadPageAsync<TCandidate>(int bookId, string module, int pageNumber, int pageSize)
{ {
pageNumber = Math.Max(1, pageNumber); pageNumber = Math.Max(1, pageNumber);

View File

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