using System.Text.Json; using Microsoft.AspNetCore.Mvc.Rendering; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryIntelligenceReviewCandidateService { Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch); Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch); Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch); Task GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); Task GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize, string scope = "significant"); Task GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); Task GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); Task GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); Task MarkCharacterDecisionsAsync(int bookId, IEnumerable choices); Task MarkLocationDecisionsAsync(int bookId, IEnumerable choices); Task MarkAssetDecisionsAsync(int bookId, IEnumerable choices); Task MarkRelationshipDecisionsAsync(int bookId, IEnumerable choices); Task MarkKnowledgeDecisionsAsync(int bookId, IEnumerable choices); Task CompleteModuleIfReadyAsync(OnboardingStoryIntelligenceBatch batch, string module); } public sealed class StoryIntelligenceReviewCandidateService( IStoryIntelligenceReviewCandidateRepository candidates, IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceLocationImportService locationImport, IStoryIntelligenceAssetImportService assetImport, IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceKnowledgeImportService knowledgeImport, ICharacterRepository characters, ILocationRepository locations, IAssetRepository assets, IStoryIntelligencePipelineStateService pipelineState, ILogger logger) : IStoryIntelligenceReviewCandidateService { private const int MaxPageSize = 100; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public async Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch) { var automatic = await characterImport.ResolveAutomaticMatchesAsync(batch); var review = await characterImport.BuildReviewAsync(batch); await PersistAsync( batch, StoryIntelligenceReviewModules.Characters, review.Candidates, candidate => candidate.Key, candidate => candidate.CharacterName, candidate => candidate.Classification, candidate => candidate.ExistingCharacterID, candidate => candidate.PossibleAliases.Take(5)); if (automatic.CandidatesResolved > 0) { logger.LogInformation( "Persisted Story Intelligence character review omitted automatically resolved identities. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneAppearancesLinked={SceneAppearancesLinked}", batch.BatchID, batch.ProjectID, batch.BookID, automatic.CandidatesResolved, automatic.SceneAppearancesLinked); } } public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch) { await PrepareLocationCandidatesAsync(batch); var automaticAssets = await assetImport.ResolveAutomaticMatchesAsync(batch); var assetReview = await assetImport.BuildReviewAsync(batch); await PersistAsync( batch, StoryIntelligenceReviewModules.Assets, assetReview.Candidates, candidate => candidate.Key, candidate => candidate.AssetName, candidate => candidate.Category, candidate => candidate.ExistingAssetID, candidate => candidate.PossibleAliases.Take(5)); if (automaticAssets.CandidatesResolved > 0) { logger.LogInformation( "Persisted Story Intelligence asset review omitted automatically resolved identities. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneEventsCreated={SceneEventsCreated} OwnershipLinksCreated={OwnershipLinksCreated}", batch.BatchID, batch.ProjectID, batch.BookID, automaticAssets.CandidatesResolved, automaticAssets.SceneEventsCreated, automaticAssets.OwnershipLinksCreated); } await PersistAsync( batch, StoryIntelligenceReviewModules.Relationships, (await relationshipImport.BuildReviewAsync(batch)).Candidates, candidate => candidate.Key, candidate => $"{candidate.CharacterAName} / {candidate.CharacterBName}", candidate => candidate.RelationshipType, candidate => candidate.ExistingRelationshipID, candidate => new[] { candidate.ExampleScene, candidate.ExampleContext }.Where(value => !string.IsNullOrWhiteSpace(value))!); await PersistAsync( batch, StoryIntelligenceReviewModules.Knowledge, (await knowledgeImport.BuildReviewAsync(batch)).Candidates, candidate => candidate.Key, candidate => candidate.KnowledgeStatement, candidate => candidate.KnowledgeStateName, candidate => candidate.ExistingKnowledgeID, candidate => new[] { candidate.FirstScene, candidate.ExampleScene, candidate.Evidence }.Where(value => !string.IsNullOrWhiteSpace(value))!); } public async Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch) { var automatic = await locationImport.ResolveAutomaticMatchesAsync(batch); var review = await locationImport.BuildReviewAsync(batch); await PersistAsync( batch, StoryIntelligenceReviewModules.Locations, review.Candidates, candidate => candidate.Key, candidate => candidate.LocationName, candidate => candidate.Category, candidate => candidate.ExistingLocationID, candidate => candidate.EvidenceSummaries.Take(5)); if (automatic.CandidatesResolved > 0) { logger.LogInformation( "Persisted Story Intelligence location review omitted automatically resolved identities. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneLocationsLinked={SceneLocationsLinked}", batch.BatchID, batch.ProjectID, batch.BookID, automatic.CandidatesResolved, automatic.SceneLocationsLinked); } } public async Task GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) { var page = await LoadPageAsync(batch.BookID, StoryIntelligenceReviewModules.Characters, pageNumber, pageSize); var existingOptions = (await characters.ListCharactersAsync(batch.ProjectID)) .Select(character => new StoryIntelligenceCharacterExistingOptionViewModel { CharacterID = character.CharacterID, CharacterName = character.CharacterName }) .OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase) .ToList(); var candidatesOnPage = page.Items .Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel { Key = candidate.Key, CharacterName = candidate.CharacterName, ImportName = candidate.ImportName, DefaultAction = candidate.DefaultAction, Classification = candidate.Classification, AppearsInScenes = candidate.AppearsInScenes, Confidence = candidate.Confidence, PossibleAliases = candidate.PossibleAliases, ExampleFirstAppearance = candidate.ExampleFirstAppearance, NameIntelligenceWarning = candidate.NameIntelligenceWarning, ExistingCharacterID = candidate.ExistingCharacterID, ExistingCharacterName = candidate.ExistingCharacterName, SelectedExistingCharacterID = candidate.SelectedExistingCharacterID, ExistingCharacterOptions = existingOptions }) .ToList(); return new StoryIntelligenceCharacterReviewViewModel { HasCommittedScenes = true, CanImport = candidatesOnPage.Count > 0, IsComplete = page.GenerationReady && page.PendingCount == 0, SummaryNeedsAttentionCount = page.PendingCount, PageNumber = page.PageNumber, PageSize = page.PageSize, TotalCandidateCount = page.TotalCount, PendingCandidateCount = page.PendingCount, GenericReferenceCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.GenericReference), ExistingMatchCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.ExistingCharacter), NewCandidateCount = candidatesOnPage.Count(candidate => candidate.Classification is StoryIntelligenceCharacterClassifications.NewCharacter or StoryIntelligenceCharacterClassifications.NewAlias), AmbiguousCount = candidatesOnPage.Count(candidate => candidate.Classification == StoryIntelligenceCharacterClassifications.Ambiguous), AliasTargetOptions = existingOptions, Candidates = candidatesOnPage }; } public async Task GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize, string scope = "significant") { scope = scope is "all" or "uncertain" ? scope : "significant"; var generation = await candidates.GetCurrentGenerationAsync(batch.BookID, StoryIntelligenceReviewModules.Locations); var rows = await candidates.ListLocationsAsync(batch.BookID); var all = rows.Select(row => { var item = JsonSerializer.Deserialize(row.CandidatePayloadJson, JsonOptions)!; item.DecisionStatus = row.DecisionStatus; return item; }).ToList(); var ordered = LocationSignificanceService.OrderHierarchy(all, scope); pageSize = Math.Clamp(pageSize, 1, MaxPageSize); pageNumber = Math.Clamp(pageNumber, 1, Math.Max(1, (int)Math.Ceiling(ordered.Count / (double)pageSize))); var items = ordered.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToList(); var pendingCount = all.Count(x => x.IsPending); var uncertainCount = all.Count(x => x.IsPending && x.NeedsReview); var projectLocations = (await locations.ListByProjectAsync(batch.ProjectID)).ToList(); var parentOptions = projectLocations .OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase) .ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase) .Select(location => new StoryIntelligenceLocationParentOptionViewModel { LocationID = location.LocationID, LocationName = location.LocationName, LocationPath = location.LocationPath }) .ToList(); var sameLocationOptions = await BuildSameLocationOptionsAsync(projectLocations); var targetOptions = await BuildLocationTargetOptionsAsync(projectLocations, all.Where(x => x.IsPending).ToList()); return new StoryIntelligenceLocationReviewViewModel { HasCommittedScenes = true, CanImport = items.Any(x => x.IsPending), IsComplete = generation?.Status == StoryIntelligenceReviewCandidateGenerationStatuses.Ready && pendingCount == 0, Scope = scope, SignificantCount = all.Count(x => x.Significance?.IsSignificant == true), UncertainCount = uncertainCount, FilteredCount = ordered.Count, SummaryNeedsAttentionCount = uncertainCount, PageNumber = pageNumber, PageSize = pageSize, TotalCandidateCount = all.Count, PendingCandidateCount = pendingCount, ParentLocationOptions = parentOptions, SameLocationOptions = sameLocationOptions, LocationTargetOptions = targetOptions, Candidates = items }; } public async Task GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) { var page = await LoadPageAsync(batch.BookID, StoryIntelligenceReviewModules.Assets, pageNumber, pageSize); var allPendingPage = await LoadPageAsync(batch.BookID, StoryIntelligenceReviewModules.Assets, 1, MaxPageSize); var projectAssets = (await assets.ListAssetsAsync(batch.ProjectID)).ToList(); var targetOptions = await BuildAssetTargetOptionsAsync(projectAssets, allPendingPage.Items); return new StoryIntelligenceAssetReviewViewModel { HasCommittedScenes = true, CanImport = page.Items.Count > 0, IsComplete = page.GenerationReady && page.PendingCount == 0, SummaryNeedsAttentionCount = page.PendingCount, PageNumber = page.PageNumber, PageSize = page.PageSize, TotalCandidateCount = page.TotalCount, PendingCandidateCount = page.PendingCount, AssetTargetOptions = targetOptions, Candidates = page.Items }; } public async Task GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) { var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var page = await LoadPageAsync(batch.BookID, StoryIntelligenceReviewModules.Relationships, pageNumber, pageSize); return new StoryIntelligenceRelationshipReviewViewModel { HasCommittedScenes = true, CanImport = page.Items.Count > 0, IsComplete = page.GenerationReady && page.PendingCount == 0, SummaryNeedsAttentionCount = page.PendingCount, PageNumber = page.PageNumber, PageSize = page.PageSize, TotalCandidateCount = page.TotalCount, PendingCandidateCount = page.PendingCount, RelationshipTypeOptions = lookupData.RelationshipTypes .OrderBy(type => type.SortOrder) .ThenBy(type => type.TypeName) .Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString())) .ToList(), Candidates = page.Items }; } public async Task GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) { var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var page = await LoadPageAsync(batch.BookID, StoryIntelligenceReviewModules.Knowledge, pageNumber, pageSize); return new StoryIntelligenceKnowledgeReviewViewModel { HasCommittedScenes = true, CanImport = page.Items.Count > 0, IsComplete = page.GenerationReady && page.PendingCount == 0, SummaryNeedsAttentionCount = page.PendingCount, PageNumber = page.PageNumber, PageSize = page.PageSize, TotalCandidateCount = page.TotalCount, PendingCandidateCount = page.PendingCount, KnowledgeStateOptions = lookupData.KnowledgeStates .OrderBy(state => state.SortOrder) .ThenBy(state => state.StateName) .Select(state => new SelectListItem(state.StateName, state.KnowledgeStateID.ToString())) .ToList(), Candidates = page.Items }; } public async Task MarkCharacterDecisionsAsync(int bookId, IEnumerable choices) { StoryIntelligenceReviewCandidateGeneration? generation = null; foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))) { generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest { BookID = bookId, Module = StoryIntelligenceReviewModules.Characters, StableKey = choice.Key, DecisionAction = choice.Action, DecisionTargetKey = choice.AliasTargetKey, DecisionCanonicalID = choice.ExistingCharacterID ?? choice.AliasTargetCharacterID }); } return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Characters))?.PendingCount ?? 0; } public async Task MarkLocationDecisionsAsync(int bookId, IEnumerable choices) { StoryIntelligenceReviewCandidateGeneration? generation = null; foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))) { generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest { BookID = bookId, Module = StoryIntelligenceReviewModules.Locations, StableKey = choice.Key, DecisionAction = choice.Action, DecisionTargetKey = choice.AliasTargetKey, DecisionCanonicalID = choice.ExistingLocationID }); } return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Locations))?.PendingCount ?? 0; } private async Task> BuildSameLocationOptionsAsync(IReadOnlyList projectLocations) { var options = new List(); foreach (var location in projectLocations) { var aliasNames = (await locations.ListAliasesAsync(location.LocationID)) .Select(alias => alias.Alias) .Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, location.LocationName, StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase) .ToList(); options.Add(new StoryIntelligenceLocationIdentityOptionViewModel { LocationID = location.LocationID, LocationName = location.LocationName, AliasSummary = string.Join(", ", aliasNames.Take(4)) }); } return options .OrderBy(location => location.LocationName, NaturalStringComparer.OrdinalIgnoreCase) .ThenBy(location => location.LocationID) .ToList(); } private async Task> BuildLocationTargetOptionsAsync( IReadOnlyList projectLocations, IReadOnlyList pendingCandidates) { var canonicalKeys = new HashSet(StringComparer.OrdinalIgnoreCase); var options = new List(); foreach (var location in projectLocations) { var aliasNames = (await locations.ListAliasesAsync(location.LocationID)) .Select(alias => alias.Alias) .Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, location.LocationName, StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase) .ToList(); canonicalKeys.Add(LocationTargetKey(location.LocationName)); options.Add(new StoryIntelligenceLocationTargetOptionViewModel { Value = $"existing:{location.LocationID}", LocationID = location.LocationID, DisplayName = location.LocationName, GroupName = "Existing locations", AliasSummary = string.Join(", ", aliasNames.Take(4)) }); } foreach (var candidate in pendingCandidates.Where(candidate => !candidate.ExistingLocationID.HasValue)) { var key = LocationTargetKey(candidate.LocationName); if (string.IsNullOrWhiteSpace(key) || canonicalKeys.Contains(key)) { continue; } options.Add(new StoryIntelligenceLocationTargetOptionViewModel { Value = $"candidate:{candidate.Key}", CandidateKey = candidate.Key, DisplayName = candidate.LocationName, GroupName = "Pending suggestions" }); } return options .OrderBy(option => option.GroupName, StringComparer.Ordinal) .ThenBy(option => option.DisplayName, NaturalStringComparer.OrdinalIgnoreCase) .ThenBy(option => option.Value, StringComparer.OrdinalIgnoreCase) .ToList(); } private static string LocationTargetKey(string? name) => StoryIntelligenceEntityTextNormaliser.SafeKey(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty)); private async Task> BuildAssetTargetOptionsAsync( IReadOnlyList projectAssets, IReadOnlyList pendingCandidates) { var canonicalKeys = new HashSet(StringComparer.OrdinalIgnoreCase); var options = new List(); foreach (var asset in projectAssets) { var aliasNames = (await assets.ListAliasesAsync(asset.StoryAssetID)) .Select(alias => alias.Alias) .Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, asset.AssetName, StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase) .ToList(); canonicalKeys.Add(AssetTargetKey(asset.AssetName)); foreach (var alias in aliasNames) { canonicalKeys.Add(AssetTargetKey(alias)); } options.Add(new StoryIntelligenceAssetTargetOptionViewModel { Value = $"existing:{asset.StoryAssetID}", AssetID = asset.StoryAssetID, DisplayName = asset.AssetName, GroupName = "Existing assets", AliasSummary = string.Join(", ", aliasNames.Take(4)) }); } foreach (var candidate in pendingCandidates.Where(candidate => !candidate.ExistingAssetID.HasValue)) { var key = AssetTargetKey(candidate.AssetName); if (string.IsNullOrWhiteSpace(key) || canonicalKeys.Contains(key)) { continue; } options.Add(new StoryIntelligenceAssetTargetOptionViewModel { Value = $"candidate:{candidate.Key}", CandidateKey = candidate.Key, DisplayName = candidate.AssetName, GroupName = "Pending suggestions" }); } return options .OrderBy(option => option.GroupName, StringComparer.Ordinal) .ThenBy(option => option.DisplayName, NaturalStringComparer.OrdinalIgnoreCase) .ThenBy(option => option.Value, StringComparer.OrdinalIgnoreCase) .ToList(); } private static string AssetTargetKey(string? name) => StoryIntelligenceEntityTextNormaliser.SafeKey(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty)); public async Task MarkAssetDecisionsAsync(int bookId, IEnumerable choices) { StoryIntelligenceReviewCandidateGeneration? generation = null; foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))) { generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest { BookID = bookId, Module = StoryIntelligenceReviewModules.Assets, StableKey = choice.Key, DecisionAction = choice.Action, DecisionTargetKey = choice.AliasTargetKey, DecisionCanonicalID = choice.ExistingAssetID }); } return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Assets))?.PendingCount ?? 0; } public async Task MarkRelationshipDecisionsAsync(int bookId, IEnumerable choices) { StoryIntelligenceReviewCandidateGeneration? generation = null; foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))) { generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest { BookID = bookId, Module = StoryIntelligenceReviewModules.Relationships, StableKey = choice.Key, DecisionAction = choice.Action, DecisionTargetKey = choice.AliasTargetKey, DecisionCanonicalID = choice.RelationshipTypeID }); } return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Relationships))?.PendingCount ?? 0; } public async Task MarkKnowledgeDecisionsAsync(int bookId, IEnumerable choices) { StoryIntelligenceReviewCandidateGeneration? generation = null; foreach (var choice in choices.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))) { generation = await candidates.MarkReviewedAsync(new StoryIntelligenceReviewCandidateDecisionRequest { BookID = bookId, Module = StoryIntelligenceReviewModules.Knowledge, StableKey = choice.Key, DecisionAction = choice.Action, DecisionTargetKey = choice.AliasTargetKey, DecisionCanonicalID = choice.KnowledgeStateID }); } return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Knowledge))?.PendingCount ?? 0; } private async Task PersistAsync( OnboardingStoryIntelligenceBatch batch, string module, IReadOnlyList sourceCandidates, Func stableKey, Func displayName, Func candidateType, Func existingCanonicalId, Func> evidence) { var generation = await candidates.BeginGenerationAsync(batch.ProjectID, batch.BookID, module, "SceneIntelligencePreparation"); try { var sortOrder = 0; foreach (var candidate in sourceCandidates) { sortOrder++; var row = await candidates.SaveCandidateAsync(new StoryIntelligenceReviewCandidateSaveRequest { ReviewCandidateGenerationID = generation.ReviewCandidateGenerationID, ProjectID = batch.ProjectID, BookID = batch.BookID, Module = module, StableKey = stableKey(candidate), CandidateType = candidateType(candidate), DisplayName = displayName(candidate), SortOrder = sortOrder, ExistingCanonicalID = existingCanonicalId(candidate), CandidatePayloadJson = JsonSerializer.Serialize(candidate, JsonOptions) }); var evidenceOrder = 0; foreach (var item in evidence(candidate).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).Take(5)) { evidenceOrder++; await candidates.SaveEvidenceAsync(row.ReviewCandidateID, null, item!, null, evidenceOrder); } } var completedGeneration = await candidates.CompleteGenerationAsync(generation.ReviewCandidateGenerationID); await CompleteModuleIfReadyAsync(batch, module, completedGeneration); logger.LogInformation( "Prepared persisted Story Intelligence review candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} Module={Module} Count={Count}", batch.BatchID, batch.ProjectID, batch.BookID, module, sourceCandidates.Count); } catch (Exception ex) { await candidates.FailGenerationAsync(generation.ReviewCandidateGenerationID, ex.Message); logger.LogError(ex, "Persisted Story Intelligence review candidate preparation failed. BatchID={BatchID} BookID={BookID} Module={Module}", batch.BatchID, batch.BookID, module); throw; } } public async Task CompleteModuleIfReadyAsync(OnboardingStoryIntelligenceBatch batch, string module) { var generation = await candidates.GetCurrentGenerationAsync(batch.BookID, module); return await CompleteModuleIfReadyAsync(batch, module, generation); } private async Task CompleteModuleIfReadyAsync( OnboardingStoryIntelligenceBatch batch, string module, StoryIntelligenceReviewCandidateGeneration? generation) { if (generation is null || !string.Equals(generation.Status, StoryIntelligenceReviewCandidateGenerationStatuses.Ready, StringComparison.OrdinalIgnoreCase) || generation.PendingCount != 0) { return generation?.PendingCount ?? 0; } switch (module) { case StoryIntelligenceReviewModules.Characters: await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID); break; case StoryIntelligenceReviewModules.Locations: await pipelineState.RecordLocationImportAsync(batch.ProjectID, batch.BookID); break; case StoryIntelligenceReviewModules.Assets: await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID); break; case StoryIntelligenceReviewModules.Relationships: await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID); break; case StoryIntelligenceReviewModules.Knowledge: await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID); break; default: logger.LogWarning("Ignoring unsupported Story Intelligence review completion module {Module}. BatchID={BatchID} BookID={BookID}", module, batch.BatchID, batch.BookID); break; } return 0; } private async Task> LoadPageAsync(int bookId, string module, int pageNumber, int pageSize) { pageNumber = Math.Max(1, pageNumber); pageSize = Math.Clamp(pageSize <= 0 ? 25 : pageSize, 1, MaxPageSize); var generation = await candidates.GetCurrentGenerationAsync(bookId, module); if (generation is null || !string.Equals(generation.Status, StoryIntelligenceReviewCandidateGenerationStatuses.Ready, StringComparison.OrdinalIgnoreCase)) { return new CandidatePage([], pageNumber, pageSize, 0, 0, false); } var maxPage = generation.PendingCount <= 0 ? 1 : (int)Math.Ceiling(generation.PendingCount / (double)pageSize); pageNumber = Math.Clamp(pageNumber, 1, maxPage); var rows = await candidates.ListPageAsync(bookId, module, pageNumber, pageSize); var items = rows.Select(row => JsonSerializer.Deserialize(row.CandidatePayloadJson, JsonOptions)) .Where(item => item is not null) .Select(item => item!) .ToList(); var totalCount = rows.FirstOrDefault()?.TotalCount ?? generation.CandidateCount; var pendingCount = rows.FirstOrDefault()?.PendingCount ?? generation.PendingCount; return new CandidatePage(items, pageNumber, pageSize, totalCount, pendingCount, true); } private sealed record CandidatePage( IReadOnlyList Items, int PageNumber, int PageSize, int TotalCount, int PendingCount, bool GenerationReady); }