using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IOnboardingStoryIntelligenceService { Task GetOverviewAsync(); 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, int pageNumber = 1, int pageSize = 25); Task GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25); Task GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25); Task GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25); Task GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25); 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); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form); Task GetCharacterImportResultAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form); Task GetLocationImportResultAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form); Task GetAssetImportResultAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form); Task GetRelationshipImportResultAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form); Task GetKnowledgeImportResultAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form); Task GetNarrativeImportResultAsync(Guid batchId); Task GetCompletionAsync(Guid batchId); Task ResumeBookAsync(int bookId); Task StartBookAsync(int bookId); } public sealed class OnboardingStoryIntelligenceService( IOnboardingRepository onboardingRepository, IOnboardingService onboarding, IManuscriptScanPreviewStore scanStore, IStoryIntelligenceResultRepository runs, IStoryIntelligenceImportCommitService commits, IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceLocationImportService locationImport, IStoryIntelligenceAssetImportService assetImport, IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceKnowledgeImportService knowledgeImport, IStoryIntelligenceNarrativeReviewService narrativeReview, IWholeBookPlotIntelligenceService wholeBookPlotIntelligence, ICharacterEnrichmentService characterEnrichment, IStoryIntelligenceSourceRepository sources, IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligenceReviewCandidateService reviewCandidates, IStoryMemoryRepository storyMemory, IBookRepository books, IChapterRepository chapters, ICharacterRepository characters, IStoryIntelligenceClient client, IOnboardingStoryIntelligenceBatchStore batchStore, IStoryIntelligenceProgressNotifier notifier, ICurrentUserService currentUser, IStoryIntelligenceEntitlementService entitlement, ILogger logger) : IOnboardingStoryIntelligenceService { private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2"; private const string SourceType = "OnboardingWordCompanionChapter"; private const string BookSourceType = "BookCanonicalSceneSource"; public async Task GetOverviewAsync() { var userId = RequireUserId(); var context = await GetContextAsync(userId); if (context is null) { return new StoryIntelligenceOverviewViewModel { Message = "Finish the Word Companion scan and review before analysing your manuscript." }; } var (_, preview, review, build, wizard) = context.Value; var included = IncludedChapters(preview, review); var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID); var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)); const int storySummaryStep = 10; const int storyContextStep = 10; const int totalWizardSteps = 11; return new StoryIntelligenceOverviewViewModel { PreviewID = preview.PreviewID, ProjectID = preview.ProjectID, BookID = preview.BookID, SelectedProjectName = wizard.SelectedProjectName, SelectedBookTitle = wizard.SelectedBookTitle, ApprovedChapterCount = included.Count, ApprovedWordCount = included.Sum(chapter => chapter.Preview.WordCount), MissingChapterTextCount = missingTextCount, WizardCurrentStep = storySummaryStep, WizardContextStep = storyContextStep, WizardTotalSteps = totalWizardSteps, ExistingBatchID = existingBatch?.BatchID, CanStart = included.Count > 0 && missingTextCount == 0, Message = build is null ? "PlotDirector will prepare the approved chapters, then analyse them with Story Intelligence." : "The approved chapters are ready to analyse." }; } public async Task StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null) { var userId = RequireUserId(); await entitlement.EnsureCanQueueAsync(userId); var context = await GetContextAsync(userId); if (context is null) { return null; } var (state, preview, review, build, wizard) = context.Value; var book = await books.GetAsync(preview.BookID); var existing = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID); if (existing is not null) { return existing; } if (build is null || build.SceneMappings.Count == 0) { throw new InvalidOperationException("Import the manuscript structure before starting Story Intelligence."); } var chapterIds = build.ChapterMappings.ToDictionary(chapter => chapter.TemporaryChapterKey, chapter => chapter.ChapterID, StringComparer.Ordinal); var included = IncludedChapters(preview, review); if (included.Count == 0) { throw new InvalidOperationException("Choose at least one chapter before analysing the manuscript."); } var clientStatus = client.GetConfigurationStatus(); var modelSummary = StoryIntelligenceOptions.BuildModelSummary( clientStatus.ChapterStructureModel, clientStatus.SceneIntelligenceModel); var batchItems = new List(); var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build); foreach (var chapter in included) { if (!chapterIds.TryGetValue(chapter.Preview.TemporaryChapterKey, out var chapterId)) { chapterId = chapter.Preview.ExistingChapterID ?? 0; } if (chapterId <= 0) { throw new InvalidOperationException($"PlotDirector could not prepare chapter '{chapter.Decision.Title}' for analysis."); } var durableSource = await sources.GetChapterSourceAsync(chapterId); var text = durableSource?.SourceText?.Trim(); if (string.IsNullOrWhiteSpace(text)) { text = chapter.Preview.ChapterText?.Trim(); } if (string.IsNullOrWhiteSpace(text)) { throw new InvalidOperationException($"PlotDirector does not have persisted scene source text for chapter '{chapter.Decision.Title}'."); } var paragraphs = StoryIntelligenceParagraphs.Split(text); var canonicalChapterTitle = CanonicalChapterTitle(await chapters.GetAsync(chapterId), chapter.Decision.ChapterNumber); var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest { UserID = userId, ProjectID = preview.ProjectID, BookID = preview.BookID, ChapterID = chapterId, ChapterNumber = chapter.Decision.ChapterNumber, SourceType = SourceType, SourceLabel = $"{wizard.SelectedBookTitle}: {canonicalChapterTitle}", ChapterText = text, SourceFileName = preview.DocumentTitle, SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text), SourceCharacterCount = durableSource?.SourceCharacterCount ?? text.Length, SourceParagraphCount = paragraphs.Count, SourceChapterCount = 1, Model = modelSummary, PromptVersionsSummary = PromptVersionsSummary, KnownCharactersJson = knownCharactersJson }); batchItems.Add(new OnboardingStoryIntelligenceBatchItem { TemporaryChapterKey = chapter.Preview.TemporaryChapterKey, ChapterNumber = chapter.Decision.ChapterNumber, ChapterTitle = canonicalChapterTitle, ChapterID = chapterId, RunID = runId }); await pipelineState.RecordChapterAnalysisQueuedAsync(preview.ProjectID, preview.BookID, runId); await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent { RunID = runId, UserID = userId, ProjectID = preview.ProjectID, BookID = preview.BookID, ChapterID = chapterId, ChapterNumber = chapter.Decision.ChapterNumber, Status = StoryIntelligenceRunStatuses.Pending, CurrentStage = "Queued", CurrentMessage = $"{chapter.Decision.Title} is queued for analysis.", EventType = "Import started", ElapsedTime = "0 sec", EstimatedRemaining = "Calculating", IsActive = true }); } var batch = new OnboardingStoryIntelligenceBatch { UserID = userId, OnboardingID = state.UserOnboardingStateID, PreviewID = preview.PreviewID, ProjectID = preview.ProjectID, BookID = preview.BookID, ProjectName = wizard.SelectedProjectName, BookTitle = wizard.SelectedBookTitle, AppearancePreference = NormaliseAppearancePreference(appearancePreference), PrimaryAgeGroup = CleanContext(primaryAgeGroup), StoryEra = CleanContext(storyEra) is { Length: > 0 } suppliedEra ? suppliedEra : CleanContext(BookContextOptions.StoryEraLabel(book?.StoryEra)), StoryLocation = CleanContext(storyLocation) is { Length: > 0 } suppliedLocation ? suppliedLocation : CleanContext(BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)), Items = batchItems }; if (await pipelineState.GetForBookAsync(preview.BookID, userId) is { } pipeline) { await storyMemory.SetImportContextAsync( pipeline.StoryIntelligenceBookPipelineID, batch.AppearancePreference, batch.PrimaryAgeGroup, batch.StoryEra, batch.StoryLocation); } await batchStore.SaveAsync(batch); logger.LogInformation( "Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}", batch.BatchID, userId, preview.ProjectID, preview.BookID, batchItems.Count); return batch; } public async Task StartBookAsync(int bookId) { var userId = RequireUserId(); await entitlement.EnsureCanQueueAsync(userId); var book = await books.GetAsync(bookId); if (book is null) { return null; } var chapterRows = (await chapters.ListByBookAsync(bookId)) .Where(chapter => !chapter.IsArchived) .OrderBy(chapter => chapter.SortOrder) .ThenBy(chapter => chapter.ChapterNumber) .ThenBy(chapter => chapter.ChapterID) .ToList(); if (chapterRows.Count == 0) { throw new InvalidOperationException("Add or import chapters before analysing this book."); } var clientStatus = client.GetConfigurationStatus(); var modelSummary = StoryIntelligenceOptions.BuildModelSummary( clientStatus.ChapterStructureModel, clientStatus.SceneIntelligenceModel); var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(book.ProjectID, new Dictionary>()); var batchItems = new List(); foreach (var chapter in chapterRows) { var durableSource = await sources.GetChapterSourceAsync(chapter.ChapterID); var text = durableSource?.SourceText?.Trim(); if (string.IsNullOrWhiteSpace(text)) { continue; } var paragraphs = StoryIntelligenceParagraphs.Split(text); var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest { UserID = userId, ProjectID = book.ProjectID, BookID = book.BookID, ChapterID = chapter.ChapterID, ChapterNumber = chapter.ChapterNumber, SourceType = BookSourceType, SourceLabel = $"{book.BookDisplayTitle}: {CanonicalChapterTitle(chapter, chapter.ChapterNumber)}", ChapterText = text, SourceFileName = durableSource?.BookTitle ?? book.BookDisplayTitle, SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text), SourceCharacterCount = durableSource?.SourceCharacterCount ?? text.Length, SourceParagraphCount = paragraphs.Count, SourceChapterCount = 1, Model = modelSummary, PromptVersionsSummary = PromptVersionsSummary, KnownCharactersJson = knownCharactersJson }); batchItems.Add(new OnboardingStoryIntelligenceBatchItem { TemporaryChapterKey = $"book-{book.BookID}-chapter-{chapter.ChapterID}", ChapterNumber = Convert.ToInt32(chapter.ChapterNumber), ChapterTitle = CanonicalChapterTitle(chapter, chapter.ChapterNumber), ChapterID = chapter.ChapterID, RunID = runId }); await pipelineState.RecordChapterAnalysisQueuedAsync(book.ProjectID, book.BookID, runId); } if (batchItems.Count == 0) { throw new InvalidOperationException("PlotDirector could not find saved manuscript text for this book. Complete the core manuscript import before analysing it."); } var batch = new OnboardingStoryIntelligenceBatch { UserID = userId, OnboardingID = 0, PreviewID = Guid.Empty, ProjectID = book.ProjectID, BookID = book.BookID, ProjectName = string.Empty, BookTitle = book.BookDisplayTitle, StoryEra = CleanContext(BookContextOptions.StoryEraLabel(book.StoryEra)), StoryLocation = CleanContext(BookContextOptions.PrimaryLocaleLabel(book.PrimaryLocaleCode)), Items = batchItems }; await batchStore.SaveAsync(batch); logger.LogInformation( "Queued book Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}", batch.BatchID, userId, book.ProjectID, book.BookID, batchItems.Count); return batch; } private static string NormaliseAppearancePreference(string? value) => value?.Trim() switch { StoryMemoryAppearancePreferences.ExplicitEvidenceOnly => StoryMemoryAppearancePreferences.ExplicitEvidenceOnly, StoryMemoryAppearancePreferences.Balanced => StoryMemoryAppearancePreferences.Balanced, StoryMemoryAppearancePreferences.PredominantlyLight => StoryMemoryAppearancePreferences.PredominantlyLight, StoryMemoryAppearancePreferences.PredominantlyMedium => StoryMemoryAppearancePreferences.PredominantlyMedium, StoryMemoryAppearancePreferences.PredominantlyDark => StoryMemoryAppearancePreferences.PredominantlyDark, StoryMemoryAppearancePreferences.CustomMix => StoryMemoryAppearancePreferences.CustomMix, _ => StoryMemoryAppearancePreferences.Balanced }; private static string CleanContext(string? value) { var clean = value?.Trim(); return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)]; } private async Task BuildCanonicalKnownCharacterContextJsonAsync( ManuscriptScanPreview preview, ManuscriptScanReviewDecision review, OnboardingManuscriptBuildResult build) { if (build.SceneMappings.Count == 0) { return BuildKnownCharacterContextJson(preview, review); } var allCharacters = (await characters.ListCharactersAsync(preview.ProjectID)) .Where(character => !character.IsArchived) .OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase) .ToList(); if (allCharacters.Count == 0) { return BuildKnownCharacterContextJson(preview, review); } var sceneIds = build.SceneMappings.Select(mapping => mapping.SceneID).Distinct().ToList(); var sceneIdsByCharacter = new Dictionary>(); foreach (var sceneId in sceneIds) { foreach (var sceneCharacter in await characters.ListSceneCharactersAsync(sceneId)) { if (!sceneIdsByCharacter.TryGetValue(sceneCharacter.CharacterID, out var mappedScenes)) { mappedScenes = []; sceneIdsByCharacter[sceneCharacter.CharacterID] = mappedScenes; } mappedScenes.Add(sceneId); } } var context = new List(); foreach (var character in allCharacters) { var aliases = (await characters.ListAliasesAsync(character.CharacterID)) .Select(alias => alias.Alias) .Where(alias => !string.IsNullOrWhiteSpace(alias)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase) .ToList(); var mappedScenes = sceneIdsByCharacter.GetValueOrDefault(character.CharacterID)?.OrderBy(id => id).ToList() ?? []; context.Add(new StoryIntelligenceKnownCharacterContext { CanonicalName = character.CharacterName, Aliases = aliases, SexHint = CharacterSexHintService.Normalise(character.Sex, character.CharacterName), ExistingCharacterID = character.CharacterID, CandidateKeys = [], SceneIDs = mappedScenes, MentionCount = mappedScenes.Count, Confidence = mappedScenes.Count > 0 ? 1m : 0.8m, Source = "CanonicalCoreImport" }); } return JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } private async Task BuildCanonicalKnownCharacterContextJsonAsync(int projectId, IReadOnlyDictionary> sceneIdsByCharacter) { var allCharacters = (await characters.ListCharactersAsync(projectId)) .Where(character => !character.IsArchived) .OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase) .ToList(); if (allCharacters.Count == 0) { return null; } var context = new List(); foreach (var character in allCharacters) { var aliases = (await characters.ListAliasesAsync(character.CharacterID)) .Select(alias => alias.Alias) .Where(alias => !string.IsNullOrWhiteSpace(alias)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase) .ToList(); var mappedScenes = sceneIdsByCharacter.GetValueOrDefault(character.CharacterID)?.OrderBy(id => id).ToList() ?? []; context.Add(new StoryIntelligenceKnownCharacterContext { CanonicalName = character.CharacterName, Aliases = aliases, SexHint = CharacterSexHintService.Normalise(character.Sex, character.CharacterName), ExistingCharacterID = character.CharacterID, CandidateKeys = [], SceneIDs = mappedScenes, MentionCount = mappedScenes.Count, Confidence = mappedScenes.Count > 0 ? 1m : 0.8m, Source = "CanonicalBook" }); } return JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review) { var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal); var decisions = review.Characters .Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)) .ToDictionary(character => character.TemporaryCharacterKey, StringComparer.Ordinal); var roots = decisions.Values .Where(character => string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)) .OrderBy(character => character.Name) .ThenBy(character => character.TemporaryCharacterKey) .ToList(); var context = new List(); foreach (var root in roots) { var aliasDecisions = decisions.Values .Where(character => string.Equals(character.AliasTargetTemporaryCharacterKey, root.TemporaryCharacterKey, StringComparison.Ordinal)) .OrderBy(character => character.Name) .ToList(); var names = new List(); AddName(names, root.Name); if (candidates.TryGetValue(root.TemporaryCharacterKey, out var rootCandidate)) { AddName(names, rootCandidate.Name); } foreach (var alias in aliasDecisions) { AddName(names, alias.Name); if (candidates.TryGetValue(alias.TemporaryCharacterKey, out var aliasCandidate)) { AddName(names, aliasCandidate.Name); } } var aliases = names .Where(name => !string.Equals(name, root.Name, StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name) .ToList(); var allKeys = new[] { root.TemporaryCharacterKey } .Concat(aliasDecisions.Select(alias => alias.TemporaryCharacterKey)) .ToList(); var mentionCount = allKeys .Select(key => candidates.TryGetValue(key, out var candidate) ? candidate.MentionCount : 0) .Sum(); var confidence = allKeys .Select(key => candidates.TryGetValue(key, out var candidate) ? Math.Clamp(candidate.QualityScore / 100m, 0.1m, 1m) : 0.5m) .DefaultIfEmpty(0.5m) .Max(); var sexHint = CharacterSexHintService.Normalise(root.SexHint, root.Name); if (string.Equals(sexHint, CharacterSexHintService.Unknown, StringComparison.Ordinal)) { sexHint = aliasDecisions .Select(alias => CharacterSexHintService.Normalise(alias.SexHint, alias.Name)) .FirstOrDefault(value => !string.Equals(value, CharacterSexHintService.Unknown, StringComparison.Ordinal)) ?? CharacterSexHintService.Unknown; } context.Add(new StoryIntelligenceKnownCharacterContext { CanonicalName = root.Name, Aliases = aliases, SexHint = sexHint, ExistingCharacterID = root.ExistingCharacterID, CandidateKeys = allKeys, MentionCount = mentionCount, Confidence = confidence }); } return context.Count == 0 ? null : JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } private static void AddName(List names, string? value) { var clean = CleanContext(value); if (!string.IsNullOrWhiteSpace(clean) && !names.Contains(clean, StringComparer.OrdinalIgnoreCase)) { names.Add(clean); } } public async Task GetProgressAsync(Guid batchId) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return null; } var chapters = new List(); foreach (var item in batch.Items) { var run = await runs.GetRunAsync(item.RunID); if (run is null) { chapters.Add(new StoryIntelligenceOnboardingChapterViewModel { TemporaryChapterKey = item.TemporaryChapterKey, ChapterNumber = item.ChapterNumber, ChapterTitle = item.ChapterTitle, ChapterID = item.ChapterID, RunID = item.RunID, Status = "Missing", ErrorMessage = "This analysis run could not be found." }); continue; } var commit = await runs.GetImportCommitAsync(item.RunID); var confirmation = run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings ? await commits.BuildConfirmationAsync(item.RunID) : null; var sceneResults = await runs.ListSceneResultsAsync(item.RunID); var canonicalScenes = item.ChapterID > 0 ? await runs.ListChapterScenesAsync(item.ChapterID) : []; var currentScene = CurrentCanonicalScene(run, canonicalScenes); var currentSceneNumber = currentScene is null ? CurrentSceneNumber(run) : Convert.ToInt32(currentScene.SceneNumber); var currentSceneDisplayTitle = currentScene is null ? (currentSceneNumber.HasValue ? SceneTitleDisplay.Format(currentSceneNumber.Value, null) : null) : SceneTitleDisplay.Format(currentScene.SceneNumber, currentScene.SceneTitle); var canonicalSceneSummary = CanonicalSceneSummary(currentScene); chapters.Add(new StoryIntelligenceOnboardingChapterViewModel { TemporaryChapterKey = item.TemporaryChapterKey, ChapterNumber = item.ChapterNumber, ChapterTitle = item.ChapterTitle, ChapterID = item.ChapterID, RunID = item.RunID, Status = run.Status, CurrentStage = run.CurrentStage, CurrentMessage = run.CurrentMessage, TotalDetectedScenes = run.TotalDetectedScenes, CompletedScenes = run.CompletedScenes, FailedScenes = run.FailedScenes, SourceWordCount = run.SourceWordCount, TotalTokens = run.TotalTokens, TotalDurationMs = run.TotalDurationMs, UpdatedUtc = run.UpdatedUtc, EstimatedCostUSD = run.EstimatedCostUSD, EstimatedCostGBP = run.EstimatedCostGBP, FailureStage = run.FailureStage, ErrorMessage = run.ErrorMessage, CurrentSceneID = currentScene?.SceneID, CurrentSceneNumber = currentSceneNumber, CurrentSceneDisplayTitle = currentSceneDisplayTitle, LatestSceneSummary = currentScene is null ? LatestSceneSummary(sceneResults) : canonicalSceneSummary, HasCompletedCommit = commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase), CanCommit = confirmation?.CanCommit == true, ScenesCreated = commit?.ScenesCreated ?? 0, CommitMessage = commit?.Notes, Blockers = confirmation?.Blockers ?? [], Warnings = confirmation?.Warnings ?? [], Scenes = confirmation is null ? [] : BuildScenePreviews(confirmation.Scenes) }); } var characterReview = await reviewCandidates.GetCharacterReviewAsync(batch, 1, 25); var locationReview = await reviewCandidates.GetLocationReviewAsync(batch, 1, 25); var assetReview = await reviewCandidates.GetAssetReviewAsync(batch, 1, 25); var relationshipReview = await reviewCandidates.GetRelationshipReviewAsync(batch, 1, 25); var knowledgeReview = await reviewCandidates.GetKnowledgeReviewAsync(batch, 1, 25); var narrative = await narrativeReview.GetReviewAsync(batch); var plotSynthesisRun = await wholeBookPlotIntelligence.GetCurrentAsync(batch.BookID); var characterEnrichmentRun = await characterEnrichment.GetCurrentAsync(batch.BookID); var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); var characterReviewComplete = pipeline?.CharacterReviewCompletedUtc is not null || characterReview.IsComplete; var characterComplete = characterReviewComplete && characterEnrichmentRun?.Status == CharacterEnrichmentStatuses.Completed; var locationComplete = pipeline?.LocationReviewCompletedUtc is not null || locationReview.IsComplete; var assetComplete = pipeline?.AssetReviewCompletedUtc is not null || assetReview.IsComplete; var relationshipComplete = pipeline?.RelationshipReviewCompletedUtc is not null || relationshipReview.IsComplete; var knowledgeComplete = pipeline?.KnowledgeReviewCompletedUtc is not null || knowledgeReview.IsComplete; var narrativeComplete = batch.NarrativeStageComplete || pipeline?.NarrativeReviewCompletedUtc is not null || narrative.IsComplete; var narrativePendingCount = narrativeComplete ? 0 : narrative.Candidates.Count; var narrativeThreadCount = narrative.Candidates.Count(candidate => string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase)); var narrativeEventCount = narrative.Candidates.Sum(candidate => candidate.Events.Count); return new StoryIntelligenceProgressViewModel { BatchID = batch.BatchID, PreviewID = batch.PreviewID, ImportSessionID = pipeline?.StoryIntelligenceBookPipelineID, ProjectID = batch.ProjectID, BookID = batch.BookID, ProjectName = batch.ProjectName, BookTitle = batch.BookTitle, Chapters = chapters, CharacterReview = characterReview, LocationReview = locationReview, AssetReview = assetReview, RelationshipReview = relationshipReview, KnowledgeReview = knowledgeReview, NarrativeReview = narrative, PlotSynthesis = plotSynthesisRun is null ? new WholeBookPlotSynthesisStatusViewModel { Status = string.Empty, CandidateCount = narrativePendingCount, ThreadCount = narrativeThreadCount, EventCount = narrativeEventCount } : new WholeBookPlotSynthesisStatusViewModel { WholeBookPlotSynthesisRunID = plotSynthesisRun.WholeBookPlotSynthesisRunID, Status = plotSynthesisRun.Status, CandidateCount = narrativePendingCount, ThreadCount = narrativeThreadCount, EventCount = narrativeEventCount, ErrorMessage = plotSynthesisRun.ErrorMessage }, CharacterEnrichment = characterEnrichmentRun is null ? new CharacterEnrichmentStatusViewModel() : new CharacterEnrichmentStatusViewModel { CharacterEnrichmentRunID = characterEnrichmentRun.CharacterEnrichmentRunID, Status = characterEnrichmentRun.Status, TotalCharacters = characterEnrichmentRun.TotalCharacters, ProcessedCharacters = characterEnrichmentRun.ProcessedCharacters, CurrentStage = characterEnrichmentRun.CurrentStage ?? string.Empty, CurrentMessage = characterEnrichmentRun.CurrentMessage ?? string.Empty, ErrorMessage = characterEnrichmentRun.ErrorMessage }, PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel { ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), ScenesImported = Math.Max(chapters.Sum(chapter => chapter.ScenesCreated), chapters.Sum(chapter => chapter.CompletedScenes ?? 0)), SceneAnalysisComplete = pipeline is not null && SceneAnalysisComplete(pipeline), CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count, CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked), CharacterStageComplete = characterReviewComplete, LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count, LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked), LocationStageComplete = locationComplete, AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count, AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked), AssetStageComplete = assetComplete, RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count, RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked), RelationshipStageComplete = relationshipComplete, KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count, KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked), KnowledgeStageComplete = knowledgeComplete, NarrativeCandidatesIdentified = narrative.Candidates.Count + (batch.LastNarrativeImportResult?.TotalReviewed ?? 0), NarrativeCandidatesApplied = (batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0) + (batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0) + (batch.LastNarrativeImportResult?.ThreadsCreated ?? 0), NarrativeStageComplete = narrativeComplete } }; } 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, int pageNumber = 1, int pageSize = 25) => GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize)); public Task GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25) => GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)); public Task GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25) => GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await reviewCandidates.GetAssetReviewAsync(frame.Batch, pageNumber, pageSize)); public Task GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25) => GetSelectedReviewAsync(batchId, "Relationships", async frame => frame.Model.RelationshipReview = await reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize)); public Task GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25) => GetSelectedReviewAsync(batchId, "Knowledge", async frame => frame.Model.KnowledgeReview = await reviewCandidates.GetKnowledgeReviewAsync(frame.Batch, pageNumber, pageSize)); 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) { return null; } var summaryStopwatch = Stopwatch.StartNew(); var summary = await pipelineState.GetReviewCentreSummaryAsync(batch.BookID, batch.UserID); summaryStopwatch.Stop(); if (summary is null) { return null; } var characterComplete = summary.CharacterReviewCompletedUtc is not null; var locationComplete = summary.LocationReviewCompletedUtc is not null; var assetComplete = summary.AssetReviewCompletedUtc is not null; var relationshipComplete = summary.RelationshipReviewCompletedUtc is not null; var knowledgeComplete = summary.KnowledgeReviewCompletedUtc is not null; var narrativeComplete = summary.NarrativeReviewCompletedUtc is not null; var sceneAnalysisComplete = SceneAnalysisComplete(summary); var narrativePendingCount = narrativeComplete ? 0 : summary.NarrativePendingCount; var hasCommittedScenes = summary.CompletedRunCount > 0 || summary.CompletedScenes > 0; var characterEnrichmentRun = await characterEnrichment.GetCurrentAsync(batch.BookID); var characterEnrichmentComplete = characterEnrichmentRun?.Status == CharacterEnrichmentStatuses.Completed; var characterPanelComplete = characterComplete && characterEnrichmentComplete; var model = new StoryIntelligenceProgressViewModel { BatchID = batch.BatchID, PreviewID = batch.PreviewID, ImportSessionID = summary.StoryIntelligenceBookPipelineID, ProjectID = summary.ProjectID, BookID = summary.BookID, ProjectName = summary.ProjectName, BookTitle = summary.BookDisplayTitle, MetricValueCount = summary.MetricValueCount, CharacterReview = new StoryIntelligenceCharacterReviewViewModel { HasCommittedScenes = hasCommittedScenes, CanImport = !characterComplete, IsComplete = characterPanelComplete, SummaryNeedsAttentionCount = characterComplete ? 0 : ReadyPendingCount(summary.CharacterCandidateGenerationStatus, summary.CharacterPendingCount) }, LocationReview = new StoryIntelligenceLocationReviewViewModel { HasCommittedScenes = hasCommittedScenes, CanImport = !locationComplete, IsComplete = locationComplete, SummaryNeedsAttentionCount = locationComplete ? 0 : ReadyPendingCount(summary.LocationCandidateGenerationStatus, summary.LocationPendingCount) }, AssetReview = new StoryIntelligenceAssetReviewViewModel { HasCommittedScenes = hasCommittedScenes, CanImport = !assetComplete, IsComplete = assetComplete, SummaryNeedsAttentionCount = assetComplete ? 0 : ReadyPendingCount(summary.AssetCandidateGenerationStatus, summary.AssetPendingCount) }, RelationshipReview = new StoryIntelligenceRelationshipReviewViewModel { HasCommittedScenes = hasCommittedScenes, CanImport = !relationshipComplete, IsComplete = relationshipComplete, SummaryNeedsAttentionCount = relationshipComplete ? 0 : ReadyPendingCount(summary.RelationshipCandidateGenerationStatus, summary.RelationshipPendingCount) }, KnowledgeReview = new StoryIntelligenceKnowledgeReviewViewModel { HasCommittedScenes = hasCommittedScenes, CanImport = !knowledgeComplete, IsComplete = knowledgeComplete, SummaryNeedsAttentionCount = knowledgeComplete ? 0 : ReadyPendingCount(summary.KnowledgeCandidateGenerationStatus, summary.KnowledgePendingCount) }, NarrativeReview = new StoryIntelligenceNarrativeReviewViewModel { HasCommittedScenes = hasCommittedScenes, CanImport = narrativePendingCount > 0, IsComplete = narrativeComplete, SummaryNeedsAttentionCount = narrativePendingCount }, PlotSynthesis = new WholeBookPlotSynthesisStatusViewModel { WholeBookPlotSynthesisRunID = summary.WholeBookPlotSynthesisRunID == 0 ? null : summary.WholeBookPlotSynthesisRunID, Status = summary.WholeBookPlotSynthesisStatus ?? string.Empty, CandidateCount = summary.NarrativeCandidateCount, ThreadCount = summary.NarrativeThreadCount, EventCount = summary.NarrativeEventCount, ErrorMessage = summary.WholeBookPlotSynthesisErrorMessage }, CharacterEnrichment = characterEnrichmentRun is null ? new CharacterEnrichmentStatusViewModel() : new CharacterEnrichmentStatusViewModel { CharacterEnrichmentRunID = characterEnrichmentRun.CharacterEnrichmentRunID, Status = characterEnrichmentRun.Status, TotalCharacters = characterEnrichmentRun.TotalCharacters, ProcessedCharacters = characterEnrichmentRun.ProcessedCharacters, CurrentStage = characterEnrichmentRun.CurrentStage ?? string.Empty, CurrentMessage = characterEnrichmentRun.CurrentMessage ?? string.Empty, ErrorMessage = characterEnrichmentRun.ErrorMessage }, PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel { ChaptersAnalysed = summary.CompletedRunCount, ScenesImported = summary.CompletedScenes, SceneAnalysisComplete = sceneAnalysisComplete, CharacterStageComplete = characterComplete, LocationStageComplete = locationComplete, AssetStageComplete = assetComplete, RelationshipStageComplete = relationshipComplete, KnowledgeStageComplete = knowledgeComplete, NarrativeCandidatesIdentified = summary.NarrativeCandidateCount, NarrativeStageComplete = narrativeComplete } }; return new StoryIntelligenceReviewFrame(batch, summary, model, summaryStopwatch.ElapsedMilliseconds); } private static int? ReadyPendingCount(string? generationStatus, int? pendingCount) => string.Equals(generationStatus, StoryIntelligenceReviewCandidateGenerationStatuses.Ready, StringComparison.OrdinalIgnoreCase) ? pendingCount ?? 0 : null; private sealed record StoryIntelligenceReviewFrame( OnboardingStoryIntelligenceBatch Batch, StoryIntelligenceReviewCentreSummary Summary, StoryIntelligenceProgressViewModel Model, long SummaryElapsedMs); public async Task CancelAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); if (progress is null) { return null; } foreach (var chapter in progress.Chapters.Where(chapter => chapter.IsActive)) { await runs.RequestCancelAsync(chapter.RunID); } return await GetProgressAsync(batchId); } public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId) { var progress = await GetProgressAsync(batchId); if (progress is null || progress.Chapters.All(chapter => chapter.RunID != runId)) { return (progress, new StoryIntelligenceImportCommitResult { Success = false, Message = "This chapter analysis could not be found." }); } var result = await commits.CommitAsync(runId, RequireUserId()); return (await GetReviewCentreAsync(batchId), result); } public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); if (progress is null) { return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return (progress, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } var userId = RequireUserId(); var created = 0; foreach (var chapter in progress.Chapters.OrderBy(chapter => chapter.ChapterNumber).Where(chapter => chapter.CanCommit)) { var result = await commits.CommitAsync(chapter.RunID, userId); if (!result.Success) { return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult { Success = false, Message = $"Scene creation stopped. Chapter {chapter.ChapterNumber:N0} could not be imported. Reason: {result.Message}", ScenesCreated = created }); } created += result.ScenesCreated; } await onboarding.MarkCompletedAsync(); await reviewCandidates.PrepareCharacterCandidatesAsync(batch); var pendingCharacterCandidates = await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters); if (pendingCharacterCandidates == 0) { await QueuePostCharacterReviewAnalysisAsync(batch); await reviewCandidates.PreparePeerCandidatesAsync(batch); } return (await GetProgressAsync(batchId), new StoryIntelligenceImportCommitResult { Success = true, ScenesCreated = created, Message = $"Scenes successfully created. {created:N0} scene(s) added to PlotDirector." }); } public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } var result = await characterImport.ImportAsync(batch, form, false); if (result.Success) { var pendingCount = await reviewCandidates.MarkCharacterDecisionsAsync(batch.BookID, form.Characters); if (pendingCount == 0) { await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Characters); await QueuePostCharacterReviewAnalysisAsync(batch); await reviewCandidates.PreparePeerCandidatesAsync(batch); } else { result = WithRemainingMessage(result, pendingCount, "character"); } } return (await GetReviewCentreAsync(batchId), result); } public async Task GetCharacterImportResultAsync(Guid batchId) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return null; } return new StoryIntelligenceCharacterImportResultViewModel { BatchID = batch.BatchID, ProjectID = batch.ProjectID, BookID = batch.BookID, CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0, CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0, CharactersAliased = batch.LastCharacterImportResult?.CharactersAliased ?? 0, CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0, PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0, ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0 }; } private async Task QueuePostCharacterReviewAnalysisAsync(OnboardingStoryIntelligenceBatch batch) { // Keep both post-character-review jobs independent; previous direct call was await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync. await Task.WhenAll( wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID), characterEnrichment.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID)); logger.LogInformation( "Queued post-character-review Story Intelligence jobs. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID}", batch.BatchID, batch.ProjectID, batch.BookID); } public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } var result = await locationImport.ImportAsync(batch, form, false); if (result.Success) { var pendingCount = await reviewCandidates.MarkLocationDecisionsAsync(batch.BookID, form.Locations); if (pendingCount == 0) { await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Locations); } else { result = WithRemainingMessage(result, pendingCount, "location"); } } return (await GetReviewCentreAsync(batchId), result); } public async Task GetLocationImportResultAsync(Guid batchId) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return null; } return new StoryIntelligenceLocationImportResultViewModel { BatchID = batch.BatchID, ProjectID = batch.ProjectID, BookID = batch.BookID, LocationsCreated = batch.LastLocationImportResult?.LocationsCreated ?? 0, LocationsLinked = batch.LastLocationImportResult?.LocationsLinked ?? 0, LocationAliasesAdded = batch.LastLocationImportResult?.LocationAliasesAdded ?? 0, LocationsIgnored = batch.LastLocationImportResult?.LocationsIgnored ?? 0, SceneLocationsUpdated = batch.LastLocationImportResult?.SceneLocationsUpdated ?? 0 }; } public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } var result = await assetImport.ImportAsync(batch, form, false); if (result.Success) { var pendingCount = await reviewCandidates.MarkAssetDecisionsAsync(batch.BookID, form.Assets); if (pendingCount == 0) { await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Assets); } else { result = WithRemainingMessage(result, pendingCount, "asset"); } } return (await GetReviewCentreAsync(batchId), result); } public async Task GetAssetImportResultAsync(Guid batchId) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return null; } return new StoryIntelligenceAssetImportResultViewModel { BatchID = batch.BatchID, ProjectID = batch.ProjectID, BookID = batch.BookID, AssetsCreated = batch.LastAssetImportResult?.AssetsCreated ?? 0, AssetsLinked = batch.LastAssetImportResult?.AssetsLinked ?? 0, AssetAliasesAdded = batch.LastAssetImportResult?.AssetAliasesAdded ?? 0, AssetsIgnored = batch.LastAssetImportResult?.AssetsIgnored ?? 0, SceneAssetEventsCreated = batch.LastAssetImportResult?.SceneAssetEventsCreated ?? 0, OwnershipLinksCreated = batch.LastAssetImportResult?.OwnershipLinksCreated ?? 0 }; } public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } var result = await relationshipImport.ImportAsync(batch, form, false); if (result.Success) { var pendingCount = await reviewCandidates.MarkRelationshipDecisionsAsync(batch.BookID, form.Relationships); if (pendingCount == 0) { await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Relationships); } else { result = WithRemainingMessage(result, pendingCount, "relationship"); } } return (await GetReviewCentreAsync(batchId), result); } public async Task GetRelationshipImportResultAsync(Guid batchId) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return null; } return new StoryIntelligenceRelationshipImportResultViewModel { BatchID = batch.BatchID, ProjectID = batch.ProjectID, BookID = batch.BookID, RelationshipsCreated = batch.LastRelationshipImportResult?.RelationshipsCreated ?? 0, RelationshipsLinked = batch.LastRelationshipImportResult?.RelationshipsLinked ?? 0, RelationshipsMerged = batch.LastRelationshipImportResult?.RelationshipsMerged ?? 0, RelationshipsIgnored = batch.LastRelationshipImportResult?.RelationshipsIgnored ?? 0, RelationshipEventsCreated = batch.LastRelationshipImportResult?.RelationshipEventsCreated ?? 0 }; } public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } var result = await knowledgeImport.ImportAsync(batch, form, false); if (result.Success) { var pendingCount = await reviewCandidates.MarkKnowledgeDecisionsAsync(batch.BookID, form.Knowledge); if (pendingCount == 0) { await reviewCandidates.CompleteModuleIfReadyAsync(batch, StoryIntelligenceReviewModules.Knowledge); } else { result = WithRemainingMessage(result, pendingCount, "knowledge"); } } return (await GetReviewCentreAsync(batchId), result); } public async Task GetKnowledgeImportResultAsync(Guid batchId) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return null; } return new StoryIntelligenceKnowledgeImportResultViewModel { BatchID = batch.BatchID, ProjectID = batch.ProjectID, BookID = batch.BookID, KnowledgeCreated = batch.LastKnowledgeImportResult?.KnowledgeCreated ?? 0, KnowledgeLinked = batch.LastKnowledgeImportResult?.KnowledgeLinked ?? 0, KnowledgeMerged = batch.LastKnowledgeImportResult?.KnowledgeMerged ?? 0, KnowledgeIgnored = batch.LastKnowledgeImportResult?.KnowledgeIgnored ?? 0 }; } private static StoryIntelligenceImportCommitResult WithRemainingMessage( StoryIntelligenceImportCommitResult result, int pendingCount, string candidateLabel) => new() { Success = result.Success, CommitID = result.CommitID, ScenesCreated = result.ScenesCreated, MetricsCreated = result.MetricsCreated, Message = $"{result.Message} {pendingCount:N0} {candidateLabel} candidate(s) remain." }; public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); if (batch is null) { return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." }); } var result = await narrativeReview.ImportAsync(batch, form, RequireUserId()); await batchStore.SaveAsync(batch); return (await GetReviewCentreAsync(batchId), result); } public async Task GetNarrativeImportResultAsync(Guid batchId) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); return batch is null ? null : await narrativeReview.GetResultAsync(batch); } public async Task GetCompletionAsync(Guid batchId) { var progress = await GetReviewCentreAsync(batchId); if (progress is null) { return null; } return new StoryIntelligenceCompletionViewModel { BatchID = progress.BatchID, ProjectID = progress.ProjectID, BookID = progress.BookID, FirstChapterID = progress.Chapters.OrderBy(chapter => chapter.ChapterNumber).FirstOrDefault()?.ChapterID, ProjectName = progress.ProjectName, BookTitle = progress.BookTitle, ChaptersCommitted = progress.CommittedChapterCount, ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated), CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked, CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete, LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked, LocationStageComplete = progress.PipelineDashboard.LocationStageComplete, AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked, AssetStageComplete = progress.PipelineDashboard.AssetStageComplete, RelationshipsCreatedOrLinked = progress.PipelineDashboard.RelationshipsCreatedOrLinked, RelationshipStageComplete = progress.PipelineDashboard.RelationshipStageComplete }; } public async Task ResumeBookAsync(int bookId) { var userId = RequireUserId(); var state = await pipelineState.GetForBookAsync(bookId, userId); if (state is null) { return null; } var chapterLookup = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID); var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId)) .OrderBy(run => run.ChapterNumber) .ThenBy(run => run.StoryIntelligenceRunID) .Select(run => new OnboardingStoryIntelligenceBatchItem { TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}", ChapterNumber = Convert.ToInt32(run.ChapterNumber), ChapterTitle = CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID), run.ChapterNumber), ChapterID = run.ChapterID, RunID = run.StoryIntelligenceRunID }) .ToList(); var activeRuns = committedRuns.Count > 0 ? [] : (await runs.ListRunsByBookForUserAsync(bookId, userId)) .Where(run => run.BookID == state.BookID) .OrderBy(run => run.ChapterNumber ?? decimal.MaxValue) .ThenBy(run => run.StoryIntelligenceRunID) .Select(run => new OnboardingStoryIntelligenceBatchItem { TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}", ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0), ChapterTitle = CanonicalChapterTitle(run.ChapterID.HasValue ? chapterLookup.GetValueOrDefault(run.ChapterID.Value) : null, run.ChapterNumber), ChapterID = run.ChapterID ?? 0, RunID = run.StoryIntelligenceRunID }) .ToList(); var batchItems = committedRuns.Count > 0 ? committedRuns : activeRuns; if (batchItems.Count == 0) { return null; } var batch = new OnboardingStoryIntelligenceBatch { UserID = userId, OnboardingID = 0, PreviewID = Guid.Empty, ProjectID = state.ProjectID, BookID = state.BookID, ProjectName = state.ProjectName, BookTitle = state.BookDisplayTitle, Items = batchItems }; batch.CharacterStageComplete = state.CharacterReviewCompletedUtc is not null; batch.LocationStageComplete = state.LocationReviewCompletedUtc is not null; batch.AssetStageComplete = state.AssetReviewCompletedUtc is not null; batch.RelationshipStageComplete = state.RelationshipReviewCompletedUtc is not null; batch.KnowledgeStageComplete = state.KnowledgeReviewCompletedUtc is not null; batch.NarrativeStageComplete = state.NarrativeReviewCompletedUtc is not null; await batchStore.SaveAsync(batch); var route = SceneAnalysisComplete(state) ? StoryIntelligenceResumeRoutes.ReviewCentre : StoryIntelligenceResumeRoutes.SceneReview; return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route); } private static bool HasCompletedStage(StoryIntelligenceBookPipelineState state, string stage) => string.Equals(state.LastCompletedStage, stage, StringComparison.OrdinalIgnoreCase); private static bool SceneAnalysisComplete(StoryIntelligenceBookPipelineState state) => HasCompletedStage(state, StoryIntelligencePipelineStages.SceneImport) || state.CurrentStage is StoryIntelligencePipelineStages.CharacterReview or StoryIntelligencePipelineStages.CharacterImport or StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport or StoryIntelligencePipelineStages.NarrativeReview or StoryIntelligencePipelineStages.NarrativeImport or StoryIntelligencePipelineStages.Complete; private static bool SceneAnalysisComplete(StoryIntelligenceReviewCentreSummary state) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.SceneImport, StringComparison.OrdinalIgnoreCase) || state.CurrentStage is StoryIntelligencePipelineStages.CharacterReview or StoryIntelligencePipelineStages.CharacterImport or StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport or StoryIntelligencePipelineStages.NarrativeReview or StoryIntelligencePipelineStages.NarrativeImport or StoryIntelligencePipelineStages.Complete; private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId) { var state = await onboardingRepository.GetAsync(userId); if (state is null || !state.ProjectID.HasValue || !state.BookID.HasValue) { return null; } var preview = await scanStore.GetLatestPreviewAsync(userId, state.UserOnboardingStateID); if (preview is null) { return null; } var review = await scanStore.GetReviewAsync(userId, preview.PreviewID); if (review is null || !string.Equals(review.Status, ManuscriptScanReviewStatuses.CoreImportComplete, StringComparison.Ordinal)) { return null; } var build = await scanStore.GetBuildResultAsync(userId, preview.PreviewID); var wizard = await onboarding.GetWizardAsync(); return (state, preview, review, build, wizard); } private int RequireUserId() => currentUser.UserId ?? throw new InvalidOperationException("Sign in before using Story Intelligence."); private static string CanonicalChapterTitle(Chapter? chapter, decimal? chapterNumber) { var number = chapterNumber ?? chapter?.ChapterNumber; var fallback = number.HasValue && number.Value > 0 ? $"Chapter {number.Value:0.##}" : "Chapter"; var title = CleanContext(chapter?.ChapterTitle).TrimStart('-', ' ', '\t'); if (string.IsNullOrWhiteSpace(title)) { return fallback; } return title.StartsWith("Chapter ", StringComparison.OrdinalIgnoreCase) ? title : $"{fallback} - {title}"; } private static IReadOnlyList IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review) { var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal); return review.Chapters .Where(chapter => chapter.Include && chapters.ContainsKey(chapter.TemporaryChapterKey)) .OrderBy(chapter => chapter.ChapterNumber) .Select(chapter => new IncludedChapter(chapter, chapters[chapter.TemporaryChapterKey])) .ToList(); } private static int CountWords(string value) => string.IsNullOrWhiteSpace(value) ? 0 : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; private static Scene? CurrentCanonicalScene(StoryIntelligenceSavedRun run, IReadOnlyList scenes) { if (scenes.Count == 0) { return null; } var orderedScenes = scenes.OrderBy(scene => scene.SceneNumber).ToList(); var sceneNumber = CurrentSceneNumber(run); if (sceneNumber is not > 0) { return orderedScenes[0]; } var byNumber = orderedScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == sceneNumber.Value); if (byNumber is not null) { return byNumber; } return orderedScenes[Math.Clamp(sceneNumber.Value - 1, 0, orderedScenes.Count - 1)]; } private static int? CurrentSceneNumber(StoryIntelligenceSavedRun run) { var completed = Math.Max(0, run.CompletedScenes ?? 0); var total = Math.Max(0, run.TotalDetectedScenes ?? 0); if (run.Status == StoryIntelligenceRunStatuses.Running && string.Equals(run.CurrentStage, StoryIntelligenceFailureStages.SceneIntelligence, StringComparison.OrdinalIgnoreCase) && total > 0) { return Math.Clamp(completed + 1, 1, total); } if (completed > 0) { return Math.Clamp(completed, 1, total > 0 ? total : completed); } return run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running && total > 0 ? 1 : null; } private static string? CanonicalSceneSummary(Scene? scene) => FirstConfigured(scene?.StructuralSummary, scene?.Summary); private static string? LatestSceneSummary(IReadOnlyList sceneResults) { foreach (var scene in sceneResults .Where(scene => scene.ValidationErrorsCount == 0 && !string.IsNullOrWhiteSpace(scene.ParsedJson)) .OrderByDescending(scene => scene.TemporarySceneNumber)) { var summary = ExtractShortSummary(scene.ParsedJson); if (!string.IsNullOrWhiteSpace(summary)) { return Trim(summary, 220); } } return null; } private static IReadOnlyList BuildScenePreviews(IReadOnlyList importScenes) => importScenes .OrderBy(scene => scene.TemporarySceneNumber) .Select(scene => new StoryIntelligenceOnboardingScenePreviewViewModel { SceneID = scene.SceneID, SceneNumber = scene.TemporarySceneNumber, Summary = Trim(FirstConfigured(scene.Summary, scene.ParsedScene.Summary?.Short, scene.ParsedScene.Summary?.Detailed, $"Scene {scene.TemporarySceneNumber:N0}")!, 180), Pov = scene.PovCharacterID.HasValue ? FirstConfigured(scene.ParsedScene.PointOfView?.CharacterName, scene.ParsedScene.PointOfView?.NarrativeMode) : null, Setting = BuildSetting(scene.ParsedScene.Setting), Confidence = scene.ParsedScene.Setting?.Confidence ?? scene.ParsedScene.PointOfView?.Confidence, HasWarnings = scene.HasWarnings, AnalysisStatus = scene.AnalysisStatus }) .ToList(); private static SceneIntelligenceScene? TryReadScene(string json) { if (string.IsNullOrWhiteSpace(json)) { return null; } try { var direct = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion)) { return direct; } using var document = JsonDocument.Parse(json); if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene)) { return parsedScene.Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } } catch (JsonException) { return null; } return null; } private static string? BuildSetting(SceneIntelligenceSetting? setting) => setting is null ? null : FirstConfigured( setting.LocationName, setting.GenericRoomType, setting.LocationType, setting.DateOrTimeReference, setting.TimeOfDay); private static string? FirstConfigured(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim(); private static string? ExtractShortSummary(string json) { try { using var document = JsonDocument.Parse(json); var root = document.RootElement; if (TryReadShortSummary(root, out var direct)) { return direct; } if (root.TryGetProperty("parsedScene", out var parsedScene) && TryReadShortSummary(parsedScene, out var wrapped)) { return wrapped; } } catch (JsonException) { return null; } return null; } private static bool TryReadShortSummary(JsonElement element, out string? summary) { summary = null; if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty("summary", out var summaryElement) || summaryElement.ValueKind != JsonValueKind.Object) { return false; } if (TryReadSummaryValue(summaryElement, "short", out summary)) { return !string.IsNullOrWhiteSpace(summary); } return TryReadSummaryValue(summaryElement, "detailed", out summary) && !string.IsNullOrWhiteSpace(summary); } private static bool TryReadSummaryValue(JsonElement summaryElement, string propertyName, out string? value) { value = null; if (summaryElement.TryGetProperty(propertyName, out var textElement) && textElement.ValueKind == JsonValueKind.String) { value = textElement.GetString(); return !string.IsNullOrWhiteSpace(value); } return false; } private static string Trim(string value, int maxLength) { var trimmed = value.Trim(); return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength].TrimEnd(); } private sealed record IncludedChapter(ManuscriptScanChapterReviewDecision Decision, ManuscriptScanChapterPreview Preview); } public interface IOnboardingStoryIntelligenceBatchStore { Task SaveAsync(OnboardingStoryIntelligenceBatch batch); Task GetAsync(int userId, Guid batchId); Task GetLatestForPreviewAsync(int userId, Guid previewId); Task GetLatestActiveForUserAsync(int userId); } public sealed class OnboardingStoryIntelligenceBatchStore : IOnboardingStoryIntelligenceBatchStore { private readonly ConcurrentDictionary batches = new(); public Task SaveAsync(OnboardingStoryIntelligenceBatch batch) { batches[batch.BatchID] = batch; return Task.CompletedTask; } public Task GetAsync(int userId, Guid batchId) => Task.FromResult(batches.TryGetValue(batchId, out var batch) && batch.UserID == userId ? batch : null); public Task GetLatestForPreviewAsync(int userId, Guid previewId) => Task.FromResult(batches.Values .Where(batch => batch.UserID == userId && batch.PreviewID == previewId) .OrderByDescending(batch => batch.CreatedUtc) .FirstOrDefault()); public Task GetLatestActiveForUserAsync(int userId) => Task.FromResult(batches.Values .Where(batch => batch.UserID == userId) .OrderByDescending(batch => batch.CreatedUtc) .FirstOrDefault(batch => batch.Items.Count > 0)); } public sealed class OnboardingStoryIntelligenceBatch { public Guid BatchID { get; init; } = Guid.NewGuid(); public int UserID { get; init; } public int OnboardingID { get; init; } public Guid PreviewID { get; init; } public int ProjectID { get; init; } public int BookID { get; init; } public string? ProjectName { get; init; } public string? BookTitle { get; init; } public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced; public string PrimaryAgeGroup { get; init; } = string.Empty; public string StoryEra { get; init; } = string.Empty; public string StoryLocation { get; init; } = string.Empty; public DateTime CreatedUtc { get; init; } = DateTime.UtcNow; public IReadOnlyList Items { get; init; } = []; public List CharacterDecisions { get; } = []; public List LocationDecisions { get; } = []; public List AssetDecisions { get; } = []; public List RelationshipSelections { get; } = []; public List RelationshipDecisions { get; } = []; public List KnowledgeSelections { get; } = []; public List KnowledgeDecisions { get; } = []; public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; } public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; } public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; } public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { get; set; } public StoryIntelligenceNarrativeImportBatchResult? LastNarrativeImportResult { get; set; } public bool CharacterStageComplete { get; set; } public bool LocationStageComplete { get; set; } public bool AssetStageComplete { get; set; } public bool RelationshipStageComplete { get; set; } public bool KnowledgeStageComplete { get; set; } public bool NarrativeStageComplete { get; set; } } public sealed class OnboardingStoryIntelligenceBatchItem { public string TemporaryChapterKey { get; init; } = string.Empty; public int ChapterNumber { get; init; } public string ChapterTitle { get; init; } = string.Empty; public int ChapterID { get; init; } public int RunID { get; init; } } public sealed class OnboardingStoryIntelligenceCharacterDecision { public string Key { get; init; } = string.Empty; public string Action { get; init; } = string.Empty; public string? CharacterName { get; init; } public int? CharacterID { get; init; } public bool CreatedOrLinked { get; init; } } public sealed class OnboardingStoryIntelligenceLocationDecision { public string Key { get; init; } = string.Empty; public string Action { get; init; } = string.Empty; public string? LocationName { get; init; } public int? LocationID { get; init; } public bool CreatedOrLinked { get; init; } } public sealed class OnboardingStoryIntelligenceAssetDecision { public string Key { get; init; } = string.Empty; public string Action { get; init; } = string.Empty; public string? AssetName { get; init; } public int? StoryAssetID { get; init; } public bool CreatedOrLinked { get; init; } } public sealed class OnboardingStoryIntelligenceRelationshipDecision { public string Key { get; init; } = string.Empty; public string Action { get; init; } = string.Empty; public int? CharacterRelationshipID { get; init; } public string? RelationshipLabel { get; init; } public bool CreatedOrLinked { get; init; } } public sealed class OnboardingStoryIntelligenceRelationshipSelection { public string Key { get; init; } = string.Empty; public int RelationshipTypeID { get; init; } } public sealed class StoryIntelligenceCharacterImportBatchResult { public int CharactersCreated { get; init; } public int CharactersLinked { get; init; } public int CharactersAliased { get; init; } public int CharactersIgnored { get; init; } public int PovLinksResolved { get; init; } public int ScenePeoplePanelsUpdated { get; init; } } public sealed class StoryIntelligenceLocationImportBatchResult { public int LocationsCreated { get; init; } public int LocationsLinked { get; init; } public int LocationAliasesAdded { get; init; } public int LocationsIgnored { get; init; } public int SceneLocationsUpdated { get; init; } } public sealed class StoryIntelligenceAssetImportBatchResult { public int AssetsCreated { get; init; } public int AssetsLinked { get; init; } public int AssetAliasesAdded { get; init; } public int AssetsIgnored { get; init; } public int SceneAssetEventsCreated { get; init; } public int OwnershipLinksCreated { get; init; } } public sealed class StoryIntelligenceRelationshipImportBatchResult { public int RelationshipsCreated { get; init; } public int RelationshipsLinked { get; init; } public int RelationshipsMerged { get; init; } public int RelationshipsIgnored { get; init; } public int RelationshipEventsCreated { get; init; } } public sealed class OnboardingStoryIntelligenceKnowledgeDecision { public string Key { get; init; } = string.Empty; public string Action { get; init; } = string.Empty; public int? CharacterKnowledgeID { get; init; } public string? KnowledgeLabel { get; init; } public bool CreatedOrLinked { get; init; } } public sealed class OnboardingStoryIntelligenceKnowledgeSelection { public string Key { get; init; } = string.Empty; public int KnowledgeStateID { get; init; } } public sealed class StoryIntelligenceKnowledgeImportBatchResult { public int KnowledgeCreated { get; init; } public int KnowledgeLinked { get; init; } public int KnowledgeMerged { get; init; } public int KnowledgeIgnored { get; init; } } public sealed class StoryIntelligenceNarrativeImportBatchResult { public int PlotLinesCreated { get; init; } public int PlotLinesMerged { get; init; } public int ThreadsCreated { get; init; } public int CandidatesRejected { get; init; } public int TotalReviewed => PlotLinesCreated + PlotLinesMerged + ThreadsCreated + CandidatesRejected; } public static class StoryIntelligenceResumeRoutes { public const string SceneReview = "SceneReview"; public const string ReviewCentre = "ReviewCentre"; public const string Characters = "Characters"; public const string Locations = "Locations"; public const string Assets = "Assets"; public const string Relationships = "Relationships"; public const string Knowledge = "Knowledge"; public const string Narrative = "Narrative"; public const string Complete = "Complete"; } public sealed record OnboardingStoryIntelligenceResumeTarget(Guid BatchID, string Route);