From 96343138eac1ae08509846c095c2dda33f7afc95 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 28 Aug 2026 19:21:31 +0000 Subject: [PATCH] Add concurrent story intelligence execution --- PlotLine.Tests/Program.cs | 33 +++- PlotLine/Controllers/OnboardingController.cs | 6 +- .../Data/StoryIntelligenceResultRepository.cs | 29 ++++ PlotLine/Models/StoryIntelligenceModels.cs | 5 + .../StoryIntelligencePersistenceModels.cs | 25 +++ PlotLine/Program.cs | 1 + .../PersistedStoryIntelligenceRunner.cs | 90 +++++++++- .../PersistedStoryIntelligenceWorker.cs | 35 +++- .../StoryIntelligenceGlobalProgressService.cs | 44 +++++ .../StoryIntelligenceOpenAIInfrastructure.cs | 19 ++- ...E_StoryIntelligenceConcurrentExecution.sql | 158 ++++++++++++++++++ ...oryIntelligenceGlobalProgressViewModels.cs | 68 ++++++++ PlotLine/Views/Shared/_Layout.cshtml | 39 +++++ PlotLine/appsettings.json | 5 + .../story-intelligence-global-progress.css | 87 ++++++++++ .../js/story-intelligence-global-progress.js | 90 ++++++++++ 16 files changed, 718 insertions(+), 16 deletions(-) create mode 100644 PlotLine/Services/StoryIntelligenceGlobalProgressService.cs create mode 100644 PlotLine/Sql/169_Phase26E_StoryIntelligenceConcurrentExecution.sql create mode 100644 PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs create mode 100644 PlotLine/wwwroot/css/story-intelligence-global-progress.css create mode 100644 PlotLine/wwwroot/js/story-intelligence-global-progress.js diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 51f5ee0..c5cb33b 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -230,7 +230,8 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence review posts do not use success interstitials", StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials), ("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard), ("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering), - ("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls) + ("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls), + ("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution) }; foreach (var test in tests) @@ -239,6 +240,36 @@ foreach (var test in tests) Console.WriteLine($"PASS {test.Name}"); } +static void StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution() +{ + var root = PlotLineRoot(); + var options = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceModels.cs")); + var worker = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceWorker.cs")); + var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); + var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/169_Phase26E_StoryIntelligenceConcurrentExecution.sql")); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml")); + var progressScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js")); + var openAiClient = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceOpenAIInfrastructure.cs")); + + Assert(options.Contains("MaxGlobalConcurrentAiRequests", StringComparison.Ordinal), "Story Intelligence concurrency must be configurable."); + Assert(worker.Contains("Task.WhenAll(workers)", StringComparison.Ordinal), "Persisted runner should run bounded worker loops."); + Assert(worker.Contains("MaxConcurrentAiRequestsPerBook", StringComparison.Ordinal), "Worker logs/config should include the per-book cap."); + Assert(runner.Contains("ClaimNextPendingFairAsync", StringComparison.Ordinal), "Runner should use the fair persisted claim procedure."); + Assert(runner.Contains("Story Intelligence scene timing", StringComparison.Ordinal), "Runner should emit scene timing instrumentation."); + Assert(runner.Contains("Story Intelligence finalisation timing", StringComparison.Ordinal), "Runner should emit finalisation timing instrumentation."); + Assert(repository.Contains("StoryIntelligenceRun_ClaimNextPendingFair", StringComparison.Ordinal), "Fair claims should use a stored procedure."); + Assert(repository.Contains("StoryIntelligenceRun_ListActiveBookSummaryForUser", StringComparison.Ordinal), "Global progress should use a stored procedure."); + Assert(migration.Contains("@MaxConcurrentRunsPerBook", StringComparison.Ordinal), "Migration should enforce a per-book cap."); + Assert(migration.Contains("@MaxConcurrentRunsPerUser", StringComparison.Ordinal), "Migration should enforce a per-user cap."); + Assert(migration.Contains("Returned to the Story Intelligence queue after an expired worker claim", StringComparison.Ordinal), "Migration should recover expired worker claims."); + Assert(controller.Contains("Progress is shown in the page header", StringComparison.Ordinal), "Book analysis should return to normal UI instead of forcing the progress page."); + Assert(layout.Contains("data-story-intelligence-global-strip", StringComparison.Ordinal), "Shared layout should render the global progress strip."); + Assert(progressScript.Contains("StoryIntelligenceRunProgressChanged", StringComparison.Ordinal), "Progress strip should listen for persisted run SignalR events."); + Assert(openAiClient.Contains("RetryConditionHeaderValue", StringComparison.Ordinal), "OpenAI retry should honour Retry-After metadata."); +} + static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume() { var root = PlotLineRoot(); diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 4a1502b..108c1f8 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -148,7 +148,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard var existing = await storyIntelligence.ResumeBookAsync(bookId); if (existing is not null) { - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = existing.BatchID }); + TempData["ArchiveMessage"] = "Story Intelligence is already analysing this book. Progress is shown in the page header."; + return RedirectToAction("Details", "Books", new { id = bookId }); } try @@ -160,7 +161,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return RedirectToAction("Details", "Books", new { id = bookId }); } - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID }); + TempData["ArchiveMessage"] = "Story Intelligence analysis has started. You can keep working while PlotDirector analyses the book in the background."; + return RedirectToAction("Details", "Books", new { id = bookId }); } catch (InvalidOperationException ex) { diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index ef6a0c7..da843a3 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -9,6 +9,7 @@ public interface IStoryIntelligenceResultRepository Task SaveAsync(StoryIntelligenceRunSaveRequest request); Task QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request); Task ClaimNextPendingAsync(); + Task ClaimNextPendingFairAsync(int maxConcurrentRunsPerBook, int maxConcurrentRunsPerUser, int claimLeaseMinutes); Task UpdateProgressAsync( int runId, string? status = null, @@ -31,6 +32,7 @@ public interface IStoryIntelligenceResultRepository Task SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene); Task> ListRunsAsync(); Task> ListRunsByBookForUserAsync(int bookId, int userId); + Task> ListActiveBookRunSummariesForUserAsync(int userId); Task GetRunAsync(int runId); Task GetChapterResultAsync(int runId); Task> ListSceneResultsAsync(int runId); @@ -321,6 +323,23 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn commandType: CommandType.StoredProcedure); } + public async Task ClaimNextPendingFairAsync( + int maxConcurrentRunsPerBook, + int maxConcurrentRunsPerUser, + int claimLeaseMinutes) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceRun_ClaimNextPendingFair", + new + { + MaxConcurrentRunsPerBook = Math.Max(1, maxConcurrentRunsPerBook), + MaxConcurrentRunsPerUser = Math.Max(1, maxConcurrentRunsPerUser), + ClaimLeaseMinutes = Math.Max(15, claimLeaseMinutes) + }, + commandType: CommandType.StoredProcedure); + } + public async Task UpdateProgressAsync( int runId, string? status = null, @@ -490,6 +509,16 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn return rows.ToList(); } + public async Task> ListActiveBookRunSummariesForUserAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + public async Task GetRunAsync(int runId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/StoryIntelligenceModels.cs b/PlotLine/Models/StoryIntelligenceModels.cs index c66984e..5effbf9 100644 --- a/PlotLine/Models/StoryIntelligenceModels.cs +++ b/PlotLine/Models/StoryIntelligenceModels.cs @@ -114,6 +114,11 @@ public sealed class StoryIntelligenceOptions public int MaxOutputTokens { get; init; } = 4000; public int? SceneIntelligenceMaxOutputTokens { get; init; } public int TimeoutSeconds { get; init; } = 120; + public int MaxGlobalConcurrentAiRequests { get; init; } = 4; + public int MaxConcurrentAiRequestsPerBook { get; init; } = 2; + public int MaxConcurrentAiRequestsPerUser { get; init; } = 3; + public int QueuePollingIntervalSeconds { get; init; } = 3; + public int ClaimLeaseMinutes { get; init; } = 90; public string EffectiveModel => FirstConfigured(Model); public string EffectiveChapterStructureModel => FirstConfigured(ChapterStructureModel, Model); diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 0c94c2d..6c5f82e 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -340,6 +340,31 @@ public sealed class StoryIntelligenceQueuedRun public bool CancellationRequested => CancellationRequestedUtc.HasValue; } +public sealed class StoryIntelligenceActiveBookRunSummary +{ + public int UserID { get; init; } + public int? ProjectID { get; init; } + public string? ProjectTitle { get; init; } + public int? BookID { get; init; } + public string? BookTitle { get; init; } + public string? BookSubtitle { get; init; } + public string BookDisplayTitle => BookTitleFormatter.DisplayTitle(BookTitle, BookSubtitle); + public int RunCount { get; init; } + public int PendingRunCount { get; init; } + public int RunningRunCount { get; init; } + public int CompletedRunCount { get; init; } + public int FailedRunCount { get; init; } + public int TotalDetectedScenes { get; init; } + public int CompletedScenes { get; init; } + public int FailedScenes { get; init; } + public long TotalDurationMs { get; init; } + public DateTime FirstCreatedUtc { get; init; } + public DateTime UpdatedUtc { get; init; } + + public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0; + public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0; +} + public sealed class StoryIntelligenceSourceOption { public int Value { get; init; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 90b185f..e83ee94 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -236,6 +236,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 0193abe..bb60588 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -8,7 +8,7 @@ namespace PlotLine.Services; public interface IPersistedStoryIntelligenceRunner { - Task ProcessNextAsync(CancellationToken cancellationToken); + Task ProcessNextAsync(CancellationToken cancellationToken); } public sealed class PersistedStoryIntelligenceRunner( @@ -45,15 +45,19 @@ public sealed class PersistedStoryIntelligenceRunner( private readonly StoryIntelligenceOptions settings = options.Value; private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value; - public async Task ProcessNextAsync(CancellationToken cancellationToken) + public async Task ProcessNextAsync(CancellationToken cancellationToken) { - var run = await repository.ClaimNextPendingAsync(); + var run = await repository.ClaimNextPendingFairAsync( + settings.MaxConcurrentAiRequestsPerBook, + settings.MaxConcurrentAiRequestsPerUser, + settings.ClaimLeaseMinutes); if (run is null) { - return; + return false; } await ProcessAsync(run, cancellationToken); + return true; } private async Task ProcessAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken) @@ -68,9 +72,21 @@ public sealed class PersistedStoryIntelligenceRunner( var completedScenes = 0; var hasWarnings = false; var currentFailureStage = StoryIntelligenceFailureStages.DocumentRead; + var queueWaitMs = Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.CreatedUtc).TotalMilliseconds)); try { + logger.LogInformation( + "Story Intelligence run claimed. RunID={RunID} UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} QueueWaitMs={QueueWaitMs} StartedUtc={StartedUtc} ClaimUpdatedUtc={UpdatedUtc}", + run.StoryIntelligenceRunID, + run.UserID, + run.ProjectID, + run.BookID, + run.ChapterID, + queueWaitMs, + run.StartedUtc, + run.UpdatedUtc); + await PublishAsync( run, "Import started", @@ -79,6 +95,7 @@ public sealed class PersistedStoryIntelligenceRunner( "Story Intelligence has started reading this chapter.", stopwatch.ElapsedMilliseconds); + var preparationStopwatch = Stopwatch.StartNew(); await entitlement.EnsureCanExecuteAsync(run.UserID); await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken); @@ -118,15 +135,21 @@ public sealed class PersistedStoryIntelligenceRunner( hasWarnings = mode.HasWarnings; failedScenes += sceneBlocks.Count(block => !block.SplitValid); currentFailureStage = StoryIntelligenceFailureStages.SceneSplit; + preparationStopwatch.Stop(); logger.LogInformation( - "Story Intelligence mode: {Mode}. RunID={RunID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneCount={SceneCount}", + "Story Intelligence preparation timing. Mode={Mode} RunID={RunID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneCount={SceneCount} QueueWaitMs={QueueWaitMs} PreparationMs={PreparationMs} PromptVersions={PromptVersions} ChapterModel={ChapterModel} SceneModel={SceneModel}", mode.Mode, run.StoryIntelligenceRunID, run.ProjectID, run.BookID, run.ChapterID, - sceneBlocks.Count); + sceneBlocks.Count, + queueWaitMs, + preparationStopwatch.ElapsedMilliseconds, + run.PromptVersionsSummary, + stageModels.ChapterStructureModel, + stageModels.SceneIntelligenceModel); await repository.UpdateProgressAsync( run.StoryIntelligenceRunID, @@ -202,15 +225,21 @@ public sealed class PersistedStoryIntelligenceRunner( try { + var sceneStopwatch = Stopwatch.StartNew(); + var promptStopwatch = Stopwatch.StartNew(); currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence; var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText); + promptStopwatch.Stop(); + var openAiStopwatch = Stopwatch.StartNew(); var sceneClientResult = await client.ExecutePromptAsync( completedScenePrompt, scenePromptVersion, cancellationToken, stageModels.SceneIntelligenceModel, SceneIntelligenceMaxOutputTokens()); + openAiStopwatch.Stop(); totals.Add(sceneClientResult, pricing); + var parseStopwatch = Stopwatch.StartNew(); var sceneAttempt = await ParseAiJsonWithRetryAsync( completedScenePrompt, scenePromptVersion, @@ -219,6 +248,8 @@ public sealed class PersistedStoryIntelligenceRunner( totals, "Scene Intelligence", cancellationToken); + parseStopwatch.Stop(); + var validationStopwatch = Stopwatch.StartNew(); var validation = sceneValidator.Validate(sceneAttempt.Parsed); foreach (var warning in sceneAttempt.Warnings) { @@ -232,7 +263,9 @@ public sealed class PersistedStoryIntelligenceRunner( } hasWarnings = hasWarnings || validation.Warnings.Count > 0; + validationStopwatch.Stop(); + var persistenceStopwatch = Stopwatch.StartNew(); var sceneResultId = await repository.SaveSceneResultAsync( run.StoryIntelligenceRunID, chapterResultId, @@ -258,6 +291,7 @@ public sealed class PersistedStoryIntelligenceRunner( TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens), DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds) }); + persistenceStopwatch.Stop(); var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null; logger.LogInformation( "Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status analysed.", @@ -266,6 +300,7 @@ public sealed class PersistedStoryIntelligenceRunner( run.ChapterNumber, block.TemporarySceneNumber, sceneResultId); + var postProcessingStopwatch = Stopwatch.StartNew(); if (importSessionId.HasValue) { try @@ -309,6 +344,33 @@ public sealed class PersistedStoryIntelligenceRunner( logger.LogError(ex, "Character Intelligence profile update failed for import session {ImportSessionId}, scene result {SceneResultId}.", importSessionId, sceneResultId); } } + postProcessingStopwatch.Stop(); + sceneStopwatch.Stop(); + + logger.LogInformation( + "Story Intelligence scene timing. RunID={RunID} UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} SceneNumber={SceneNumber} QueueWaitMs={QueueWaitMs} PromptPrepareMs={PromptPrepareMs} OpenAiMs={OpenAiMs} ParseValidationMs={ParseValidationMs} ValidationMs={ValidationMs} PersistenceMs={PersistenceMs} PostProcessingMs={PostProcessingMs} SceneElapsedMs={SceneElapsedMs} Model={Model} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} TotalTokens={TotalTokens} StartedUtc={StartedUtc} CompletedUtc={CompletedUtc}", + run.StoryIntelligenceRunID, + run.UserID, + run.ProjectID, + run.BookID, + run.ChapterID, + block.SceneID, + block.TemporarySceneNumber, + queueWaitMs, + promptStopwatch.ElapsedMilliseconds, + openAiStopwatch.ElapsedMilliseconds, + parseStopwatch.ElapsedMilliseconds, + validationStopwatch.ElapsedMilliseconds, + persistenceStopwatch.ElapsedMilliseconds, + postProcessingStopwatch.ElapsedMilliseconds, + sceneStopwatch.ElapsedMilliseconds, + sceneClientResult.Model, + sceneClientResult.RetryCount + (sceneAttempt.RetryAttempted ? 1 : 0), + sceneAttempt.InputTokens, + sceneAttempt.OutputTokens, + SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens), + DateTime.UtcNow.AddMilliseconds(-sceneStopwatch.ElapsedMilliseconds), + DateTime.UtcNow); completedScenes++; await PublishAsync( @@ -371,6 +433,7 @@ public sealed class PersistedStoryIntelligenceRunner( ? StoryIntelligenceRunStatuses.CompletedWithWarnings : StoryIntelligenceRunStatuses.Completed; + var finalisationStopwatch = Stopwatch.StartNew(); await repository.CompleteRunAsync( run.StoryIntelligenceRunID, finalStatus, @@ -394,6 +457,21 @@ public sealed class PersistedStoryIntelligenceRunner( failedScenes: failedScenes, totalTokens: totals.TotalTokens); await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken); + finalisationStopwatch.Stop(); + logger.LogInformation( + "Story Intelligence finalisation timing. RunID={RunID} UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} Status={Status} SceneCount={SceneCount} CompletedScenes={CompletedScenes} FailedScenes={FailedScenes} FinalisationMs={FinalisationMs} TotalRunMs={TotalRunMs} TotalTokens={TotalTokens}", + run.StoryIntelligenceRunID, + run.UserID, + run.ProjectID, + run.BookID, + run.ChapterID, + finalStatus, + sceneBlocks.Count, + completedScenes, + failedScenes, + finalisationStopwatch.ElapsedMilliseconds, + stopwatch.ElapsedMilliseconds, + totals.TotalTokens); } catch (StoryIntelligenceRunCancelledException) { diff --git a/PlotLine/Services/PersistedStoryIntelligenceWorker.cs b/PlotLine/Services/PersistedStoryIntelligenceWorker.cs index a1f01aa..62a3e4f 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceWorker.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceWorker.cs @@ -1,19 +1,43 @@ +using Microsoft.Extensions.Options; +using PlotLine.Models; + namespace PlotLine.Services; public sealed class PersistedStoryIntelligenceWorker( IServiceScopeFactory scopeFactory, + IOptions options, ILogger logger) : BackgroundService { + private readonly StoryIntelligenceOptions settings = options.Value; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); + var workerCount = Math.Clamp(settings.MaxGlobalConcurrentAiRequests, 1, 24); + logger.LogInformation( + "Starting {WorkerCount} persisted Story Intelligence worker loop(s). MaxPerBook={MaxPerBook} MaxPerUser={MaxPerUser} PollSeconds={PollSeconds} ClaimLeaseMinutes={ClaimLeaseMinutes}", + workerCount, + Math.Max(1, settings.MaxConcurrentAiRequestsPerBook), + Math.Max(1, settings.MaxConcurrentAiRequestsPerUser), + Math.Max(1, settings.QueuePollingIntervalSeconds), + Math.Max(15, settings.ClaimLeaseMinutes)); + + var workers = Enumerable.Range(1, workerCount) + .Select(workerNumber => RunWorkerLoopAsync(workerNumber, stoppingToken)) + .ToArray(); + await Task.WhenAll(workers); + } + + private async Task RunWorkerLoopAsync(int workerNumber, CancellationToken stoppingToken) + { + var pollDelay = TimeSpan.FromSeconds(Math.Max(1, settings.QueuePollingIntervalSeconds)); while (!stoppingToken.IsCancellationRequested) { + var processedRun = false; try { using var scope = scopeFactory.CreateScope(); var runner = scope.ServiceProvider.GetRequiredService(); - await runner.ProcessNextAsync(stoppingToken); + processedRun = await runner.ProcessNextAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -21,10 +45,13 @@ public sealed class PersistedStoryIntelligenceWorker( } catch (Exception ex) { - logger.LogError(ex, "Persisted Story Intelligence worker failed while processing the next queued run."); + logger.LogError(ex, "Persisted Story Intelligence worker {WorkerNumber} failed while processing the next queued run.", workerNumber); } - await timer.WaitForNextTickAsync(stoppingToken); + if (!processedRun) + { + await Task.Delay(pollDelay, stoppingToken); + } } } } diff --git a/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs b/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs new file mode 100644 index 0000000..ee4ee72 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs @@ -0,0 +1,44 @@ +using PlotLine.Data; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceGlobalProgressService +{ + Task GetForCurrentUserAsync(); +} + +public sealed class StoryIntelligenceGlobalProgressService( + ICurrentUserService currentUser, + IStoryIntelligenceResultRepository runs) : IStoryIntelligenceGlobalProgressService +{ + public async Task GetForCurrentUserAsync() + { + if (currentUser.UserId is not int userId) + { + return new StoryIntelligenceGlobalProgressViewModel(); + } + + var summaries = await runs.ListActiveBookRunSummariesForUserAsync(userId); + return new StoryIntelligenceGlobalProgressViewModel + { + Books = summaries.Select(summary => new StoryIntelligenceGlobalProgressBookViewModel + { + ProjectID = summary.ProjectID, + BookID = summary.BookID, + BookTitle = string.IsNullOrWhiteSpace(summary.BookDisplayTitle) ? "Book" : summary.BookDisplayTitle, + RunCount = summary.RunCount, + PendingRunCount = summary.PendingRunCount, + RunningRunCount = summary.RunningRunCount, + CompletedRunCount = summary.CompletedRunCount, + FailedRunCount = summary.FailedRunCount, + TotalDetectedScenes = summary.TotalDetectedScenes, + CompletedScenes = summary.CompletedScenes, + FailedScenes = summary.FailedScenes, + TotalDurationMs = summary.TotalDurationMs, + UpdatedUtc = summary.UpdatedUtc + }) + .ToList() + }; + } +} diff --git a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs index 190c384..cd38d74 100644 --- a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs +++ b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs @@ -290,6 +290,8 @@ public sealed class StoryIntelligenceClient( } retryCount++; + await DelayForRetryAsync(attempt, cancellationToken, response.Headers.RetryAfter); + continue; } catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { @@ -309,7 +311,6 @@ public sealed class StoryIntelligenceClient( continue; } - await DelayForRetryAsync(attempt, cancellationToken); } throw new InvalidOperationException("OpenAI request failed after retry attempts."); @@ -327,8 +328,20 @@ public sealed class StoryIntelligenceClient( => responseText.Contains("\"insufficient_quota\"", StringComparison.OrdinalIgnoreCase) || responseText.Contains("exceeded your current quota", StringComparison.OrdinalIgnoreCase); - private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken) - => Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken); + private static Task DelayForRetryAsync( + int attempt, + CancellationToken cancellationToken, + RetryConditionHeaderValue? retryAfter = null) + { + var retryAfterDelay = retryAfter?.Delta + ?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : (TimeSpan?)null); + var exponentialDelay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)); + var delay = retryAfterDelay.HasValue && retryAfterDelay.Value > TimeSpan.Zero + ? retryAfterDelay.Value + : exponentialDelay; + var cappedDelay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds, 60)); + return Task.Delay(cappedDelay, cancellationToken); + } private static string TrimForLog(string value) => string.IsNullOrWhiteSpace(value) || value.Length <= 500 ? value : value[..500]; diff --git a/PlotLine/Sql/169_Phase26E_StoryIntelligenceConcurrentExecution.sql b/PlotLine/Sql/169_Phase26E_StoryIntelligenceConcurrentExecution.sql new file mode 100644 index 0000000..c39c89e --- /dev/null +++ b/PlotLine/Sql/169_Phase26E_StoryIntelligenceConcurrentExecution.sql @@ -0,0 +1,158 @@ +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ClaimNextPendingFair + @MaxConcurrentRunsPerBook int = 2, + @MaxConcurrentRunsPerUser int = 3, + @ClaimLeaseMinutes int = 90 +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now datetime2(7) = SYSUTCDATETIME(); + DECLARE @SafeMaxPerBook int = CASE WHEN ISNULL(@MaxConcurrentRunsPerBook, 0) < 1 THEN 1 ELSE @MaxConcurrentRunsPerBook END; + DECLARE @SafeMaxPerUser int = CASE WHEN ISNULL(@MaxConcurrentRunsPerUser, 0) < 1 THEN 1 ELSE @MaxConcurrentRunsPerUser END; + DECLARE @SafeClaimLeaseMinutes int = CASE WHEN ISNULL(@ClaimLeaseMinutes, 0) < 15 THEN 15 ELSE @ClaimLeaseMinutes END; + DECLARE @StoryIntelligenceRunID int; + + BEGIN TRANSACTION; + + UPDATE dbo.StoryIntelligenceRuns + SET Status = N'Pending', + CurrentStage = N'Pending', + CurrentMessage = N'Returned to the Story Intelligence queue after an expired worker claim.', + StartedUtc = NULL, + UpdatedUtc = @Now + WHERE Status = N'Running' + AND CancellationRequestedUtc IS NULL + AND UpdatedUtc < DATEADD(minute, -@SafeClaimLeaseMinutes, @Now); + + ;WITH PendingRuns AS + ( + SELECT + pending.StoryIntelligenceRunID, + pending.CreatedUtc, + pending.UserID, + pending.BookID, + ActiveForBook = ( + SELECT COUNT_BIG(1) + FROM dbo.StoryIntelligenceRuns activeBook + WHERE activeBook.Status = N'Running' + AND activeBook.BookID = pending.BookID + ), + ActiveForUser = ( + SELECT COUNT_BIG(1) + FROM dbo.StoryIntelligenceRuns activeUser + WHERE activeUser.Status = N'Running' + AND activeUser.UserID = pending.UserID + ) + FROM dbo.StoryIntelligenceRuns pending WITH (UPDLOCK, READPAST) + WHERE pending.Status = N'Pending' + AND ( + pending.BookID IS NULL + OR ( + SELECT COUNT_BIG(1) + FROM dbo.StoryIntelligenceRuns activeBook + WHERE activeBook.Status = N'Running' + AND activeBook.BookID = pending.BookID + ) < @SafeMaxPerBook + ) + AND ( + SELECT COUNT_BIG(1) + FROM dbo.StoryIntelligenceRuns activeUser + WHERE activeUser.Status = N'Running' + AND activeUser.UserID = pending.UserID + ) < @SafeMaxPerUser + ) + SELECT TOP (1) @StoryIntelligenceRunID = StoryIntelligenceRunID + FROM PendingRuns + ORDER BY + ActiveForBook, + ActiveForUser, + CreatedUtc, + StoryIntelligenceRunID; + + IF @StoryIntelligenceRunID IS NOT NULL + BEGIN + UPDATE dbo.StoryIntelligenceRuns + SET Status = N'Running', + StartedUtc = @Now, + CurrentStage = N'ChapterStructure', + CurrentMessage = N'Running Chapter Structure analysis.', + UpdatedUtc = @Now + WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID + AND Status = N'Pending'; + END; + + COMMIT TRANSACTION; + + IF @StoryIntelligenceRunID IS NULL + BEGIN + SELECT TOP (0) + StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, + SourceText, SourceWordCount, SourceCharacterCount, SourceParagraphCount, SourceChapterCount, PromptVersion, + PromptVersionsSummary, KnownCharactersJson, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens, + TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD, + ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes, + CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc + FROM dbo.StoryIntelligenceRuns; + RETURN; + END; + + SELECT + StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, + SourceText, SourceWordCount, SourceCharacterCount, SourceParagraphCount, SourceChapterCount, PromptVersion, + PromptVersionsSummary, KnownCharactersJson, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens, + TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD, + ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes, + CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc + FROM dbo.StoryIntelligenceRuns + WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT + r.UserID, + r.ProjectID, + p.ProjectName AS ProjectTitle, + r.BookID, + b.BookTitle, + b.Subtitle AS BookSubtitle, + COUNT(1) AS RunCount, + SUM(CASE WHEN r.Status = N'Pending' THEN 1 ELSE 0 END) AS PendingRunCount, + SUM(CASE WHEN r.Status = N'Running' THEN 1 ELSE 0 END) AS RunningRunCount, + SUM(CASE WHEN r.Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END) AS CompletedRunCount, + SUM(CASE WHEN r.Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) AS FailedRunCount, + SUM(ISNULL(r.TotalDetectedScenes, 0)) AS TotalDetectedScenes, + SUM(ISNULL(r.CompletedScenes, 0)) AS CompletedScenes, + SUM(ISNULL(r.FailedScenes, 0)) AS FailedScenes, + SUM(ISNULL(r.TotalDurationMs, 0)) AS TotalDurationMs, + MIN(r.CreatedUtc) AS FirstCreatedUtc, + MAX(r.UpdatedUtc) AS UpdatedUtc + FROM dbo.StoryIntelligenceRuns r + LEFT JOIN dbo.Projects p ON p.ProjectID = r.ProjectID + LEFT JOIN dbo.Books b ON b.BookID = r.BookID + WHERE r.UserID = @UserID + AND r.Status IN (N'Pending', N'Running') + GROUP BY r.UserID, r.ProjectID, p.ProjectName, r.BookID, b.BookTitle, b.Subtitle + ORDER BY MAX(r.UpdatedUtc) DESC, MIN(r.CreatedUtc); +END; +GO + +IF NOT EXISTS +( + SELECT 1 + FROM sys.indexes + WHERE name = N'IX_StoryIntelligenceRuns_QueueFairness' + AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceRuns') +) +BEGIN + CREATE INDEX IX_StoryIntelligenceRuns_QueueFairness + ON dbo.StoryIntelligenceRuns (Status, UserID, BookID, CreatedUtc, StoryIntelligenceRunID) + INCLUDE (ProjectID, ChapterID, ChapterNumber, UpdatedUtc, TotalDetectedScenes, CompletedScenes, FailedScenes, TotalDurationMs); +END; +GO diff --git a/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs new file mode 100644 index 0000000..7851487 --- /dev/null +++ b/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs @@ -0,0 +1,68 @@ +namespace PlotLine.ViewModels; + +public sealed class StoryIntelligenceGlobalProgressViewModel +{ + public IReadOnlyList Books { get; init; } = []; + public bool HasActiveWork => Books.Any(book => book.HasActiveRuns); + public int ActiveBookCount => Books.Count(book => book.HasActiveRuns); + public int ActiveRunCount => Books.Sum(book => book.PendingRunCount + book.RunningRunCount); + public int TotalDetectedScenes => Books.Sum(book => book.TotalDetectedScenes); + public int CompletedScenes => Books.Sum(book => book.CompletedScenes); + public int FailedScenes => Books.Sum(book => book.FailedScenes); + public int ProgressPercent => TotalDetectedScenes <= 0 + ? 0 + : Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalDetectedScenes)), 0, 100); + public string SummaryText => ActiveBookCount switch + { + 0 => string.Empty, + 1 => Books.First(book => book.HasActiveRuns).SummaryText, + _ => $"{ActiveBookCount:N0} books analysing" + }; +} + +public sealed class StoryIntelligenceGlobalProgressBookViewModel +{ + public int? ProjectID { get; init; } + public int? BookID { get; init; } + public string BookTitle { get; init; } = "Book"; + public int RunCount { get; init; } + public int PendingRunCount { get; init; } + public int RunningRunCount { get; init; } + public int CompletedRunCount { get; init; } + public int FailedRunCount { get; init; } + public int TotalDetectedScenes { get; init; } + public int CompletedScenes { get; init; } + public int FailedScenes { get; init; } + public long TotalDurationMs { get; init; } + public DateTime UpdatedUtc { get; init; } + + public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0; + public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0; + public int ProgressPercent => TotalDetectedScenes <= 0 + ? 0 + : Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalDetectedScenes)), 0, 100); + public string SummaryText => $"{BookTitle}: {CompletedScenes:N0}/{Math.Max(TotalDetectedScenes, CompletedScenes):N0} scenes"; + public string EstimatedRemaining => EstimateRemaining(TotalDurationMs, CompletedScenes, TotalDetectedScenes); + + private static string EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes) + { + if (totalScenes <= 0 || completedScenes <= 0) + { + return "Calculating"; + } + + if (completedScenes >= totalScenes) + { + return "Finalising"; + } + + var averageSceneMs = elapsedMs / Math.Max(1, completedScenes); + var remaining = TimeSpan.FromMilliseconds(averageSceneMs * (totalScenes - completedScenes)); + if (remaining.TotalMinutes >= 1) + { + return $"{Math.Max(1, (int)Math.Round(remaining.TotalMinutes)):N0} min"; + } + + return $"{Math.Max(1, (int)Math.Round(remaining.TotalSeconds)):N0} sec"; + } +} diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index 405155b..cdbb55c 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -4,6 +4,7 @@ @inject IConfiguration Configuration @inject IFeatureRequestService FeatureRequestService @inject IOnboardingService OnboardingService +@inject IStoryIntelligenceGlobalProgressService StoryIntelligenceGlobalProgress @{ var shellClass = ViewData["ShellClass"] as string ?? string.Empty; var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase); @@ -16,6 +17,9 @@ var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0; var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0; var showWordCompanionHeader = User.Identity?.IsAuthenticated == true && await OnboardingService.ShouldShowWordCompanionHeaderAsync(); + var storyIntelligenceProgress = User.Identity?.IsAuthenticated == true && !isMarketingPage + ? await StoryIntelligenceGlobalProgress.GetForCurrentUserAsync() + : new StoryIntelligenceGlobalProgressViewModel(); var seoMetadata = ViewData["Seo"] as SeoMetadata; var defaultTitle = PublicSeo.DisplayTitle(ViewData["Title"] as string); var seoTitle = ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle; @@ -99,6 +103,7 @@ + @@ -223,6 +228,39 @@ + @if (storyIntelligenceProgress.HasActiveWork) + { + var primaryBook = storyIntelligenceProgress.Books.FirstOrDefault(book => book.HasActiveRuns); +
+
+
+ + @storyIntelligenceProgress.SummaryText + + @storyIntelligenceProgress.CompletedScenes.ToString("N0") of @storyIntelligenceProgress.TotalDetectedScenes.ToString("N0") scenes analysed + @if (primaryBook is not null) + { + · @primaryBook.EstimatedRemaining remaining + } + +
+ + @if (primaryBook?.BookID is int progressBookId) + { + Book + } +
+
+ }
@@ -322,6 +360,7 @@ @if (User.Identity?.IsAuthenticated == true) { + } @await RenderSectionAsync("Scripts", required: false) diff --git a/PlotLine/appsettings.json b/PlotLine/appsettings.json index 09cd4fe..16ccf8e 100644 --- a/PlotLine/appsettings.json +++ b/PlotLine/appsettings.json @@ -28,6 +28,11 @@ "ChapterStructureModel": "gpt-5-mini", "SceneIntelligenceModel": "gpt-5-mini", "SceneIntelligenceMaxOutputTokens": 10000, + "MaxGlobalConcurrentAiRequests": 4, + "MaxConcurrentAiRequestsPerBook": 2, + "MaxConcurrentAiRequestsPerUser": 3, + "QueuePollingIntervalSeconds": 3, + "ClaimLeaseMinutes": 90, "AutomaticImageGenerationDuringImportEnabled": false, "RequireImageGenerationApproval": true, "MaxImagesPerImport": 12, diff --git a/PlotLine/wwwroot/css/story-intelligence-global-progress.css b/PlotLine/wwwroot/css/story-intelligence-global-progress.css new file mode 100644 index 0000000..c814be5 --- /dev/null +++ b/PlotLine/wwwroot/css/story-intelligence-global-progress.css @@ -0,0 +1,87 @@ +.story-intelligence-global-strip { + margin: -0.65rem 0 0.85rem; + border-bottom: 1px solid rgba(84, 68, 48, 0.14); + background: rgba(251, 248, 240, 0.96); + box-shadow: 0 8px 20px rgba(62, 45, 28, 0.05); +} + +.story-intelligence-global-strip__inner { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(180px, 320px) auto; + align-items: center; + gap: 0.85rem; + min-height: 44px; + padding: 0.45rem max(0.75rem, env(safe-area-inset-left)) 0.45rem max(0.75rem, env(safe-area-inset-right)); +} + +.story-intelligence-global-strip__copy { + display: flex; + align-items: center; + min-width: 0; + gap: 0.55rem; + color: var(--plotline-text); + font-size: 0.9rem; +} + +.story-intelligence-global-strip__copy strong, +.story-intelligence-global-strip__copy span:not(.story-intelligence-global-strip__dot) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.story-intelligence-global-strip__copy strong { + flex: 0 1 auto; + max-width: min(34vw, 28rem); +} + +.story-intelligence-global-strip__copy span:not(.story-intelligence-global-strip__dot) { + color: var(--plotline-text-muted); +} + +.story-intelligence-global-strip__dot { + flex: 0 0 auto; + width: 0.62rem; + height: 0.62rem; + border-radius: 50%; + background: var(--plotline-secondary); + box-shadow: 0 0 0 0.22rem rgba(49, 95, 103, 0.14); +} + +.story-intelligence-global-strip__meter { + overflow: hidden; + width: 100%; + height: 0.44rem; + border-radius: 999px; + background: rgba(84, 68, 48, 0.14); +} + +.story-intelligence-global-strip__meter span { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--plotline-secondary), var(--plotline-accent)); + transition: width 0.25s ease; +} + +@media (max-width: 720px) { + .story-intelligence-global-strip__inner { + grid-template-columns: minmax(0, 1fr) auto; + } + + .story-intelligence-global-strip__meter { + grid-column: 1 / -1; + order: 3; + } + + .story-intelligence-global-strip__copy { + flex-wrap: wrap; + gap: 0.25rem 0.55rem; + } + + .story-intelligence-global-strip__copy strong, + .story-intelligence-global-strip__copy span:not(.story-intelligence-global-strip__dot) { + max-width: calc(100vw - 7rem); + } +} diff --git a/PlotLine/wwwroot/js/story-intelligence-global-progress.js b/PlotLine/wwwroot/js/story-intelligence-global-progress.js new file mode 100644 index 0000000..5bc381c --- /dev/null +++ b/PlotLine/wwwroot/js/story-intelligence-global-progress.js @@ -0,0 +1,90 @@ +(() => { + const strip = document.querySelector("[data-story-intelligence-global-strip]"); + if (!strip || !window.signalR) { + return; + } + + const title = strip.querySelector("[data-story-intelligence-global-title]"); + const detail = strip.querySelector("[data-story-intelligence-global-detail]"); + const bar = strip.querySelector("[data-story-intelligence-global-bar]"); + const readNumber = (value) => { + const parsed = Number.parseInt(value || "", 10); + return Number.isInteger(parsed) ? parsed : null; + }; + + const trackedBookId = readNumber(strip.dataset.bookId); + const trackedProjectId = readNumber(strip.dataset.projectId); + const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0)); + const estimateRemaining = (elapsedMs, completed, total) => { + if (!total || !completed) { + return "Calculating"; + } + + if (completed >= total) { + return "Finalising"; + } + + const remainingMs = (elapsedMs / completed) * (total - completed); + const remainingSeconds = Math.max(1, Math.round(remainingMs / 1000)); + if (remainingSeconds >= 60) { + return `${Math.max(1, Math.round(remainingSeconds / 60))} min`; + } + + return `${remainingSeconds} sec`; + }; + + const update = (progress) => { + if (!progress) { + return; + } + + const bookId = readNumber(progress.bookID ?? progress.bookId); + const projectId = readNumber(progress.projectID ?? progress.projectId); + if (trackedBookId && bookId && trackedBookId !== bookId) { + return; + } + + if (!trackedBookId && trackedProjectId && projectId && trackedProjectId !== projectId) { + return; + } + + const status = progress.status || progress.Status || ""; + const total = readNumber(progress.totalDetectedScenes ?? progress.TotalDetectedScenes) || 0; + const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0; + const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0; + const elapsedMs = readNumber(progress.totalDurationMs ?? progress.TotalDurationMs) || 0; + const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0; + + if (bar) { + bar.style.width = `${percent}%`; + } + + if (detail) { + const remaining = estimateRemaining(elapsedMs, completed, total); + detail.textContent = `${formatCount(completed)} of ${formatCount(total)} scenes analysed \u00b7 ${remaining} remaining`; + } + + if (title && status) { + if (status === "Failed") { + title.textContent = "Story Intelligence needs attention"; + } else if (status === "Cancelled") { + title.textContent = "Story Intelligence cancelled"; + } else if (status === "Completed" || status === "CompletedWithWarnings") { + title.textContent = failed > 0 ? "Story Intelligence completed with notes" : "Story Intelligence complete"; + window.setTimeout(() => { + strip.hidden = true; + }, 10000); + } + } + }; + + const connection = new signalR.HubConnectionBuilder() + .withUrl("/hubs/story-intelligence") + .withAutomaticReconnect() + .build(); + + connection.on("StoryIntelligenceRunProgressChanged", update); + connection.start().catch(() => { + strip.dataset.connection = "offline"; + }); +})();