From e9f15e076b446a4fcfd1a04f8d92d47ca2ef085f Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 29 Aug 2026 09:10:38 +0000 Subject: [PATCH] Complete Story Intelligence scene analysis pipeline --- PlotLine.Tests/Program.cs | 41 +++- .../StoryIntelligencePipelineRepository.cs | 19 ++ .../StoryIntelligencePersistenceModels.cs | 20 ++ .../PersistedStoryIntelligenceRunner.cs | 19 +- .../PersistedStoryIntelligenceWorker.cs | 17 ++ .../StoryIntelligenceOperationalServices.cs | 8 +- .../StoryIntelligencePipelineStateService.cs | 36 +++ .../StoryIntelligenceProgressNotifier.cs | 9 +- ...lligenceSceneAnalysisCompletionBarrier.sql | 225 ++++++++++++++++++ .../js/story-intelligence-global-progress.js | 3 +- 10 files changed, 390 insertions(+), 7 deletions(-) create mode 100644 PlotLine/Sql/172_Phase26H_StoryIntelligenceSceneAnalysisCompletionBarrier.sql diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 1fc9510..5f26847 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -245,6 +245,7 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence global banner remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps), ("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots), ("Story Intelligence live global progress publishes authoritative scene aggregates", StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates), + ("Story Intelligence scene analysis completion barrier releases review", StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview), ("Story Intelligence active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip), ("Story Intelligence book actions do not open review centre while running", StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning) }; @@ -387,7 +388,7 @@ static void StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps() Assert(model.SummaryText.Contains("49/49", StringComparison.Ordinal), "Finalising summary should remain book-scoped instead of becoming empty."); Assert(viewModel.Contains("|| string.Equals(Status, \"Finalising\"", StringComparison.Ordinal), "Finalising should be treated as active in the shared status model."); Assert(layout.Contains("data-story-intelligence-updated-utc", StringComparison.Ordinal), "Initial render should expose an aggregate freshness timestamp."); - Assert(!script.Contains("strip.hidden = true", StringComparison.Ordinal), "Live refresh should not hide the banner from an empty/finalising aggregate."); + Assert(script.Contains("strip.hidden = true", StringComparison.Ordinal), "Live refresh should clear the active banner once persisted state no longer has active/finalising aggregate work."); Assert(!script.Contains("window.setTimeout(() =>", StringComparison.Ordinal), "Completion states should not auto-hide while final review routing may still be preparing."); Assert(migration.Contains("OR pipeline.Status = N'InProgress'", StringComparison.Ordinal), "In-progress book pipeline state should keep the aggregate row visible."); } @@ -419,7 +420,7 @@ static void StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots() Assert(runningWithTerminalSceneIssue.Status == StoryIntelligenceRunStatuses.Running, "Active running work should outrank terminal scene warnings until finalisation."); Assert(script.Contains("lastSnapshotTime", StringComparison.Ordinal), "Client should track the freshest aggregate snapshot."); Assert(script.Contains("updatedTime < lastSnapshotTime", StringComparison.Ordinal), "Out-of-order SignalR refreshes should not regress the visible banner."); - Assert(script.Contains("strip.dataset.connection = \"waiting\"", StringComparison.Ordinal), "Empty aggregate refreshes should be treated as indeterminate rather than completion."); + Assert(script.Contains("strip.dataset.connection = \"complete\"", StringComparison.Ordinal), "Empty aggregate refreshes should clear stale completed analysis banners."); Assert(viewModel.Contains("public DateTime? UpdatedUtc", StringComparison.Ordinal), "Aggregate payload should expose a timestamp for stale-snapshot checks."); } @@ -449,7 +450,7 @@ static void StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggreg Assert(sceneFailureBlock.Contains("failedScenes: failedScenes", StringComparison.Ordinal), "Terminal scene failure should persist the authoritative failed-scene count."); Assert(globalService.Contains("Task GetForUserAsync(int userId)", StringComparison.Ordinal), "Background publishing should be able to retrieve the same user-scoped aggregate used by page load."); - Assert(notifier.Contains("GetForUserAsync(progress.UserID)", StringComparison.Ordinal), "Live progress publish should fetch the authoritative aggregate for the run owner."); + Assert(notifier.Contains("GetForUserAsync(userId)", StringComparison.Ordinal), "Live progress publish should fetch the authoritative aggregate for the run owner."); Assert(notifier.Contains("StoryIntelligenceGlobalProgressChanged", StringComparison.Ordinal), "Live progress publish should send a compact aggregate SignalR event."); Assert(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\", render)", StringComparison.Ordinal), "The global banner should render authoritative aggregate payloads directly."); Assert(script.Contains("connection.onreconnected(refresh)", StringComparison.Ordinal), "Reconnect should rejoin the user group and immediately catch up from persisted aggregate state."); @@ -458,6 +459,40 @@ static void StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggreg Assert(layout.Contains("data-failed-scenes", StringComparison.Ordinal), "Initial server render should seed terminal failed count for monotonic live comparisons."); } +static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview() +{ + var root = PlotLineRoot(); + var migration = File.ReadAllText(Path.Combine(root, "Sql/172_Phase26H_StoryIntelligenceSceneAnalysisCompletionBarrier.sql")); + var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs")); + var pipelineState = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs")); + var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); + var worker = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceWorker.cs")); + var notificationService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceOperationalServices.cs")); + var globalScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js")); + + Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_TryCompleteSceneAnalysis", StringComparison.Ordinal), "Scene analysis completion should be controlled by a stored procedure."); + Assert(migration.Contains("WITH (UPDLOCK, HOLDLOCK)", StringComparison.Ordinal), "The book-level barrier should use a database lock to avoid duplicate finalisers."); + Assert(migration.Contains("Status = N'NeedsReview'", StringComparison.Ordinal), "A successful scene-analysis barrier should release the book into review instead of leaving it InProgress."); + Assert(migration.Contains("CurrentStage = N'CharacterReview'", StringComparison.Ordinal), "The next persisted stage after scene analysis should be character resolution/review."); + Assert(migration.Contains("LastCompletedStage = N'SceneImport'", StringComparison.Ordinal), "The completed scene-analysis stage should be durable for page-load recovery."); + Assert(migration.Contains("AND @ActiveRunCount = 0", StringComparison.Ordinal), "The barrier should wait until no queued/running chapter work remains."); + Assert(migration.Contains("AND @FailedRunCount = 0", StringComparison.Ordinal), "The normal success path should not silently hide failed chapter runs."); + Assert(migration.Contains("@CompletedScenes + @FailedScenes >= @TotalExpectedScenes", StringComparison.Ordinal), "All expected scenes should be terminal before review is released."); + Assert(!migration.Contains("StoryIntelligenceImportCommits", StringComparison.Ordinal), "Canonical scene-analysis completion should not depend on legacy import commit rows."); + Assert(migration.Contains("AND Status = N'InProgress'", StringComparison.Ordinal), "The finaliser update should be idempotent and claim only an active pipeline."); + Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_TryCompleteNextReadySceneAnalysis", StringComparison.Ordinal), "Idle workers should recover books stranded after restart before finalisation."); + + Assert(repository.Contains("TryCompleteSceneAnalysisAsync", StringComparison.Ordinal), "Repository should expose the per-book barrier procedure."); + Assert(repository.Contains("TryCompleteNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Repository should expose the restart recovery barrier procedure."); + Assert(pipelineState.Contains("CompleteSceneAnalysisIfReadyAsync", StringComparison.Ordinal), "Pipeline state service should complete scene analysis after the final chapter run."); + Assert(pipelineState.Contains("RecoverNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Pipeline state service should support restart-safe recovery."); + Assert(runner.Contains("CompleteBookSceneAnalysisIfReadyAsync", StringComparison.Ordinal), "Runner should check the book barrier after chapter completion."); + Assert(runner.Contains("await CompleteBookSceneAnalysisIfReadyAsync(run, cancellationToken);", StringComparison.Ordinal), "Runner should use the barrier instead of emailing while the pipeline is still InProgress."); + Assert(worker.Contains("RecoverReadySceneAnalysisAsync", StringComparison.Ordinal), "Idle worker polling should repair ready pipelines even without new scene events."); + Assert(notificationService.Contains("NotifyIfImportTerminalAsync(int bookId, int userId", StringComparison.Ordinal), "Completion notification should be idempotently callable during recovery without re-running AI."); + Assert(globalScript.Contains("strip.dataset.connection = \"complete\"", StringComparison.Ordinal), "The global strip should clear stale 100% analysing text after review-ready state is persisted."); +} + static void StoryIntelligenceRecoveredProviderFailuresStayOperational() { var requestCount = 0; diff --git a/PlotLine/Data/StoryIntelligencePipelineRepository.cs b/PlotLine/Data/StoryIntelligencePipelineRepository.cs index 63d31d5..075b7a0 100644 --- a/PlotLine/Data/StoryIntelligencePipelineRepository.cs +++ b/PlotLine/Data/StoryIntelligencePipelineRepository.cs @@ -12,6 +12,8 @@ public interface IStoryIntelligencePipelineRepository Task> ListForUserAsync(int userId); Task UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request); Task> ListCommittedRunsByBookAsync(int bookId, int userId); + Task TryCompleteSceneAnalysisAsync(int bookId, int userId); + Task TryCompleteNextReadySceneAnalysisAsync(); Task TryBeginCompletionNotificationAsync(int bookId, int userId); } @@ -83,6 +85,23 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co return rows.ToList(); } + public async Task TryCompleteSceneAnalysisAsync(int bookId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceBookPipeline_TryCompleteSceneAnalysis", + new { BookID = bookId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task TryCompleteNextReadySceneAnalysisAsync() + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceBookPipeline_TryCompleteNextReadySceneAnalysis", + commandType: CommandType.StoredProcedure); + } + public async Task TryBeginCompletionNotificationAsync(int bookId, int userId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 3d57e8d..6fc57a2 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -87,6 +87,26 @@ public sealed class StoryIntelligenceImportNotificationTarget public string NotificationKind { get; init; } = "Success"; } +public sealed class StoryIntelligenceSceneAnalysisCompletion +{ + public int StoryIntelligenceBookPipelineID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public int UserID { get; init; } + public int? LastRunID { get; init; } + public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview; + public string CurrentStage { get; init; } = StoryIntelligencePipelineStages.CharacterReview; + public string? LastCompletedStage { get; init; } + public string? CurrentReviewStage { get; init; } + public int RunCount { get; init; } + public int CompletedRunCount { get; init; } + public int FailedRunCount { get; init; } + public int TotalExpectedScenes { get; init; } + public int CompletedScenes { get; init; } + public int FailedScenes { get; init; } + public bool CompletedNow { get; init; } +} + public sealed class StoryIntelligenceBookPipelineSaveRequest { public int ProjectID { get; init; } diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 47ee3d4..a9cd02b 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -29,6 +29,7 @@ public sealed class PersistedStoryIntelligenceRunner( IStoryIntelligenceProgressNotifier notifier, IStoryMemoryService storyMemory, ICharacterIntelligenceService characterIntelligence, + IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligenceEntitlementService entitlement, IStoryIntelligenceCompletionNotificationService completionNotifications, ILogger logger) : IPersistedStoryIntelligenceRunner @@ -457,7 +458,7 @@ public sealed class PersistedStoryIntelligenceRunner( completedScenes: completedScenes, failedScenes: failedScenes, totalTokens: totals.TotalTokens); - await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken); + await CompleteBookSceneAnalysisIfReadyAsync(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}", @@ -782,6 +783,22 @@ public sealed class PersistedStoryIntelligenceRunner( } } + private async Task CompleteBookSceneAnalysisIfReadyAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken) + { + if (run.BookID is not int bookId) + { + await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken); + return; + } + + var completion = await pipelineState.CompleteSceneAnalysisIfReadyAsync(bookId, run.UserID); + if (completion is { CompletedNow: true }) + { + await notifier.PublishGlobalAsync(run.UserID); + await completionNotifications.NotifyIfImportTerminalAsync(bookId, run.UserID, cancellationToken); + } + } + private Task PublishAsync( StoryIntelligenceQueuedRun run, string eventType, diff --git a/PlotLine/Services/PersistedStoryIntelligenceWorker.cs b/PlotLine/Services/PersistedStoryIntelligenceWorker.cs index 62a3e4f..2107f81 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceWorker.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceWorker.cs @@ -50,8 +50,25 @@ public sealed class PersistedStoryIntelligenceWorker( if (!processedRun) { + await RecoverReadySceneAnalysisAsync(stoppingToken); await Task.Delay(pollDelay, stoppingToken); } } } + + private async Task RecoverReadySceneAnalysisAsync(CancellationToken stoppingToken) + { + using var scope = scopeFactory.CreateScope(); + var pipelineState = scope.ServiceProvider.GetRequiredService(); + var notifier = scope.ServiceProvider.GetRequiredService(); + var completionNotifications = scope.ServiceProvider.GetRequiredService(); + var completion = await pipelineState.RecoverNextReadySceneAnalysisAsync(); + if (completion is not { CompletedNow: true }) + { + return; + } + + await notifier.PublishGlobalAsync(completion.UserID); + await completionNotifications.NotifyIfImportTerminalAsync(completion.BookID, completion.UserID, stoppingToken); + } } diff --git a/PlotLine/Services/StoryIntelligenceOperationalServices.cs b/PlotLine/Services/StoryIntelligenceOperationalServices.cs index 26bbe01..e8d0420 100644 --- a/PlotLine/Services/StoryIntelligenceOperationalServices.cs +++ b/PlotLine/Services/StoryIntelligenceOperationalServices.cs @@ -51,6 +51,7 @@ public sealed class StoryIntelligenceEntitlementService(ISubscriptionService sub public interface IStoryIntelligenceCompletionNotificationService { Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default); + Task NotifyIfImportTerminalAsync(int bookId, int userId, CancellationToken cancellationToken = default); } public sealed class StoryIntelligenceCompletionNotificationService( @@ -66,7 +67,12 @@ public sealed class StoryIntelligenceCompletionNotificationService( return; } - var target = await pipelines.TryBeginCompletionNotificationAsync(bookId, run.UserID); + await NotifyIfImportTerminalAsync(bookId, run.UserID, cancellationToken); + } + + public async Task NotifyIfImportTerminalAsync(int bookId, int userId, CancellationToken cancellationToken = default) + { + var target = await pipelines.TryBeginCompletionNotificationAsync(bookId, userId); if (target is null) { return; diff --git a/PlotLine/Services/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs index d276322..ad709ba 100644 --- a/PlotLine/Services/StoryIntelligencePipelineStateService.cs +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -16,6 +16,8 @@ public interface IStoryIntelligencePipelineStateService Task RecordRelationshipImportAsync(int projectId, int bookId); Task RecordKnowledgeImportAsync(int projectId, int bookId); Task RecordNarrativeImportAsync(int projectId, int bookId); + Task CompleteSceneAnalysisIfReadyAsync(int bookId, int userId); + Task RecoverNextReadySceneAnalysisAsync(); Task> ListCommittedRunsByBookAsync(int bookId, int userId); } @@ -185,6 +187,40 @@ public sealed class StoryIntelligencePipelineStateService( }); } + public async Task CompleteSceneAnalysisIfReadyAsync(int bookId, int userId) + { + var completion = await pipelines.TryCompleteSceneAnalysisAsync(bookId, userId); + LogSceneAnalysisCompletion(completion, "completed"); + return completion; + } + + public async Task RecoverNextReadySceneAnalysisAsync() + { + var completion = await pipelines.TryCompleteNextReadySceneAnalysisAsync(); + LogSceneAnalysisCompletion(completion, "recovered"); + return completion; + } + public Task> ListCommittedRunsByBookAsync(int bookId, int userId) => pipelines.ListCommittedRunsByBookAsync(bookId, userId); + + private void LogSceneAnalysisCompletion(StoryIntelligenceSceneAnalysisCompletion? completion, string mode) + { + if (completion is not { CompletedNow: true }) + { + return; + } + + logger.LogInformation( + "Story Intelligence scene analysis {Mode} for book {BookID}. PipelineID={PipelineID} UserID={UserID} Scenes={CompletedScenes}/{TotalExpectedScenes} FailedScenes={FailedScenes} Runs={CompletedRunCount}/{RunCount}", + mode, + completion.BookID, + completion.StoryIntelligenceBookPipelineID, + completion.UserID, + completion.CompletedScenes, + completion.TotalExpectedScenes, + completion.FailedScenes, + completion.CompletedRunCount, + completion.RunCount); + } } diff --git a/PlotLine/Services/StoryIntelligenceProgressNotifier.cs b/PlotLine/Services/StoryIntelligenceProgressNotifier.cs index 75ca2a5..16ea032 100644 --- a/PlotLine/Services/StoryIntelligenceProgressNotifier.cs +++ b/PlotLine/Services/StoryIntelligenceProgressNotifier.cs @@ -7,6 +7,7 @@ namespace PlotLine.Services; public interface IStoryIntelligenceProgressNotifier { Task PublishAsync(StoryIntelligenceRunProgressEvent progress); + Task PublishGlobalAsync(int userId); } public sealed class StoryIntelligenceProgressNotifier( @@ -19,7 +20,13 @@ public sealed class StoryIntelligenceProgressNotifier( await hub.Clients.Group(userGroup) .SendAsync("StoryIntelligenceRunProgressChanged", progress); - var aggregate = await globalProgress.GetForUserAsync(progress.UserID); + await PublishGlobalAsync(progress.UserID); + } + + public async Task PublishGlobalAsync(int userId) + { + var userGroup = StoryIntelligenceHub.UserGroup(userId); + var aggregate = await globalProgress.GetForUserAsync(userId); await hub.Clients.Group(userGroup) .SendAsync("StoryIntelligenceGlobalProgressChanged", aggregate); } diff --git a/PlotLine/Sql/172_Phase26H_StoryIntelligenceSceneAnalysisCompletionBarrier.sql b/PlotLine/Sql/172_Phase26H_StoryIntelligenceSceneAnalysisCompletionBarrier.sql new file mode 100644 index 0000000..fb6cd76 --- /dev/null +++ b/PlotLine/Sql/172_Phase26H_StoryIntelligenceSceneAnalysisCompletionBarrier.sql @@ -0,0 +1,225 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_TryCompleteSceneAnalysis + @BookID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @Now datetime2(7) = SYSUTCDATETIME(); + DECLARE @ImportSessionID int; + DECLARE @ProjectID int; + DECLARE @LastRunID int; + DECLARE @LastRunCreatedUtc datetime2(7); + DECLARE @RunCount int = 0; + DECLARE @CompletedRunCount int = 0; + DECLARE @FailedRunCount int = 0; + DECLARE @ActiveRunCount int = 0; + DECLARE @TotalExpectedScenes int = 0; + DECLARE @CompletedScenes int = 0; + DECLARE @FailedScenes int = 0; + DECLARE @CompletedNow bit = 0; + + BEGIN TRANSACTION; + + SELECT + @ImportSessionID = pipeline.StoryIntelligenceBookPipelineID, + @ProjectID = pipeline.ProjectID, + @LastRunID = pipeline.LastRunID + FROM dbo.StoryIntelligenceBookPipelines pipeline WITH (UPDLOCK, HOLDLOCK) + INNER JOIN dbo.ProjectUserAccess access + ON access.ProjectID = pipeline.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + INNER JOIN dbo.Books book + ON book.BookID = pipeline.BookID + AND book.IsArchived = 0 + INNER JOIN dbo.Projects project + ON project.ProjectID = pipeline.ProjectID + AND project.IsArchived = 0 + WHERE pipeline.BookID = @BookID; + + IF @ImportSessionID IS NULL + BEGIN + ROLLBACK TRANSACTION; + RETURN; + END; + + SELECT @LastRunCreatedUtc = CreatedUtc + FROM dbo.StoryIntelligenceRuns + WHERE StoryIntelligenceRunID = @LastRunID + AND BookID = @BookID + AND UserID = @UserID; + + IF @LastRunCreatedUtc IS NULL + BEGIN + SELECT TOP (1) + @LastRunID = StoryIntelligenceRunID, + @LastRunCreatedUtc = CreatedUtc + FROM dbo.StoryIntelligenceRuns + WHERE BookID = @BookID + AND UserID = @UserID + AND ProjectID = @ProjectID + ORDER BY CreatedUtc DESC, StoryIntelligenceRunID DESC; + END; + + ;WITH ScopedRuns AS + ( + SELECT * + FROM dbo.StoryIntelligenceRuns + WHERE BookID = @BookID + AND UserID = @UserID + AND ProjectID = @ProjectID + AND (@LastRunCreatedUtc IS NULL OR CreatedUtc >= DATEADD(minute, -30, @LastRunCreatedUtc)) + ) + SELECT + @RunCount = COUNT(1), + @CompletedRunCount = SUM(CASE WHEN Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END), + @FailedRunCount = SUM(CASE WHEN Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END), + @ActiveRunCount = SUM(CASE WHEN Status IN (N'Pending', N'Running') THEN 1 ELSE 0 END), + @CompletedScenes = SUM(ISNULL(CompletedScenes, 0)), + @FailedScenes = SUM(ISNULL(FailedScenes, 0)) + FROM ScopedRuns; + + SELECT @TotalExpectedScenes = COUNT(1) + FROM dbo.Scenes scene + INNER JOIN dbo.Chapters chapter + ON chapter.ChapterID = scene.ChapterID + AND chapter.BookID = @BookID + AND chapter.IsArchived = 0 + WHERE scene.IsArchived = 0; + + IF @RunCount > 0 + AND @ActiveRunCount = 0 + AND @FailedRunCount = 0 + AND @TotalExpectedScenes > 0 + AND @CompletedScenes + @FailedScenes >= @TotalExpectedScenes + BEGIN + UPDATE dbo.StoryIntelligenceBookPipelines + SET CurrentStage = N'CharacterReview', + LastCompletedStage = N'SceneImport', + CurrentReviewStage = N'CharacterReview', + Status = N'NeedsReview', + CompletedUtc = NULL, + LastRunID = COALESCE(@LastRunID, LastRunID), + UpdatedUtc = @Now + WHERE StoryIntelligenceBookPipelineID = @ImportSessionID + AND Status = N'InProgress' + AND CurrentStage = N'Chapters'; + + IF @@ROWCOUNT > 0 + SET @CompletedNow = 1; + END; + + COMMIT TRANSACTION; + + IF @CompletedNow = 1 + BEGIN + SELECT + @ImportSessionID AS StoryIntelligenceBookPipelineID, + @ProjectID AS ProjectID, + @BookID AS BookID, + @UserID AS UserID, + @LastRunID AS LastRunID, + N'NeedsReview' AS Status, + N'CharacterReview' AS CurrentStage, + N'SceneImport' AS LastCompletedStage, + N'CharacterReview' AS CurrentReviewStage, + @RunCount AS RunCount, + @CompletedRunCount AS CompletedRunCount, + @FailedRunCount AS FailedRunCount, + @TotalExpectedScenes AS TotalExpectedScenes, + @CompletedScenes AS CompletedScenes, + @FailedScenes AS FailedScenes, + @CompletedNow AS CompletedNow; + END; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_TryCompleteNextReadySceneAnalysis +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @BookID int; + DECLARE @UserID int; + + SELECT TOP (1) + @BookID = pipeline.BookID, + @UserID = latest.UserID + FROM dbo.StoryIntelligenceBookPipelines pipeline + CROSS APPLY + ( + SELECT TOP (1) + run.UserID, + run.CreatedUtc + FROM dbo.StoryIntelligenceRuns run + WHERE run.BookID = pipeline.BookID + AND run.ProjectID = pipeline.ProjectID + ORDER BY run.CreatedUtc DESC, run.StoryIntelligenceRunID DESC + ) latest + WHERE pipeline.Status = N'InProgress' + AND pipeline.CurrentStage = N'Chapters' + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.StoryIntelligenceRuns activeRun + WHERE activeRun.BookID = pipeline.BookID + AND activeRun.ProjectID = pipeline.ProjectID + AND activeRun.UserID = latest.UserID + AND activeRun.CreatedUtc >= DATEADD(minute, -30, latest.CreatedUtc) + AND activeRun.Status IN (N'Pending', N'Running') + ) + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.StoryIntelligenceRuns failedRun + WHERE failedRun.BookID = pipeline.BookID + AND failedRun.ProjectID = pipeline.ProjectID + AND failedRun.UserID = latest.UserID + AND failedRun.CreatedUtc >= DATEADD(minute, -30, latest.CreatedUtc) + AND failedRun.Status IN (N'Failed', N'Cancelled') + ) + AND + ( + SELECT COUNT(1) + FROM dbo.Scenes scene + INNER JOIN dbo.Chapters chapter + ON chapter.ChapterID = scene.ChapterID + AND chapter.BookID = pipeline.BookID + AND chapter.IsArchived = 0 + WHERE scene.IsArchived = 0 + ) > 0 + AND + ( + SELECT SUM(ISNULL(run.CompletedScenes, 0) + ISNULL(run.FailedScenes, 0)) + FROM dbo.StoryIntelligenceRuns run + WHERE run.BookID = pipeline.BookID + AND run.ProjectID = pipeline.ProjectID + AND run.UserID = latest.UserID + AND run.CreatedUtc >= DATEADD(minute, -30, latest.CreatedUtc) + ) >= + ( + SELECT COUNT(1) + FROM dbo.Scenes scene + INNER JOIN dbo.Chapters chapter + ON chapter.ChapterID = scene.ChapterID + AND chapter.BookID = pipeline.BookID + AND chapter.IsArchived = 0 + WHERE scene.IsArchived = 0 + ) + ORDER BY pipeline.UpdatedUtc, pipeline.StoryIntelligenceBookPipelineID; + + IF @BookID IS NULL OR @UserID IS NULL + RETURN; + + EXEC dbo.StoryIntelligenceBookPipeline_TryCompleteSceneAnalysis + @BookID = @BookID, + @UserID = @UserID; +END; +GO diff --git a/PlotLine/wwwroot/js/story-intelligence-global-progress.js b/PlotLine/wwwroot/js/story-intelligence-global-progress.js index 342bfb7..72798cb 100644 --- a/PlotLine/wwwroot/js/story-intelligence-global-progress.js +++ b/PlotLine/wwwroot/js/story-intelligence-global-progress.js @@ -44,7 +44,8 @@ const render = (progress) => { const books = progress?.books || progress?.Books || []; if (!books.length) { - strip.dataset.connection = "waiting"; + strip.hidden = true; + strip.dataset.connection = "complete"; return; }