From d6d52b09e3a76669407ecbb29538a84e1844d00b Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 30 Aug 2026 20:46:31 +0000 Subject: [PATCH] Consolidate story intelligence review module loads --- PlotLine.Tests/Program.cs | 60 ++++++++- PlotLine/Controllers/OnboardingController.cs | 12 +- .../OnboardingStoryIntelligenceService.cs | 121 +++++++++++++++--- .../StoryIntelligenceAssetImportService.cs | 7 +- ...StoryIntelligenceCharacterImportService.cs | 7 +- ...StoryIntelligenceKnowledgeImportService.cs | 20 ++- .../StoryIntelligenceLocationImportService.cs | 7 +- ...ryIntelligenceRelationshipImportService.cs | 20 ++- PlotLine/ViewModels/OnboardingViewModels.cs | 14 +- 9 files changed, 210 insertions(+), 58 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index c9f3cbc..c6b4dd9 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -279,6 +279,7 @@ var tests = new (string Name, Action Test)[] ("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 detailed review GETs use selected module paths", StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths), ("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), @@ -1059,11 +1060,11 @@ static void StoryIntelligenceReviewCentreUsesBoundedSummaryQuery() var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); var migration = File.ReadAllText(Path.Combine(root, "Sql/176_Phase27C_StoryIntelligenceReviewCentreSummary.sql")); var centreAction = ExtractBetween(controller, "public async Task StoryIntelligenceReviewCentre", "[HttpGet(\"story-intelligence/characters\")]"); - var centreSummary = ExtractBetween(service, "public async Task GetReviewCentreAsync", "public async Task CancelAsync"); + var centreSummary = ExtractBetween(service, "public async Task GetReviewCentreAsync", "public Task GetCharacterReviewAsync"); 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("GetReviewFrameAsync(batchId)", StringComparison.Ordinal), "Review Centre summary should use the shared aggregate review frame."); 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."); @@ -1076,6 +1077,61 @@ static void StoryIntelligenceReviewCentreUsesBoundedSummaryQuery() Assert(viewModel.Contains("SummaryNeedsAttentionCount", StringComparison.Ordinal), "Tile counts should support lightweight summary values without full candidate objects."); } +static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths() +{ + var root = PlotLineRoot(); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); + + var expectedActions = new Dictionary(StringComparer.Ordinal) + { + ["StoryIntelligenceCharacters"] = "GetCharacterReviewAsync(batchId)", + ["StoryIntelligenceLocations"] = "GetLocationReviewAsync(batchId)", + ["StoryIntelligenceAssets"] = "GetAssetReviewAsync(batchId)", + ["StoryIntelligenceRelationships"] = "GetRelationshipReviewAsync(batchId)", + ["StoryIntelligenceKnowledge"] = "GetKnowledgeReviewAsync(batchId)", + ["StoryIntelligenceNarrative"] = "GetNarrativeReviewAsync(batchId)" + }; + + foreach (var (actionName, serviceCall) in expectedActions) + { + var action = ExtractAction(controller, $"public async Task {actionName}"); + Assert(action.Contains(serviceCall, StringComparison.Ordinal), $"{actionName} should load only its selected review module."); + Assert(!action.Contains("GetProgressAsync(batchId)", StringComparison.Ordinal), $"{actionName} should not build full Story Intelligence progress."); + Assert(!action.Contains("ResumeBookAsync", StringComparison.Ordinal), $"{actionName} should not resume/rebuild book analysis."); + } + + var selectedReview = ExtractBetween(service, "private async Task GetSelectedReviewAsync", "private async Task GetReviewFrameAsync"); + Assert(selectedReview.Contains("GetReviewFrameAsync(batchId)", StringComparison.Ordinal), "Selected module loads should start from the compact persisted review frame."); + Assert(!selectedReview.Contains("GetProgressAsync", StringComparison.Ordinal), "Selected module loads should not call full progress."); + Assert(!selectedReview.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Selected module orchestration should not read all retained scene JSON."); + Assert(!selectedReview.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Selected module orchestration should not regenerate narrative candidates."); + Assert(!selectedReview.Contains("ResumeBookAsync", StringComparison.Ordinal), "Selected module orchestration should not resume book analysis."); + + var frame = ExtractBetween(service, "private async Task GetReviewFrameAsync", "private sealed record StoryIntelligenceReviewFrame"); + Assert(frame.Contains("GetReviewCentreSummaryAsync(batch.BookID, batch.UserID)", StringComparison.Ordinal), "Selected module frame should use the persisted Review Centre summary."); + Assert(!frame.Contains("BuildReviewAsync", StringComparison.Ordinal), "Selected module frame should not build unrelated review candidates."); + Assert(!frame.Contains("ListSceneResultsAsync", StringComparison.Ordinal), "Selected module frame should not parse retained scene JSON."); + Assert(!frame.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Selected module frame should not generate candidates."); + + var narrativeMethod = ExtractBetween(service, "public Task GetNarrativeReviewAsync", "private async Task GetSelectedReviewAsync"); + Assert(narrativeMethod.Contains("narrativeReview.GetReviewAsync(frame.Batch)", StringComparison.Ordinal), "Plot Lines & Threads should use persisted narrative candidates."); + Assert(!narrativeMethod.Contains("EnsureCandidatesAsync", StringComparison.Ordinal), "Plot Lines & Threads GET should not generate narrative candidates."); +} + +static string ExtractAction(string value, string signature) +{ + var start = value.IndexOf(signature, StringComparison.Ordinal); + Assert(start >= 0, $"Could not find action signature {signature}."); + var next = value.IndexOf(" [Http", start + signature.Length, StringComparison.Ordinal); + if (next < 0) + { + next = value.IndexOf(" private ", start + signature.Length, StringComparison.Ordinal); + } + + return next < 0 ? value[start..] : value[start..next]; +} + static string PlotLineRoot() => Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index e35737d..493f43e 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -323,7 +323,7 @@ public sealed class OnboardingController( [HttpGet("story-intelligence/characters")] public async Task StoryIntelligenceCharacters(Guid batchId) { - var model = await storyIntelligence.GetProgressAsync(batchId); + var model = await storyIntelligence.GetCharacterReviewAsync(batchId); if (model is null) { return NotFound(); @@ -347,7 +347,7 @@ public sealed class OnboardingController( [HttpGet("story-intelligence/locations")] public async Task StoryIntelligenceLocations(Guid batchId) { - var model = await storyIntelligence.GetProgressAsync(batchId); + var model = await storyIntelligence.GetLocationReviewAsync(batchId); if (model is null) { return NotFound(); @@ -371,7 +371,7 @@ public sealed class OnboardingController( [HttpGet("story-intelligence/assets")] public async Task StoryIntelligenceAssets(Guid batchId) { - var model = await storyIntelligence.GetProgressAsync(batchId); + var model = await storyIntelligence.GetAssetReviewAsync(batchId); if (model is null) { return NotFound(); @@ -398,7 +398,7 @@ public sealed class OnboardingController( [HttpGet("story-intelligence/relationships")] public async Task StoryIntelligenceRelationships(Guid batchId) { - var model = await storyIntelligence.GetProgressAsync(batchId); + var model = await storyIntelligence.GetRelationshipReviewAsync(batchId); if (model is null) { return NotFound(); @@ -555,7 +555,7 @@ public sealed class OnboardingController( [HttpGet("story-intelligence/knowledge")] public async Task StoryIntelligenceKnowledge(Guid batchId) { - var model = await storyIntelligence.GetProgressAsync(batchId); + var model = await storyIntelligence.GetKnowledgeReviewAsync(batchId); if (model is null) { return NotFound(); @@ -604,7 +604,7 @@ public sealed class OnboardingController( [HttpGet("story-intelligence/narrative")] public async Task StoryIntelligenceNarrative(Guid batchId) { - var model = await storyIntelligence.GetProgressAsync(batchId); + var model = await storyIntelligence.GetNarrativeReviewAsync(batchId); if (model is null) { return NotFound(); diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index c3312ff..35178e2 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -13,6 +13,12 @@ public interface IOnboardingStoryIntelligenceService Task StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null); Task GetProgressAsync(Guid batchId); Task GetReviewCentreAsync(Guid batchId); + Task GetCharacterReviewAsync(Guid batchId); + Task GetLocationReviewAsync(Guid batchId); + Task GetAssetReviewAsync(Guid batchId); + Task GetRelationshipReviewAsync(Guid batchId); + Task GetKnowledgeReviewAsync(Guid batchId); + Task GetNarrativeReviewAsync(Guid batchId); Task CancelAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId); @@ -724,6 +730,88 @@ public sealed class OnboardingStoryIntelligenceService( public async Task GetReviewCentreAsync(Guid batchId) { var totalStopwatch = Stopwatch.StartNew(); + var frame = await GetReviewFrameAsync(batchId); + if (frame is null) + { + return null; + } + + logger.LogInformation( + "Story Intelligence Review Centre summary built. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} SummaryMs={SummaryMs} TotalMs={TotalMs} ActiveRunCount={ActiveRunCount} NarrativeCandidates={NarrativeCandidateCount} NarrativePending={NarrativePendingCount}", + frame.Batch.BatchID, + frame.Summary.ProjectID, + frame.Summary.BookID, + frame.SummaryElapsedMs, + totalStopwatch.ElapsedMilliseconds, + frame.Summary.ActiveRunCount, + frame.Summary.NarrativeCandidateCount, + frame.Summary.NarrativePendingCount); + + return frame.Model; + } + + public Task GetCharacterReviewAsync(Guid batchId) + => GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await characterImport.BuildReviewAsync(frame.Batch)); + + public Task GetLocationReviewAsync(Guid batchId) + => GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await locationImport.BuildReviewAsync(frame.Batch)); + + public Task GetAssetReviewAsync(Guid batchId) + => GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await assetImport.BuildReviewAsync(frame.Batch)); + + public Task GetRelationshipReviewAsync(Guid batchId) + => GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await relationshipImport.BuildReviewAsync(frame.Batch)); + + public Task GetKnowledgeReviewAsync(Guid batchId) + => GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await knowledgeImport.BuildReviewAsync(frame.Batch)); + + public Task GetNarrativeReviewAsync(Guid batchId) + => GetSelectedReviewAsync(batchId, "PlotLinesThreads", async frame => + { + frame.Model.NarrativeReview = await narrativeReview.GetReviewAsync(frame.Batch); + frame.Model.PlotSynthesis = new WholeBookPlotSynthesisStatusViewModel + { + WholeBookPlotSynthesisRunID = frame.Model.PlotSynthesis.WholeBookPlotSynthesisRunID, + Status = frame.Model.PlotSynthesis.Status, + CandidateCount = frame.Model.NarrativeReview.Candidates.Count, + ThreadCount = frame.Model.NarrativeReview.Candidates.Count(candidate => + string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase)), + EventCount = frame.Model.NarrativeReview.Candidates.Sum(candidate => candidate.Events.Count), + ErrorMessage = frame.Model.PlotSynthesis.ErrorMessage + }; + }); + + private async Task GetSelectedReviewAsync( + Guid batchId, + string module, + Func loadModule) + { + var totalStopwatch = Stopwatch.StartNew(); + var frame = await GetReviewFrameAsync(batchId); + if (frame is null) + { + return null; + } + + var moduleStopwatch = Stopwatch.StartNew(); + await loadModule(frame); + moduleStopwatch.Stop(); + + logger.LogInformation( + "Story Intelligence selected review module built. Module={Module} BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} SummaryMs={SummaryMs} ModuleMs={ModuleMs} TotalMs={TotalMs}", + module, + frame.Batch.BatchID, + frame.Summary.ProjectID, + frame.Summary.BookID, + frame.SummaryElapsedMs, + moduleStopwatch.ElapsedMilliseconds, + totalStopwatch.ElapsedMilliseconds); + + return frame.Model; + } + + private async Task GetReviewFrameAsync(Guid batchId) + { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { @@ -823,20 +911,15 @@ public sealed class OnboardingStoryIntelligenceService( } }; - 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; + return new StoryIntelligenceReviewFrame(batch, summary, model, summaryStopwatch.ElapsedMilliseconds); } + private sealed record StoryIntelligenceReviewFrame( + OnboardingStoryIntelligenceBatch Batch, + StoryIntelligenceReviewCentreSummary Summary, + StoryIntelligenceProgressViewModel Model, + long SummaryElapsedMs); + public async Task CancelAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); @@ -862,7 +945,7 @@ public sealed class OnboardingStoryIntelligenceService( } var result = await commits.CommitAsync(runId, RequireUserId()); - return (await GetProgressAsync(batchId), result); + return (await GetReviewCentreAsync(batchId), result); } public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId) @@ -909,7 +992,7 @@ public sealed class OnboardingStoryIntelligenceService( } var result = await characterImport.ImportAsync(batch, form); - return (await GetProgressAsync(batchId), result); + return (await GetReviewCentreAsync(batchId), result); } public async Task GetCharacterImportResultAsync(Guid batchId) @@ -943,7 +1026,7 @@ public sealed class OnboardingStoryIntelligenceService( } var result = await locationImport.ImportAsync(batch, form); - return (await GetProgressAsync(batchId), result); + return (await GetReviewCentreAsync(batchId), result); } public async Task GetLocationImportResultAsync(Guid batchId) @@ -976,7 +1059,7 @@ public sealed class OnboardingStoryIntelligenceService( } var result = await assetImport.ImportAsync(batch, form); - return (await GetProgressAsync(batchId), result); + return (await GetReviewCentreAsync(batchId), result); } public async Task GetAssetImportResultAsync(Guid batchId) @@ -1010,7 +1093,7 @@ public sealed class OnboardingStoryIntelligenceService( } var result = await relationshipImport.ImportAsync(batch, form); - return (await GetProgressAsync(batchId), result); + return (await GetReviewCentreAsync(batchId), result); } public async Task GetRelationshipImportResultAsync(Guid batchId) @@ -1043,7 +1126,7 @@ public sealed class OnboardingStoryIntelligenceService( } var result = await knowledgeImport.ImportAsync(batch, form); - return (await GetProgressAsync(batchId), result); + return (await GetReviewCentreAsync(batchId), result); } public async Task GetKnowledgeImportResultAsync(Guid batchId) @@ -1076,7 +1159,7 @@ public sealed class OnboardingStoryIntelligenceService( var result = await narrativeReview.ImportAsync(batch, form, RequireUserId()); await batchStore.SaveAsync(batch); - return (await GetProgressAsync(batchId), result); + return (await GetReviewCentreAsync(batchId), result); } public async Task GetNarrativeImportResultAsync(Guid batchId) diff --git a/PlotLine/Services/StoryIntelligenceAssetImportService.cs b/PlotLine/Services/StoryIntelligenceAssetImportService.cs index dc17219..cbd9a44 100644 --- a/PlotLine/Services/StoryIntelligenceAssetImportService.cs +++ b/PlotLine/Services/StoryIntelligenceAssetImportService.cs @@ -55,20 +55,19 @@ public sealed class StoryIntelligenceAssetImportService( public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { - var data = await BuildCandidateDataAsync(batch); var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); if (batch.AssetStageComplete || pipeline?.AssetReviewCompletedUtc is not null) { return new StoryIntelligenceAssetReviewViewModel { - HasCommittedScenes = data.HasCommittedScenes, + HasCommittedScenes = true, CanImport = false, - AlreadyLinkedCount = data.AlreadyLinkedCount, - IsComplete = data.HasCommittedScenes, + IsComplete = true, Candidates = [] }; } + var data = await BuildCandidateDataAsync(batch); var decidedKeys = batch.AssetDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); return new StoryIntelligenceAssetReviewViewModel diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index 65be69d..a7f36db 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -71,20 +71,19 @@ public sealed class StoryIntelligenceCharacterImportService( public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { - var data = await BuildCandidateDataAsync(batch); var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); if (batch.CharacterStageComplete || pipeline?.CharacterReviewCompletedUtc is not null) { return new StoryIntelligenceCharacterReviewViewModel { - HasCommittedScenes = data.HasCommittedScenes, + HasCommittedScenes = true, CanImport = false, - AlreadyLinkedCount = data.AlreadyLinkedCount, - IsComplete = data.HasCommittedScenes, + IsComplete = true, Candidates = [] }; } + var data = await BuildCandidateDataAsync(batch); var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); diff --git a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs index 2ef4415..4290647 100644 --- a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs +++ b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs @@ -29,23 +29,31 @@ public sealed class StoryIntelligenceKnowledgeImportService( public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { + var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); + if (batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null) + { + return new StoryIntelligenceKnowledgeReviewViewModel + { + HasCommittedScenes = true, + CanImport = false, + IsComplete = true, + KnowledgeStateOptions = [], + Candidates = [] + }; + } + var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates); var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var selectedStateIds = batch.KnowledgeSelections.ToDictionary(selection => selection.Key, selection => selection.KnowledgeStateID, StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); - var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); - if (batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null) - { - visibleCandidates = []; - } return new StoryIntelligenceKnowledgeReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, - IsComplete = data.HasCommittedScenes && (visibleCandidates.Count == 0 || batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null), + IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, KnowledgeStateOptions = lookupData.KnowledgeStates .OrderBy(state => state.SortOrder) .ThenBy(state => state.StateName) diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index cea57e1..12a16e5 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -69,20 +69,19 @@ public sealed class StoryIntelligenceLocationImportService( public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { - var data = await BuildCandidateDataAsync(batch); var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); if (batch.LocationStageComplete || pipeline?.LocationReviewCompletedUtc is not null) { return new StoryIntelligenceLocationReviewViewModel { - HasCommittedScenes = data.HasCommittedScenes, + HasCommittedScenes = true, CanImport = false, - AlreadyLinkedCount = data.AlreadyLinkedCount, - IsComplete = data.HasCommittedScenes, + IsComplete = true, Candidates = [] }; } + var data = await BuildCandidateDataAsync(batch); var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); return new StoryIntelligenceLocationReviewViewModel diff --git a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs index e19b8e8..02c134a 100644 --- a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs +++ b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs @@ -28,24 +28,32 @@ public sealed class StoryIntelligenceRelationshipImportService( public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { + var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); + if (batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null) + { + return new StoryIntelligenceRelationshipReviewViewModel + { + HasCommittedScenes = true, + CanImport = false, + IsComplete = true, + RelationshipTypeOptions = [], + Candidates = [] + }; + } + var data = await BuildCandidateDataAsync(batch); var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var typeOptions = ToRelationshipTypeOptions(lookupData.RelationshipTypes); var selectedTypeIds = batch.RelationshipSelections.ToDictionary(selection => selection.Key, selection => selection.RelationshipTypeID, StringComparer.OrdinalIgnoreCase); var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); - var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); - if (batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null) - { - visibleCandidates = []; - } return new StoryIntelligenceRelationshipReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, - IsComplete = data.HasCommittedScenes && (visibleCandidates.Count == 0 || batch.RelationshipStageComplete || pipeline?.RelationshipReviewCompletedUtc is not null), + IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, RelationshipTypeOptions = typeOptions, Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel { diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 011e13f..528a095 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -169,13 +169,13 @@ public sealed class StoryIntelligenceProgressViewModel public string? ProjectName { get; init; } public string? BookTitle { get; init; } public IReadOnlyList Chapters { get; init; } = []; - public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new(); - public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new(); - public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new(); - public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new(); - public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; init; } = new(); - public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; init; } = new(); - public WholeBookPlotSynthesisStatusViewModel PlotSynthesis { get; init; } = new(); + public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; set; } = new(); + public StoryIntelligenceLocationReviewViewModel LocationReview { get; set; } = new(); + public StoryIntelligenceAssetReviewViewModel AssetReview { get; set; } = new(); + public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; set; } = new(); + public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; set; } = new(); + public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; set; } = new(); + public WholeBookPlotSynthesisStatusViewModel PlotSynthesis { get; set; } = new(); public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public int ChapterCount => Chapters.Count; public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);