using Microsoft.AspNetCore.SignalR; using PlotLine.Data; using PlotLine.Hubs; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryIntelligenceService { Task GetOverviewAsync(); Task GetProgressAsync(Guid jobId); Task GetCompletionAsync(Guid jobId); Task StartAsync(); Task CancelAsync(Guid jobId); Task GetDashboardAsync(); Task GetProgressForUserAsync(int userId, Guid jobId); } public interface IStoryIntelligenceProvider { Task ProcessAsync(StoryIntelligenceJob job, Func progress, CancellationToken cancellationToken); } public sealed class StoryIntelligenceProviderProgress { public string Status { get; init; } = StoryIntelligenceJobStatuses.Preparing; public string Stage { get; init; } = StoryIntelligenceJobStatuses.Preparing; public string Message { get; init; } = "Preparing manuscript..."; public int Percent { get; init; } public string? EstimatedRemaining { get; init; } } public sealed class StubStoryIntelligenceProvider : IStoryIntelligenceProvider { public async Task ProcessAsync(StoryIntelligenceJob job, Func progress, CancellationToken cancellationToken) { var stages = new[] { new StoryIntelligenceProviderProgress { Percent = 20, Message = "Building analysis pipeline...", EstimatedRemaining = "Calculating" }, new StoryIntelligenceProviderProgress { Percent = 55, Message = "Preparing scene batches...", EstimatedRemaining = "Calculating" }, new StoryIntelligenceProviderProgress { Percent = 85, Message = "Initialising Story Intelligence...", EstimatedRemaining = "Calculating" } }; foreach (var stage in stages) { cancellationToken.ThrowIfCancellationRequested(); await progress(stage); await Task.Yield(); } } } public sealed class StoryIntelligenceService( IStoryIntelligenceRepository repository, IStoryIntelligenceResultRepository persistedRuns, IStoryIntelligencePipelineStateService pipelineState, IOnboardingStoryIntelligenceBatchStore onboardingBatches, IOnboardingRepository onboarding, IProjectRepository projects, IBookRepository books, ICurrentUserService currentUser) : IStoryIntelligenceService { private const string ConsentText = "I consent to PlotDirector preparing the Story Intelligence analysis framework for this manuscript. I understand future AI analysis is optional and will use the OpenAI API."; public async Task GetOverviewAsync() { var state = await onboarding.GetOrCreateAsync(RequireUserId()); var latest = await repository.GetLatestForUserAsync(state.UserID); return new StoryIntelligenceOverviewViewModel { ProjectID = state.ProjectID, BookID = state.BookID, SelectedProjectName = state.ProjectID.HasValue ? (await projects.GetForUserAsync(state.ProjectID.Value, state.UserID))?.ProjectName : null, SelectedBookTitle = state.BookID.HasValue ? BuildBookTitle(await books.GetAsync(state.BookID.Value)) : null, ExistingJob = latest is null ? null : ToProgress(latest) }; } public async Task GetProgressAsync(Guid jobId) { var job = await repository.GetAsync(RequireUserId(), jobId); return job is null ? null : new StoryIntelligenceProgressViewModel { Job = ToProgress(job), ProjectName = (await projects.GetForUserAsync(job.ProjectID, job.UserID))?.ProjectName, BookTitle = BuildBookTitle(await books.GetAsync(job.BookID)) }; } public async Task GetCompletionAsync(Guid jobId) { var job = await repository.GetAsync(RequireUserId(), jobId); if (job is null || !job.IsCompleted) { return null; } return new StoryIntelligenceCompletionViewModel { Job = ToProgress(job), ProjectID = job.ProjectID, BookID = job.BookID, ProjectName = (await projects.GetForUserAsync(job.ProjectID, job.UserID))?.ProjectName, BookTitle = BuildBookTitle(await books.GetAsync(job.BookID)) }; } public async Task StartAsync() { var state = await onboarding.GetOrCreateAsync(RequireUserId()); if (!state.ProjectID.HasValue || !state.BookID.HasValue) { return null; } return await repository.CreateWithConsentAsync( state.UserID, state.UserOnboardingStateID, state.ProjectID.Value, state.BookID.Value, ConsentText); } public async Task CancelAsync(Guid jobId) => await repository.CancelAsync(RequireUserId(), jobId); public async Task GetDashboardAsync() { if (!currentUser.UserId.HasValue) { return new StoryIntelligenceDashboardViewModel(); } var pipelineBooks = (await pipelineState.ListForUserAsync(currentUser.UserId.Value)) .Select(ToPipelineBookStatus) .ToList(); var activeBatch = await onboardingBatches.GetLatestActiveForUserAsync(currentUser.UserId.Value); if (activeBatch is not null) { var runs = new List(); foreach (var item in activeBatch.Items) { var run = await persistedRuns.GetRunAsync(item.RunID); if (run is not null) { runs.Add(run); } } var activeRun = runs .Where(run => run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running) .OrderBy(run => run.ChapterNumber ?? decimal.MaxValue) .ThenBy(run => run.StoryIntelligenceRunID) .FirstOrDefault(); if (activeRun is not null) { var completedChapters = runs.Count(run => run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings); var currentChapter = Math.Clamp(completedChapters + 1, 1, Math.Max(1, activeBatch.Items.Count)); return new StoryIntelligenceDashboardViewModel { ShouldShow = true, ActiveBatchID = activeBatch.BatchID, ActiveImportBookTitle = activeBatch.BookTitle, ActiveImportProgressLabel = $"Chapter {currentChapter:N0} of {activeBatch.Items.Count:N0}", Title = "Story Intelligence is reading", Description = "PlotDirector is analysing your manuscript in the background.", ButtonText = "View progress", PipelineBooks = pipelineBooks }; } } var nextPipeline = pipelineBooks.FirstOrDefault(item => !item.IsComplete) ?? pipelineBooks.FirstOrDefault(); if (nextPipeline is not null) { return new StoryIntelligenceDashboardViewModel { ShouldShow = true, Title = nextPipeline.IsComplete ? "Story Intelligence Complete" : "Continue Story Intelligence", Description = nextPipeline.IsComplete ? $"{nextPipeline.BookTitle} has completed the currently available Story Intelligence stages." : $"{nextPipeline.BookTitle} is waiting at the next Story Intelligence review stage.", ButtonText = nextPipeline.IsComplete ? "View summary" : "Continue Story Intelligence", PipelineBooks = pipelineBooks }; } var state = await onboarding.GetAsync(currentUser.UserId.Value); return state?.ProjectID.HasValue == true && state.BookID.HasValue ? new StoryIntelligenceDashboardViewModel { ShouldShow = true, Title = "Import your manuscript", Description = "Scan your manuscript with the Word Companion, review the chapters, then let PlotDirector prepare scene suggestions.", ButtonText = "Continue manuscript import", PipelineBooks = pipelineBooks } : new StoryIntelligenceDashboardViewModel(); } public async Task GetProgressForUserAsync(int userId, Guid jobId) { var job = await repository.GetAsync(userId, jobId); return job is null ? null : ToProgress(job); } public static StoryIntelligenceJobProgress ToProgress(StoryIntelligenceJob job) { var elapsedFrom = job.StartedUtc ?? job.CreatedUtc; return new StoryIntelligenceJobProgress { JobID = job.JobID, UserID = job.UserID, ProjectID = job.ProjectID, BookID = job.BookID, Status = job.Status, CurrentStage = job.CurrentStage, CurrentMessage = job.CurrentMessage, ProgressPercent = job.ProgressPercent, EstimatedRemaining = job.EstimatedRemaining, ErrorMessage = job.ErrorMessage, UpdatedUtc = job.UpdatedUtc, IsActive = job.IsActive, IsCompleted = job.IsCompleted, ElapsedTime = FormatElapsed(DateTime.UtcNow - elapsedFrom) }; } private int RequireUserId() => currentUser.UserId ?? throw new InvalidOperationException("Sign in to use Story Intelligence."); private static string BuildBookTitle(Book? book) => book is null ? "Selected book" : $"Book {book.BookNumber}: {book.BookDisplayTitle}"; private static StoryIntelligencePipelineBookStatusViewModel ToPipelineBookStatus(StoryIntelligenceBookPipelineState state) => new() { BookID = state.BookID, BookTitle = state.BookDisplayTitle, StatusLabel = PipelineStatusLabel(state), Description = PipelineDescription(state), IsComplete = state.IsComplete }; private static string PipelineStatusLabel(StoryIntelligenceBookPipelineState state) { if (state.IsComplete) { return "Story Intelligence Complete"; } return state.CurrentReviewStage switch { StoryIntelligencePipelineStages.KnowledgeReview => "Waiting for Knowledge Review", StoryIntelligencePipelineStages.RelationshipReview => "Waiting for Relationship Review", StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review", StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review", StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review", StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review", _ when StoryIntelligenceReadyForKnowledge(state) => "Waiting for Knowledge Review", _ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review", _ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review", _ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review", _ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase) ? "Story Intelligence In Progress" : "Story Intelligence Needs Review" }; } private static string PipelineDescription(StoryIntelligenceBookPipelineState state) => state.IsComplete ? "All currently available Story Intelligence stages are complete." : state.CurrentReviewStage switch { StoryIntelligencePipelineStages.KnowledgeReview => "Review detected knowledge changes and decide what to create or link.", StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.", StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.", StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.", StoryIntelligencePipelineStages.CharacterReview => "Review detected characters and decide what to create or link.", StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.", _ when StoryIntelligenceReadyForKnowledge(state) => "Review detected knowledge changes and decide what to create or link.", _ when StoryIntelligenceReadyForRelationships(state) => "Review detected relationships and decide what to create or link.", _ when StoryIntelligenceReadyForAssets(state) => "Review detected assets and decide what to create or link.", _ when StoryIntelligenceReadyForLocations(state) => "Review detected locations and decide what to create or link.", _ => "Continue from the next Story Intelligence stage." }; private static bool StoryIntelligenceReadyForKnowledge(StoryIntelligenceBookPipelineState state) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase) || state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport; private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState state) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase) || state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport; private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase) || state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport || (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase) && !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase)); private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase) || state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport; private static string FormatElapsed(TimeSpan elapsed) => elapsed.TotalMinutes < 1 ? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec" : $"{(int)elapsed.TotalMinutes} min {elapsed.Seconds:D2} sec"; } public sealed class StoryIntelligenceWorker( IServiceScopeFactory scopeFactory, IHubContext hub, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2)); while (!stoppingToken.IsCancellationRequested) { await ProcessNextAsync(stoppingToken); await timer.WaitForNextTickAsync(stoppingToken); } } private async Task ProcessNextAsync(CancellationToken stoppingToken) { using var scope = scopeFactory.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var provider = scope.ServiceProvider.GetRequiredService(); var job = await repository.ClaimNextPendingAsync(); if (job is null) { return; } await NotifyAsync(job); try { await provider.ProcessAsync(job, async update => { var updated = await repository.UpdateProgressAsync( job.JobID, update.Status, update.Percent, update.Stage, update.Message, update.EstimatedRemaining); if (updated is not null) { await NotifyAsync(updated); if (updated.IsCancelled) { throw new OperationCanceledException(); } } }, stoppingToken); var completed = await repository.CompleteAsync(job.JobID); if (completed is not null) { await NotifyAsync(completed); } } catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) { var cancelled = await repository.CancelAsync(job.UserID, job.JobID); if (cancelled is not null) { await NotifyAsync(cancelled); } } catch (Exception ex) { logger.LogError(ex, "Story Intelligence job {JobID} failed.", job.JobID); var failed = await repository.FailAsync(job.JobID, ex.Message); if (failed is not null) { await NotifyAsync(failed); } } } private Task NotifyAsync(StoryIntelligenceJob job) => hub.Clients.Group(StoryIntelligenceHub.UserGroup(job.UserID)) .SendAsync("StoryIntelligenceProgressChanged", StoryIntelligenceService.ToProgress(job)); }