Consolidate story intelligence review module loads

This commit is contained in:
Nick Beckley 2026-08-30 20:46:31 +00:00
parent a1cba8a057
commit d6d52b09e3
9 changed files with 210 additions and 58 deletions

View File

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

View File

@ -323,7 +323,7 @@ public sealed class OnboardingController(
[HttpGet("story-intelligence/characters")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> StoryIntelligenceNarrative(Guid batchId)
{
var model = await storyIntelligence.GetProgressAsync(batchId);
var model = await storyIntelligence.GetNarrativeReviewAsync(batchId);
if (model is null)
{
return NotFound();

View File

@ -13,6 +13,12 @@ public interface IOnboardingStoryIntelligenceService
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> GetNarrativeReviewAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
@ -724,6 +730,88 @@ public sealed class OnboardingStoryIntelligenceService(
public async Task<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId)
=> GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await characterImport.BuildReviewAsync(frame.Batch));
public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId)
=> GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await locationImport.BuildReviewAsync(frame.Batch));
public Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId)
=> GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await assetImport.BuildReviewAsync(frame.Batch));
public Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId)
=> GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await relationshipImport.BuildReviewAsync(frame.Batch));
public Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId)
=> GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await knowledgeImport.BuildReviewAsync(frame.Batch));
public Task<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceProgressViewModel?> GetSelectedReviewAsync(
Guid batchId,
string module,
Func<StoryIntelligenceReviewFrame, Task> 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<StoryIntelligenceReviewFrame?> 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<StoryIntelligenceProgressViewModel?> 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<StoryIntelligenceCharacterImportResultViewModel?> 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<StoryIntelligenceLocationImportResultViewModel?> 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<StoryIntelligenceAssetImportResultViewModel?> 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<StoryIntelligenceRelationshipImportResultViewModel?> 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<StoryIntelligenceKnowledgeImportResultViewModel?> 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<StoryIntelligenceNarrativeImportResultViewModel?> GetNarrativeImportResultAsync(Guid batchId)

View File

@ -55,20 +55,19 @@ public sealed class StoryIntelligenceAssetImportService(
public async Task<StoryIntelligenceAssetReviewViewModel> 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

View File

@ -71,20 +71,19 @@ public sealed class StoryIntelligenceCharacterImportService(
public async Task<StoryIntelligenceCharacterReviewViewModel> 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);

View File

@ -29,23 +29,31 @@ public sealed class StoryIntelligenceKnowledgeImportService(
public async Task<StoryIntelligenceKnowledgeReviewViewModel> 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)

View File

@ -69,20 +69,19 @@ public sealed class StoryIntelligenceLocationImportService(
public async Task<StoryIntelligenceLocationReviewViewModel> 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

View File

@ -28,24 +28,32 @@ public sealed class StoryIntelligenceRelationshipImportService(
public async Task<StoryIntelligenceRelationshipReviewViewModel> 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
{

View File

@ -169,13 +169,13 @@ public sealed class StoryIntelligenceProgressViewModel
public string? ProjectName { get; init; }
public string? BookTitle { get; init; }
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> 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);