Complete Story Intelligence scene analysis pipeline
This commit is contained in:
parent
a28495865a
commit
e9f15e076b
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user