Complete Story Intelligence scene analysis pipeline

This commit is contained in:
Nick Beckley 2026-08-29 09:10:38 +00:00
parent a28495865a
commit e9f15e076b
10 changed files with 390 additions and 7 deletions

View File

@ -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 remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps),
("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots), ("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots),
("Story Intelligence live global progress publishes authoritative scene aggregates", StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates), ("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 active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip),
("Story Intelligence book actions do not open review centre while running", StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning) ("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(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(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(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(!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."); 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(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("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("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."); 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(sceneFailureBlock.Contains("failedScenes: failedScenes", StringComparison.Ordinal), "Terminal scene failure should persist the authoritative failed-scene count.");
Assert(globalService.Contains("Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)", StringComparison.Ordinal), "Background publishing should be able to retrieve the same user-scoped aggregate used by page load."); Assert(globalService.Contains("Task<StoryIntelligenceGlobalProgressViewModel> 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(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.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."); 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."); 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() static void StoryIntelligenceRecoveredProviderFailuresStayOperational()
{ {
var requestCount = 0; var requestCount = 0;

View File

@ -12,6 +12,8 @@ public interface IStoryIntelligencePipelineRepository
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId); Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request); Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteSceneAnalysisAsync(int bookId, int userId);
Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteNextReadySceneAnalysisAsync();
Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId); Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId);
} }
@ -83,6 +85,23 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
return rows.ToList(); return rows.ToList();
} }
public async Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteSceneAnalysisAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceSceneAnalysisCompletion>(
"dbo.StoryIntelligenceBookPipeline_TryCompleteSceneAnalysis",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteNextReadySceneAnalysisAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceSceneAnalysisCompletion>(
"dbo.StoryIntelligenceBookPipeline_TryCompleteNextReadySceneAnalysis",
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId) public async Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -87,6 +87,26 @@ public sealed class StoryIntelligenceImportNotificationTarget
public string NotificationKind { get; init; } = "Success"; 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 sealed class StoryIntelligenceBookPipelineSaveRequest
{ {
public int ProjectID { get; init; } public int ProjectID { get; init; }

View File

@ -29,6 +29,7 @@ public sealed class PersistedStoryIntelligenceRunner(
IStoryIntelligenceProgressNotifier notifier, IStoryIntelligenceProgressNotifier notifier,
IStoryMemoryService storyMemory, IStoryMemoryService storyMemory,
ICharacterIntelligenceService characterIntelligence, ICharacterIntelligenceService characterIntelligence,
IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceEntitlementService entitlement, IStoryIntelligenceEntitlementService entitlement,
IStoryIntelligenceCompletionNotificationService completionNotifications, IStoryIntelligenceCompletionNotificationService completionNotifications,
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
@ -457,7 +458,7 @@ public sealed class PersistedStoryIntelligenceRunner(
completedScenes: completedScenes, completedScenes: completedScenes,
failedScenes: failedScenes, failedScenes: failedScenes,
totalTokens: totals.TotalTokens); totalTokens: totals.TotalTokens);
await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken); await CompleteBookSceneAnalysisIfReadyAsync(run, cancellationToken);
finalisationStopwatch.Stop(); finalisationStopwatch.Stop();
logger.LogInformation( 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}", "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( private Task PublishAsync(
StoryIntelligenceQueuedRun run, StoryIntelligenceQueuedRun run,
string eventType, string eventType,

View File

@ -50,8 +50,25 @@ public sealed class PersistedStoryIntelligenceWorker(
if (!processedRun) if (!processedRun)
{ {
await RecoverReadySceneAnalysisAsync(stoppingToken);
await Task.Delay(pollDelay, stoppingToken); await Task.Delay(pollDelay, stoppingToken);
} }
} }
} }
private async Task RecoverReadySceneAnalysisAsync(CancellationToken stoppingToken)
{
using var scope = scopeFactory.CreateScope();
var pipelineState = scope.ServiceProvider.GetRequiredService<IStoryIntelligencePipelineStateService>();
var notifier = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceProgressNotifier>();
var completionNotifications = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceCompletionNotificationService>();
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);
}
} }

View File

@ -51,6 +51,7 @@ public sealed class StoryIntelligenceEntitlementService(ISubscriptionService sub
public interface IStoryIntelligenceCompletionNotificationService public interface IStoryIntelligenceCompletionNotificationService
{ {
Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default); Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default);
Task NotifyIfImportTerminalAsync(int bookId, int userId, CancellationToken cancellationToken = default);
} }
public sealed class StoryIntelligenceCompletionNotificationService( public sealed class StoryIntelligenceCompletionNotificationService(
@ -66,7 +67,12 @@ public sealed class StoryIntelligenceCompletionNotificationService(
return; 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) if (target is null)
{ {
return; return;

View File

@ -16,6 +16,8 @@ public interface IStoryIntelligencePipelineStateService
Task RecordRelationshipImportAsync(int projectId, int bookId); Task RecordRelationshipImportAsync(int projectId, int bookId);
Task RecordKnowledgeImportAsync(int projectId, int bookId); Task RecordKnowledgeImportAsync(int projectId, int bookId);
Task RecordNarrativeImportAsync(int projectId, int bookId); Task RecordNarrativeImportAsync(int projectId, int bookId);
Task<StoryIntelligenceSceneAnalysisCompletion?> CompleteSceneAnalysisIfReadyAsync(int bookId, int userId);
Task<StoryIntelligenceSceneAnalysisCompletion?> RecoverNextReadySceneAnalysisAsync();
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
} }
@ -185,6 +187,40 @@ public sealed class StoryIntelligencePipelineStateService(
}); });
} }
public async Task<StoryIntelligenceSceneAnalysisCompletion?> CompleteSceneAnalysisIfReadyAsync(int bookId, int userId)
{
var completion = await pipelines.TryCompleteSceneAnalysisAsync(bookId, userId);
LogSceneAnalysisCompletion(completion, "completed");
return completion;
}
public async Task<StoryIntelligenceSceneAnalysisCompletion?> RecoverNextReadySceneAnalysisAsync()
{
var completion = await pipelines.TryCompleteNextReadySceneAnalysisAsync();
LogSceneAnalysisCompletion(completion, "recovered");
return completion;
}
public Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId) public Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId)
=> pipelines.ListCommittedRunsByBookAsync(bookId, 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);
}
} }

View File

@ -7,6 +7,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceProgressNotifier public interface IStoryIntelligenceProgressNotifier
{ {
Task PublishAsync(StoryIntelligenceRunProgressEvent progress); Task PublishAsync(StoryIntelligenceRunProgressEvent progress);
Task PublishGlobalAsync(int userId);
} }
public sealed class StoryIntelligenceProgressNotifier( public sealed class StoryIntelligenceProgressNotifier(
@ -19,7 +20,13 @@ public sealed class StoryIntelligenceProgressNotifier(
await hub.Clients.Group(userGroup) await hub.Clients.Group(userGroup)
.SendAsync("StoryIntelligenceRunProgressChanged", progress); .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) await hub.Clients.Group(userGroup)
.SendAsync("StoryIntelligenceGlobalProgressChanged", aggregate); .SendAsync("StoryIntelligenceGlobalProgressChanged", aggregate);
} }

View File

@ -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

View File

@ -44,7 +44,8 @@
const render = (progress) => { const render = (progress) => {
const books = progress?.books || progress?.Books || []; const books = progress?.books || progress?.Books || [];
if (!books.length) { if (!books.length) {
strip.dataset.connection = "waiting"; strip.hidden = true;
strip.dataset.connection = "complete";
return; return;
} }