using System.Collections.Concurrent; using System.Text.Json; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IOnboardingStoryIntelligenceService { Task GetOverviewAsync(); Task StartAsync(); Task GetProgressAsync(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 GetCompletionAsync(Guid batchId); Task ResumeBookAsync(int bookId); } public sealed class OnboardingStoryIntelligenceService( IOnboardingRepository onboardingRepository, IOnboardingService onboarding, IManuscriptScanPreviewStore scanStore, IStoryIntelligenceResultRepository runs, IStoryIntelligenceImportCommitService commits, IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceLocationImportService locationImport, IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligenceClient client, IOnboardingStoryIntelligenceBatchStore batchStore, IStoryIntelligenceProgressNotifier notifier, ICurrentUserService currentUser, ILogger logger) : IOnboardingStoryIntelligenceService { private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2"; private const string SourceType = "OnboardingWordCompanionChapter"; 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)); 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, 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() { var userId = RequireUserId(); var context = await GetContextAsync(userId); if (context is null) { return null; } var (state, preview, review, build, wizard) = context.Value; var existing = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID); if (existing is not null) { return existing; } build ??= await onboarding.BuildApprovedStructureAsync(preview.PreviewID) ?? throw new InvalidOperationException("The approved chapters could not be prepared for analysis."); 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."); } if (included.Any(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText))) { throw new InvalidOperationException("The Word Companion scan did not include chapter text. Scan the manuscript again before analysing it."); } var clientStatus = client.GetConfigurationStatus(); var modelSummary = StoryIntelligenceOptions.BuildModelSummary( clientStatus.ChapterStructureModel, clientStatus.SceneIntelligenceModel); var batchItems = new List(); 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 text = chapter.Preview.ChapterText!.Trim(); var paragraphs = StoryIntelligenceParagraphs.Split(text); 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}: {chapter.Decision.Title}", ChapterText = text, SourceFileName = preview.DocumentTitle, SourceWordCount = CountWords(text), SourceCharacterCount = text.Length, SourceParagraphCount = paragraphs.Count, SourceChapterCount = 1, Model = modelSummary, PromptVersionsSummary = PromptVersionsSummary }); batchItems.Add(new OnboardingStoryIntelligenceBatchItem { TemporaryChapterKey = chapter.Preview.TemporaryChapterKey, ChapterNumber = chapter.Decision.ChapterNumber, ChapterTitle = chapter.Decision.Title, ChapterID = chapterId, RunID = 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, Items = batchItems }; 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 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); 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, EstimatedCostUSD = run.EstimatedCostUSD, EstimatedCostGBP = run.EstimatedCostGBP, FailureStage = run.FailureStage, ErrorMessage = run.ErrorMessage, LatestSceneSummary = LatestSceneSummary(sceneResults), 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 characterImport.BuildReviewAsync(batch); var locationReview = await locationImport.BuildReviewAsync(batch); return new StoryIntelligenceProgressViewModel { BatchID = batch.BatchID, PreviewID = batch.PreviewID, ProjectID = batch.ProjectID, BookID = batch.BookID, ProjectName = batch.ProjectName, BookTitle = batch.BookTitle, Chapters = chapters, CharacterReview = characterReview, LocationReview = locationReview, PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel { ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated), CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count, CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked), CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete, LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count, LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked), LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete } }; } 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 GetProgressAsync(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 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(); 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); return (await GetProgressAsync(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 }; } 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); return (await GetProgressAsync(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 GetCompletionAsync(Guid batchId) { var progress = await GetProgressAsync(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 }; } public async Task ResumeBookAsync(int bookId) { var userId = RequireUserId(); var state = await pipelineState.GetForBookAsync(bookId, userId); if (state is null) { return null; } var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId); if (committedRuns.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 = committedRuns .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 = run.ChapterTitle, ChapterID = run.ChapterID, RunID = run.StoryIntelligenceRunID }) .ToList() }; if (state.IsComplete) { batch.CharacterStageComplete = true; batch.LocationStageComplete = true; } else if (state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport) { batch.CharacterStageComplete = true; } await batchStore.SaveAsync(batch); var route = state.CurrentStage switch { StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete, StoryIntelligencePipelineStages.LocationReview => StoryIntelligenceResumeRoutes.Locations, StoryIntelligencePipelineStages.LocationImport => StoryIntelligenceResumeRoutes.Locations, StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview, StoryIntelligencePipelineStages.SceneImport => StoryIntelligenceResumeRoutes.SceneReview, _ => StoryIntelligenceResumeRoutes.SceneReview }; return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route); } 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.ReadyToImport, 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 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 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 { SceneNumber = scene.TemporarySceneNumber, Summary = Trim(FirstConfigured(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.Summary?.Confidence ?? scene.ParsedScene.Setting?.Confidence, HasWarnings = false }) .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 DateTime CreatedUtc { get; init; } = DateTime.UtcNow; public IReadOnlyList Items { get; init; } = []; public List CharacterDecisions { get; } = []; public List LocationDecisions { get; } = []; public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; } public bool CharacterStageComplete { get; set; } public bool LocationStageComplete { 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 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 static class StoryIntelligenceResumeRoutes { public const string SceneReview = "SceneReview"; public const string Characters = "Characters"; public const string Locations = "Locations"; public const string Complete = "Complete"; } public sealed record OnboardingStoryIntelligenceResumeTarget(Guid BatchID, string Route);