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 PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch); Task GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); Task GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize); 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); } public sealed class StoryIntelligenceReviewCandidateService( IStoryIntelligenceReviewCandidateRepository candidates, IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceLocationImportService locationImport, IStoryIntelligenceAssetImportService assetImport, IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceKnowledgeImportService knowledgeImport, ICharacterRepository characters, ILogger logger) : IStoryIntelligenceReviewCandidateService { private const int MaxPageSize = 100; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public async Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch 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)); } public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch) { await PersistAsync( batch, StoryIntelligenceReviewModules.Locations, (await locationImport.BuildReviewAsync(batch)).Candidates, candidate => candidate.Key, candidate => candidate.LocationName, candidate => candidate.Category, candidate => candidate.ExistingLocationID, candidate => candidate.EvidenceSummaries.Take(5)); await PersistAsync( batch, StoryIntelligenceReviewModules.Assets, (await assetImport.BuildReviewAsync(batch)).Candidates, candidate => candidate.Key, candidate => candidate.AssetName, candidate => candidate.Category, candidate => candidate.ExistingAssetID, candidate => candidate.PossibleAliases.Take(5)); 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 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) { var page = await LoadPageAsync(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize); return new StoryIntelligenceLocationReviewViewModel { 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, Candidates = page.Items }; } public async Task GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize) { var page = await LoadPageAsync(batch.BookID, StoryIntelligenceReviewModules.Assets, pageNumber, pageSize); 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, 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 }); } return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Locations))?.PendingCount ?? 0; } 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 }); } 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); } } await candidates.CompleteGenerationAsync(generation.ReviewCandidateGenerationID); 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; } } 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 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); }