Add concurrent story intelligence execution

This commit is contained in:
Nick Beckley 2026-08-28 19:21:31 +00:00
parent 6d2340ae46
commit 96343138ea
16 changed files with 718 additions and 16 deletions

View File

@ -230,7 +230,8 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence review posts do not use success interstitials", StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials), ("Story Intelligence review posts do not use success interstitials", StoryIntelligenceReviewPostsDoNotUseSuccessInterstitials),
("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard), ("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard),
("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering), ("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls) ("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls),
("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution)
}; };
foreach (var test in tests) foreach (var test in tests)
@ -239,6 +240,36 @@ foreach (var test in tests)
Console.WriteLine($"PASS {test.Name}"); Console.WriteLine($"PASS {test.Name}");
} }
static void StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution()
{
var root = PlotLineRoot();
var options = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceModels.cs"));
var worker = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceWorker.cs"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/169_Phase26E_StoryIntelligenceConcurrentExecution.sql"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
var progressScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
var openAiClient = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceOpenAIInfrastructure.cs"));
Assert(options.Contains("MaxGlobalConcurrentAiRequests", StringComparison.Ordinal), "Story Intelligence concurrency must be configurable.");
Assert(worker.Contains("Task.WhenAll(workers)", StringComparison.Ordinal), "Persisted runner should run bounded worker loops.");
Assert(worker.Contains("MaxConcurrentAiRequestsPerBook", StringComparison.Ordinal), "Worker logs/config should include the per-book cap.");
Assert(runner.Contains("ClaimNextPendingFairAsync", StringComparison.Ordinal), "Runner should use the fair persisted claim procedure.");
Assert(runner.Contains("Story Intelligence scene timing", StringComparison.Ordinal), "Runner should emit scene timing instrumentation.");
Assert(runner.Contains("Story Intelligence finalisation timing", StringComparison.Ordinal), "Runner should emit finalisation timing instrumentation.");
Assert(repository.Contains("StoryIntelligenceRun_ClaimNextPendingFair", StringComparison.Ordinal), "Fair claims should use a stored procedure.");
Assert(repository.Contains("StoryIntelligenceRun_ListActiveBookSummaryForUser", StringComparison.Ordinal), "Global progress should use a stored procedure.");
Assert(migration.Contains("@MaxConcurrentRunsPerBook", StringComparison.Ordinal), "Migration should enforce a per-book cap.");
Assert(migration.Contains("@MaxConcurrentRunsPerUser", StringComparison.Ordinal), "Migration should enforce a per-user cap.");
Assert(migration.Contains("Returned to the Story Intelligence queue after an expired worker claim", StringComparison.Ordinal), "Migration should recover expired worker claims.");
Assert(controller.Contains("Progress is shown in the page header", StringComparison.Ordinal), "Book analysis should return to normal UI instead of forcing the progress page.");
Assert(layout.Contains("data-story-intelligence-global-strip", StringComparison.Ordinal), "Shared layout should render the global progress strip.");
Assert(progressScript.Contains("StoryIntelligenceRunProgressChanged", StringComparison.Ordinal), "Progress strip should listen for persisted run SignalR events.");
Assert(openAiClient.Contains("RetryConditionHeaderValue", StringComparison.Ordinal), "OpenAI retry should honour Retry-After metadata.");
}
static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume() static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume()
{ {
var root = PlotLineRoot(); var root = PlotLineRoot();

View File

@ -148,7 +148,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
var existing = await storyIntelligence.ResumeBookAsync(bookId); var existing = await storyIntelligence.ResumeBookAsync(bookId);
if (existing is not null) if (existing is not null)
{ {
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = existing.BatchID }); TempData["ArchiveMessage"] = "Story Intelligence is already analysing this book. Progress is shown in the page header.";
return RedirectToAction("Details", "Books", new { id = bookId });
} }
try try
@ -160,7 +161,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction("Details", "Books", new { id = bookId }); return RedirectToAction("Details", "Books", new { id = bookId });
} }
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID }); TempData["ArchiveMessage"] = "Story Intelligence analysis has started. You can keep working while PlotDirector analyses the book in the background.";
return RedirectToAction("Details", "Books", new { id = bookId });
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {

View File

@ -9,6 +9,7 @@ public interface IStoryIntelligenceResultRepository
Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request); Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request);
Task<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request); Task<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request);
Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync(); Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync();
Task<StoryIntelligenceQueuedRun?> ClaimNextPendingFairAsync(int maxConcurrentRunsPerBook, int maxConcurrentRunsPerUser, int claimLeaseMinutes);
Task UpdateProgressAsync( Task UpdateProgressAsync(
int runId, int runId,
string? status = null, string? status = null,
@ -31,6 +32,7 @@ public interface IStoryIntelligenceResultRepository
Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene); Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene);
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync(); Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceActiveBookRunSummary>> ListActiveBookRunSummariesForUserAsync(int userId);
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId); Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId); Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId); Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
@ -321,6 +323,23 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<StoryIntelligenceQueuedRun?> ClaimNextPendingFairAsync(
int maxConcurrentRunsPerBook,
int maxConcurrentRunsPerUser,
int claimLeaseMinutes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceQueuedRun>(
"dbo.StoryIntelligenceRun_ClaimNextPendingFair",
new
{
MaxConcurrentRunsPerBook = Math.Max(1, maxConcurrentRunsPerBook),
MaxConcurrentRunsPerUser = Math.Max(1, maxConcurrentRunsPerUser),
ClaimLeaseMinutes = Math.Max(15, claimLeaseMinutes)
},
commandType: CommandType.StoredProcedure);
}
public async Task UpdateProgressAsync( public async Task UpdateProgressAsync(
int runId, int runId,
string? status = null, string? status = null,
@ -490,6 +509,16 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
return rows.ToList(); return rows.ToList();
} }
public async Task<IReadOnlyList<StoryIntelligenceActiveBookRunSummary>> ListActiveBookRunSummariesForUserAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceActiveBookRunSummary>(
"dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId) public async Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -114,6 +114,11 @@ public sealed class StoryIntelligenceOptions
public int MaxOutputTokens { get; init; } = 4000; public int MaxOutputTokens { get; init; } = 4000;
public int? SceneIntelligenceMaxOutputTokens { get; init; } public int? SceneIntelligenceMaxOutputTokens { get; init; }
public int TimeoutSeconds { get; init; } = 120; public int TimeoutSeconds { get; init; } = 120;
public int MaxGlobalConcurrentAiRequests { get; init; } = 4;
public int MaxConcurrentAiRequestsPerBook { get; init; } = 2;
public int MaxConcurrentAiRequestsPerUser { get; init; } = 3;
public int QueuePollingIntervalSeconds { get; init; } = 3;
public int ClaimLeaseMinutes { get; init; } = 90;
public string EffectiveModel => FirstConfigured(Model); public string EffectiveModel => FirstConfigured(Model);
public string EffectiveChapterStructureModel => FirstConfigured(ChapterStructureModel, Model); public string EffectiveChapterStructureModel => FirstConfigured(ChapterStructureModel, Model);

View File

@ -340,6 +340,31 @@ public sealed class StoryIntelligenceQueuedRun
public bool CancellationRequested => CancellationRequestedUtc.HasValue; public bool CancellationRequested => CancellationRequestedUtc.HasValue;
} }
public sealed class StoryIntelligenceActiveBookRunSummary
{
public int UserID { get; init; }
public int? ProjectID { get; init; }
public string? ProjectTitle { get; init; }
public int? BookID { get; init; }
public string? BookTitle { get; init; }
public string? BookSubtitle { get; init; }
public string BookDisplayTitle => BookTitleFormatter.DisplayTitle(BookTitle, BookSubtitle);
public int RunCount { get; init; }
public int PendingRunCount { get; init; }
public int RunningRunCount { get; init; }
public int CompletedRunCount { get; init; }
public int FailedRunCount { get; init; }
public int TotalDetectedScenes { get; init; }
public int CompletedScenes { get; init; }
public int FailedScenes { get; init; }
public long TotalDurationMs { get; init; }
public DateTime FirstCreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0;
public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0;
}
public sealed class StoryIntelligenceSourceOption public sealed class StoryIntelligenceSourceOption
{ {
public int Value { get; init; } public int Value { get; init; }

View File

@ -236,6 +236,7 @@ public class Program
builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>(); builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<IStoryIntelligenceGlobalProgressService, StoryIntelligenceGlobalProgressService>();
builder.Services.AddScoped<IStoryIntelligenceEntitlementService, StoryIntelligenceEntitlementService>(); builder.Services.AddScoped<IStoryIntelligenceEntitlementService, StoryIntelligenceEntitlementService>();
builder.Services.AddScoped<IStoryIntelligenceCompletionNotificationService, StoryIntelligenceCompletionNotificationService>(); builder.Services.AddScoped<IStoryIntelligenceCompletionNotificationService, StoryIntelligenceCompletionNotificationService>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();

View File

@ -8,7 +8,7 @@ namespace PlotLine.Services;
public interface IPersistedStoryIntelligenceRunner public interface IPersistedStoryIntelligenceRunner
{ {
Task ProcessNextAsync(CancellationToken cancellationToken); Task<bool> ProcessNextAsync(CancellationToken cancellationToken);
} }
public sealed class PersistedStoryIntelligenceRunner( public sealed class PersistedStoryIntelligenceRunner(
@ -45,15 +45,19 @@ public sealed class PersistedStoryIntelligenceRunner(
private readonly StoryIntelligenceOptions settings = options.Value; private readonly StoryIntelligenceOptions settings = options.Value;
private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value; private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value;
public async Task ProcessNextAsync(CancellationToken cancellationToken) public async Task<bool> ProcessNextAsync(CancellationToken cancellationToken)
{ {
var run = await repository.ClaimNextPendingAsync(); var run = await repository.ClaimNextPendingFairAsync(
settings.MaxConcurrentAiRequestsPerBook,
settings.MaxConcurrentAiRequestsPerUser,
settings.ClaimLeaseMinutes);
if (run is null) if (run is null)
{ {
return; return false;
} }
await ProcessAsync(run, cancellationToken); await ProcessAsync(run, cancellationToken);
return true;
} }
private async Task ProcessAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken) private async Task ProcessAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken)
@ -68,9 +72,21 @@ public sealed class PersistedStoryIntelligenceRunner(
var completedScenes = 0; var completedScenes = 0;
var hasWarnings = false; var hasWarnings = false;
var currentFailureStage = StoryIntelligenceFailureStages.DocumentRead; var currentFailureStage = StoryIntelligenceFailureStages.DocumentRead;
var queueWaitMs = Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.CreatedUtc).TotalMilliseconds));
try try
{ {
logger.LogInformation(
"Story Intelligence run claimed. RunID={RunID} UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} QueueWaitMs={QueueWaitMs} StartedUtc={StartedUtc} ClaimUpdatedUtc={UpdatedUtc}",
run.StoryIntelligenceRunID,
run.UserID,
run.ProjectID,
run.BookID,
run.ChapterID,
queueWaitMs,
run.StartedUtc,
run.UpdatedUtc);
await PublishAsync( await PublishAsync(
run, run,
"Import started", "Import started",
@ -79,6 +95,7 @@ public sealed class PersistedStoryIntelligenceRunner(
"Story Intelligence has started reading this chapter.", "Story Intelligence has started reading this chapter.",
stopwatch.ElapsedMilliseconds); stopwatch.ElapsedMilliseconds);
var preparationStopwatch = Stopwatch.StartNew();
await entitlement.EnsureCanExecuteAsync(run.UserID); await entitlement.EnsureCanExecuteAsync(run.UserID);
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken); await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
@ -118,15 +135,21 @@ public sealed class PersistedStoryIntelligenceRunner(
hasWarnings = mode.HasWarnings; hasWarnings = mode.HasWarnings;
failedScenes += sceneBlocks.Count(block => !block.SplitValid); failedScenes += sceneBlocks.Count(block => !block.SplitValid);
currentFailureStage = StoryIntelligenceFailureStages.SceneSplit; currentFailureStage = StoryIntelligenceFailureStages.SceneSplit;
preparationStopwatch.Stop();
logger.LogInformation( logger.LogInformation(
"Story Intelligence mode: {Mode}. RunID={RunID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneCount={SceneCount}", "Story Intelligence preparation timing. Mode={Mode} RunID={RunID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneCount={SceneCount} QueueWaitMs={QueueWaitMs} PreparationMs={PreparationMs} PromptVersions={PromptVersions} ChapterModel={ChapterModel} SceneModel={SceneModel}",
mode.Mode, mode.Mode,
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
run.ProjectID, run.ProjectID,
run.BookID, run.BookID,
run.ChapterID, run.ChapterID,
sceneBlocks.Count); sceneBlocks.Count,
queueWaitMs,
preparationStopwatch.ElapsedMilliseconds,
run.PromptVersionsSummary,
stageModels.ChapterStructureModel,
stageModels.SceneIntelligenceModel);
await repository.UpdateProgressAsync( await repository.UpdateProgressAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
@ -202,15 +225,21 @@ public sealed class PersistedStoryIntelligenceRunner(
try try
{ {
var sceneStopwatch = Stopwatch.StartNew();
var promptStopwatch = Stopwatch.StartNew();
currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence; currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence;
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText); var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText);
promptStopwatch.Stop();
var openAiStopwatch = Stopwatch.StartNew();
var sceneClientResult = await client.ExecutePromptAsync( var sceneClientResult = await client.ExecutePromptAsync(
completedScenePrompt, completedScenePrompt,
scenePromptVersion, scenePromptVersion,
cancellationToken, cancellationToken,
stageModels.SceneIntelligenceModel, stageModels.SceneIntelligenceModel,
SceneIntelligenceMaxOutputTokens()); SceneIntelligenceMaxOutputTokens());
openAiStopwatch.Stop();
totals.Add(sceneClientResult, pricing); totals.Add(sceneClientResult, pricing);
var parseStopwatch = Stopwatch.StartNew();
var sceneAttempt = await ParseAiJsonWithRetryAsync<SceneIntelligenceScene>( var sceneAttempt = await ParseAiJsonWithRetryAsync<SceneIntelligenceScene>(
completedScenePrompt, completedScenePrompt,
scenePromptVersion, scenePromptVersion,
@ -219,6 +248,8 @@ public sealed class PersistedStoryIntelligenceRunner(
totals, totals,
"Scene Intelligence", "Scene Intelligence",
cancellationToken); cancellationToken);
parseStopwatch.Stop();
var validationStopwatch = Stopwatch.StartNew();
var validation = sceneValidator.Validate(sceneAttempt.Parsed); var validation = sceneValidator.Validate(sceneAttempt.Parsed);
foreach (var warning in sceneAttempt.Warnings) foreach (var warning in sceneAttempt.Warnings)
{ {
@ -232,7 +263,9 @@ public sealed class PersistedStoryIntelligenceRunner(
} }
hasWarnings = hasWarnings || validation.Warnings.Count > 0; hasWarnings = hasWarnings || validation.Warnings.Count > 0;
validationStopwatch.Stop();
var persistenceStopwatch = Stopwatch.StartNew();
var sceneResultId = await repository.SaveSceneResultAsync( var sceneResultId = await repository.SaveSceneResultAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
chapterResultId, chapterResultId,
@ -258,6 +291,7 @@ public sealed class PersistedStoryIntelligenceRunner(
TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens), TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens),
DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds) DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds)
}); });
persistenceStopwatch.Stop();
var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null; var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null;
logger.LogInformation( logger.LogInformation(
"Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status analysed.", "Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status analysed.",
@ -266,6 +300,7 @@ public sealed class PersistedStoryIntelligenceRunner(
run.ChapterNumber, run.ChapterNumber,
block.TemporarySceneNumber, block.TemporarySceneNumber,
sceneResultId); sceneResultId);
var postProcessingStopwatch = Stopwatch.StartNew();
if (importSessionId.HasValue) if (importSessionId.HasValue)
{ {
try try
@ -309,6 +344,33 @@ public sealed class PersistedStoryIntelligenceRunner(
logger.LogError(ex, "Character Intelligence profile update failed for import session {ImportSessionId}, scene result {SceneResultId}.", importSessionId, sceneResultId); logger.LogError(ex, "Character Intelligence profile update failed for import session {ImportSessionId}, scene result {SceneResultId}.", importSessionId, sceneResultId);
} }
} }
postProcessingStopwatch.Stop();
sceneStopwatch.Stop();
logger.LogInformation(
"Story Intelligence scene timing. RunID={RunID} UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} SceneNumber={SceneNumber} QueueWaitMs={QueueWaitMs} PromptPrepareMs={PromptPrepareMs} OpenAiMs={OpenAiMs} ParseValidationMs={ParseValidationMs} ValidationMs={ValidationMs} PersistenceMs={PersistenceMs} PostProcessingMs={PostProcessingMs} SceneElapsedMs={SceneElapsedMs} Model={Model} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} TotalTokens={TotalTokens} StartedUtc={StartedUtc} CompletedUtc={CompletedUtc}",
run.StoryIntelligenceRunID,
run.UserID,
run.ProjectID,
run.BookID,
run.ChapterID,
block.SceneID,
block.TemporarySceneNumber,
queueWaitMs,
promptStopwatch.ElapsedMilliseconds,
openAiStopwatch.ElapsedMilliseconds,
parseStopwatch.ElapsedMilliseconds,
validationStopwatch.ElapsedMilliseconds,
persistenceStopwatch.ElapsedMilliseconds,
postProcessingStopwatch.ElapsedMilliseconds,
sceneStopwatch.ElapsedMilliseconds,
sceneClientResult.Model,
sceneClientResult.RetryCount + (sceneAttempt.RetryAttempted ? 1 : 0),
sceneAttempt.InputTokens,
sceneAttempt.OutputTokens,
SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens),
DateTime.UtcNow.AddMilliseconds(-sceneStopwatch.ElapsedMilliseconds),
DateTime.UtcNow);
completedScenes++; completedScenes++;
await PublishAsync( await PublishAsync(
@ -371,6 +433,7 @@ public sealed class PersistedStoryIntelligenceRunner(
? StoryIntelligenceRunStatuses.CompletedWithWarnings ? StoryIntelligenceRunStatuses.CompletedWithWarnings
: StoryIntelligenceRunStatuses.Completed; : StoryIntelligenceRunStatuses.Completed;
var finalisationStopwatch = Stopwatch.StartNew();
await repository.CompleteRunAsync( await repository.CompleteRunAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
finalStatus, finalStatus,
@ -394,6 +457,21 @@ public sealed class PersistedStoryIntelligenceRunner(
failedScenes: failedScenes, failedScenes: failedScenes,
totalTokens: totals.TotalTokens); totalTokens: totals.TotalTokens);
await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken); await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken);
finalisationStopwatch.Stop();
logger.LogInformation(
"Story Intelligence finalisation timing. RunID={RunID} UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} Status={Status} SceneCount={SceneCount} CompletedScenes={CompletedScenes} FailedScenes={FailedScenes} FinalisationMs={FinalisationMs} TotalRunMs={TotalRunMs} TotalTokens={TotalTokens}",
run.StoryIntelligenceRunID,
run.UserID,
run.ProjectID,
run.BookID,
run.ChapterID,
finalStatus,
sceneBlocks.Count,
completedScenes,
failedScenes,
finalisationStopwatch.ElapsedMilliseconds,
stopwatch.ElapsedMilliseconds,
totals.TotalTokens);
} }
catch (StoryIntelligenceRunCancelledException) catch (StoryIntelligenceRunCancelledException)
{ {

View File

@ -1,19 +1,43 @@
using Microsoft.Extensions.Options;
using PlotLine.Models;
namespace PlotLine.Services; namespace PlotLine.Services;
public sealed class PersistedStoryIntelligenceWorker( public sealed class PersistedStoryIntelligenceWorker(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IOptions<StoryIntelligenceOptions> options,
ILogger<PersistedStoryIntelligenceWorker> logger) : BackgroundService ILogger<PersistedStoryIntelligenceWorker> logger) : BackgroundService
{ {
private readonly StoryIntelligenceOptions settings = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); var workerCount = Math.Clamp(settings.MaxGlobalConcurrentAiRequests, 1, 24);
logger.LogInformation(
"Starting {WorkerCount} persisted Story Intelligence worker loop(s). MaxPerBook={MaxPerBook} MaxPerUser={MaxPerUser} PollSeconds={PollSeconds} ClaimLeaseMinutes={ClaimLeaseMinutes}",
workerCount,
Math.Max(1, settings.MaxConcurrentAiRequestsPerBook),
Math.Max(1, settings.MaxConcurrentAiRequestsPerUser),
Math.Max(1, settings.QueuePollingIntervalSeconds),
Math.Max(15, settings.ClaimLeaseMinutes));
var workers = Enumerable.Range(1, workerCount)
.Select(workerNumber => RunWorkerLoopAsync(workerNumber, stoppingToken))
.ToArray();
await Task.WhenAll(workers);
}
private async Task RunWorkerLoopAsync(int workerNumber, CancellationToken stoppingToken)
{
var pollDelay = TimeSpan.FromSeconds(Math.Max(1, settings.QueuePollingIntervalSeconds));
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
var processedRun = false;
try try
{ {
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var runner = scope.ServiceProvider.GetRequiredService<IPersistedStoryIntelligenceRunner>(); var runner = scope.ServiceProvider.GetRequiredService<IPersistedStoryIntelligenceRunner>();
await runner.ProcessNextAsync(stoppingToken); processedRun = await runner.ProcessNextAsync(stoppingToken);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
@ -21,10 +45,13 @@ public sealed class PersistedStoryIntelligenceWorker(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Persisted Story Intelligence worker failed while processing the next queued run."); logger.LogError(ex, "Persisted Story Intelligence worker {WorkerNumber} failed while processing the next queued run.", workerNumber);
} }
await timer.WaitForNextTickAsync(stoppingToken); if (!processedRun)
{
await Task.Delay(pollDelay, stoppingToken);
}
} }
} }
} }

View File

@ -0,0 +1,44 @@
using PlotLine.Data;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceGlobalProgressService
{
Task<StoryIntelligenceGlobalProgressViewModel> GetForCurrentUserAsync();
}
public sealed class StoryIntelligenceGlobalProgressService(
ICurrentUserService currentUser,
IStoryIntelligenceResultRepository runs) : IStoryIntelligenceGlobalProgressService
{
public async Task<StoryIntelligenceGlobalProgressViewModel> GetForCurrentUserAsync()
{
if (currentUser.UserId is not int userId)
{
return new StoryIntelligenceGlobalProgressViewModel();
}
var summaries = await runs.ListActiveBookRunSummariesForUserAsync(userId);
return new StoryIntelligenceGlobalProgressViewModel
{
Books = summaries.Select(summary => new StoryIntelligenceGlobalProgressBookViewModel
{
ProjectID = summary.ProjectID,
BookID = summary.BookID,
BookTitle = string.IsNullOrWhiteSpace(summary.BookDisplayTitle) ? "Book" : summary.BookDisplayTitle,
RunCount = summary.RunCount,
PendingRunCount = summary.PendingRunCount,
RunningRunCount = summary.RunningRunCount,
CompletedRunCount = summary.CompletedRunCount,
FailedRunCount = summary.FailedRunCount,
TotalDetectedScenes = summary.TotalDetectedScenes,
CompletedScenes = summary.CompletedScenes,
FailedScenes = summary.FailedScenes,
TotalDurationMs = summary.TotalDurationMs,
UpdatedUtc = summary.UpdatedUtc
})
.ToList()
};
}
}

View File

@ -290,6 +290,8 @@ public sealed class StoryIntelligenceClient(
} }
retryCount++; retryCount++;
await DelayForRetryAsync(attempt, cancellationToken, response.Headers.RetryAfter);
continue;
} }
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
@ -309,7 +311,6 @@ public sealed class StoryIntelligenceClient(
continue; continue;
} }
await DelayForRetryAsync(attempt, cancellationToken);
} }
throw new InvalidOperationException("OpenAI request failed after retry attempts."); throw new InvalidOperationException("OpenAI request failed after retry attempts.");
@ -327,8 +328,20 @@ public sealed class StoryIntelligenceClient(
=> responseText.Contains("\"insufficient_quota\"", StringComparison.OrdinalIgnoreCase) => responseText.Contains("\"insufficient_quota\"", StringComparison.OrdinalIgnoreCase)
|| responseText.Contains("exceeded your current quota", StringComparison.OrdinalIgnoreCase); || responseText.Contains("exceeded your current quota", StringComparison.OrdinalIgnoreCase);
private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken) private static Task DelayForRetryAsync(
=> Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken); int attempt,
CancellationToken cancellationToken,
RetryConditionHeaderValue? retryAfter = null)
{
var retryAfterDelay = retryAfter?.Delta
?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : (TimeSpan?)null);
var exponentialDelay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
var delay = retryAfterDelay.HasValue && retryAfterDelay.Value > TimeSpan.Zero
? retryAfterDelay.Value
: exponentialDelay;
var cappedDelay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds, 60));
return Task.Delay(cappedDelay, cancellationToken);
}
private static string TrimForLog(string value) private static string TrimForLog(string value)
=> string.IsNullOrWhiteSpace(value) || value.Length <= 500 ? value : value[..500]; => string.IsNullOrWhiteSpace(value) || value.Length <= 500 ? value : value[..500];

View File

@ -0,0 +1,158 @@
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ClaimNextPendingFair
@MaxConcurrentRunsPerBook int = 2,
@MaxConcurrentRunsPerUser int = 3,
@ClaimLeaseMinutes int = 90
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @Now datetime2(7) = SYSUTCDATETIME();
DECLARE @SafeMaxPerBook int = CASE WHEN ISNULL(@MaxConcurrentRunsPerBook, 0) < 1 THEN 1 ELSE @MaxConcurrentRunsPerBook END;
DECLARE @SafeMaxPerUser int = CASE WHEN ISNULL(@MaxConcurrentRunsPerUser, 0) < 1 THEN 1 ELSE @MaxConcurrentRunsPerUser END;
DECLARE @SafeClaimLeaseMinutes int = CASE WHEN ISNULL(@ClaimLeaseMinutes, 0) < 15 THEN 15 ELSE @ClaimLeaseMinutes END;
DECLARE @StoryIntelligenceRunID int;
BEGIN TRANSACTION;
UPDATE dbo.StoryIntelligenceRuns
SET Status = N'Pending',
CurrentStage = N'Pending',
CurrentMessage = N'Returned to the Story Intelligence queue after an expired worker claim.',
StartedUtc = NULL,
UpdatedUtc = @Now
WHERE Status = N'Running'
AND CancellationRequestedUtc IS NULL
AND UpdatedUtc < DATEADD(minute, -@SafeClaimLeaseMinutes, @Now);
;WITH PendingRuns AS
(
SELECT
pending.StoryIntelligenceRunID,
pending.CreatedUtc,
pending.UserID,
pending.BookID,
ActiveForBook = (
SELECT COUNT_BIG(1)
FROM dbo.StoryIntelligenceRuns activeBook
WHERE activeBook.Status = N'Running'
AND activeBook.BookID = pending.BookID
),
ActiveForUser = (
SELECT COUNT_BIG(1)
FROM dbo.StoryIntelligenceRuns activeUser
WHERE activeUser.Status = N'Running'
AND activeUser.UserID = pending.UserID
)
FROM dbo.StoryIntelligenceRuns pending WITH (UPDLOCK, READPAST)
WHERE pending.Status = N'Pending'
AND (
pending.BookID IS NULL
OR (
SELECT COUNT_BIG(1)
FROM dbo.StoryIntelligenceRuns activeBook
WHERE activeBook.Status = N'Running'
AND activeBook.BookID = pending.BookID
) < @SafeMaxPerBook
)
AND (
SELECT COUNT_BIG(1)
FROM dbo.StoryIntelligenceRuns activeUser
WHERE activeUser.Status = N'Running'
AND activeUser.UserID = pending.UserID
) < @SafeMaxPerUser
)
SELECT TOP (1) @StoryIntelligenceRunID = StoryIntelligenceRunID
FROM PendingRuns
ORDER BY
ActiveForBook,
ActiveForUser,
CreatedUtc,
StoryIntelligenceRunID;
IF @StoryIntelligenceRunID IS NOT NULL
BEGIN
UPDATE dbo.StoryIntelligenceRuns
SET Status = N'Running',
StartedUtc = @Now,
CurrentStage = N'ChapterStructure',
CurrentMessage = N'Running Chapter Structure analysis.',
UpdatedUtc = @Now
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status = N'Pending';
END;
COMMIT TRANSACTION;
IF @StoryIntelligenceRunID IS NULL
BEGIN
SELECT TOP (0)
StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel,
SourceText, SourceWordCount, SourceCharacterCount, SourceParagraphCount, SourceChapterCount, PromptVersion,
PromptVersionsSummary, KnownCharactersJson, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens,
TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD,
ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes,
CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc
FROM dbo.StoryIntelligenceRuns;
RETURN;
END;
SELECT
StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel,
SourceText, SourceWordCount, SourceCharacterCount, SourceParagraphCount, SourceChapterCount, PromptVersion,
PromptVersionsSummary, KnownCharactersJson, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens,
TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD,
ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes,
CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc
FROM dbo.StoryIntelligenceRuns
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
r.UserID,
r.ProjectID,
p.ProjectName AS ProjectTitle,
r.BookID,
b.BookTitle,
b.Subtitle AS BookSubtitle,
COUNT(1) AS RunCount,
SUM(CASE WHEN r.Status = N'Pending' THEN 1 ELSE 0 END) AS PendingRunCount,
SUM(CASE WHEN r.Status = N'Running' THEN 1 ELSE 0 END) AS RunningRunCount,
SUM(CASE WHEN r.Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END) AS CompletedRunCount,
SUM(CASE WHEN r.Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) AS FailedRunCount,
SUM(ISNULL(r.TotalDetectedScenes, 0)) AS TotalDetectedScenes,
SUM(ISNULL(r.CompletedScenes, 0)) AS CompletedScenes,
SUM(ISNULL(r.FailedScenes, 0)) AS FailedScenes,
SUM(ISNULL(r.TotalDurationMs, 0)) AS TotalDurationMs,
MIN(r.CreatedUtc) AS FirstCreatedUtc,
MAX(r.UpdatedUtc) AS UpdatedUtc
FROM dbo.StoryIntelligenceRuns r
LEFT JOIN dbo.Projects p ON p.ProjectID = r.ProjectID
LEFT JOIN dbo.Books b ON b.BookID = r.BookID
WHERE r.UserID = @UserID
AND r.Status IN (N'Pending', N'Running')
GROUP BY r.UserID, r.ProjectID, p.ProjectName, r.BookID, b.BookTitle, b.Subtitle
ORDER BY MAX(r.UpdatedUtc) DESC, MIN(r.CreatedUtc);
END;
GO
IF NOT EXISTS
(
SELECT 1
FROM sys.indexes
WHERE name = N'IX_StoryIntelligenceRuns_QueueFairness'
AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceRuns')
)
BEGIN
CREATE INDEX IX_StoryIntelligenceRuns_QueueFairness
ON dbo.StoryIntelligenceRuns (Status, UserID, BookID, CreatedUtc, StoryIntelligenceRunID)
INCLUDE (ProjectID, ChapterID, ChapterNumber, UpdatedUtc, TotalDetectedScenes, CompletedScenes, FailedScenes, TotalDurationMs);
END;
GO

View File

@ -0,0 +1,68 @@
namespace PlotLine.ViewModels;
public sealed class StoryIntelligenceGlobalProgressViewModel
{
public IReadOnlyList<StoryIntelligenceGlobalProgressBookViewModel> Books { get; init; } = [];
public bool HasActiveWork => Books.Any(book => book.HasActiveRuns);
public int ActiveBookCount => Books.Count(book => book.HasActiveRuns);
public int ActiveRunCount => Books.Sum(book => book.PendingRunCount + book.RunningRunCount);
public int TotalDetectedScenes => Books.Sum(book => book.TotalDetectedScenes);
public int CompletedScenes => Books.Sum(book => book.CompletedScenes);
public int FailedScenes => Books.Sum(book => book.FailedScenes);
public int ProgressPercent => TotalDetectedScenes <= 0
? 0
: Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalDetectedScenes)), 0, 100);
public string SummaryText => ActiveBookCount switch
{
0 => string.Empty,
1 => Books.First(book => book.HasActiveRuns).SummaryText,
_ => $"{ActiveBookCount:N0} books analysing"
};
}
public sealed class StoryIntelligenceGlobalProgressBookViewModel
{
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string BookTitle { get; init; } = "Book";
public int RunCount { get; init; }
public int PendingRunCount { get; init; }
public int RunningRunCount { get; init; }
public int CompletedRunCount { get; init; }
public int FailedRunCount { get; init; }
public int TotalDetectedScenes { get; init; }
public int CompletedScenes { get; init; }
public int FailedScenes { get; init; }
public long TotalDurationMs { get; init; }
public DateTime UpdatedUtc { get; init; }
public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0;
public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0;
public int ProgressPercent => TotalDetectedScenes <= 0
? 0
: Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalDetectedScenes)), 0, 100);
public string SummaryText => $"{BookTitle}: {CompletedScenes:N0}/{Math.Max(TotalDetectedScenes, CompletedScenes):N0} scenes";
public string EstimatedRemaining => EstimateRemaining(TotalDurationMs, CompletedScenes, TotalDetectedScenes);
private static string EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes)
{
if (totalScenes <= 0 || completedScenes <= 0)
{
return "Calculating";
}
if (completedScenes >= totalScenes)
{
return "Finalising";
}
var averageSceneMs = elapsedMs / Math.Max(1, completedScenes);
var remaining = TimeSpan.FromMilliseconds(averageSceneMs * (totalScenes - completedScenes));
if (remaining.TotalMinutes >= 1)
{
return $"{Math.Max(1, (int)Math.Round(remaining.TotalMinutes)):N0} min";
}
return $"{Math.Max(1, (int)Math.Round(remaining.TotalSeconds)):N0} sec";
}
}

View File

@ -4,6 +4,7 @@
@inject IConfiguration Configuration @inject IConfiguration Configuration
@inject IFeatureRequestService FeatureRequestService @inject IFeatureRequestService FeatureRequestService
@inject IOnboardingService OnboardingService @inject IOnboardingService OnboardingService
@inject IStoryIntelligenceGlobalProgressService StoryIntelligenceGlobalProgress
@{ @{
var shellClass = ViewData["ShellClass"] as string ?? string.Empty; var shellClass = ViewData["ShellClass"] as string ?? string.Empty;
var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase); var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase);
@ -16,6 +17,9 @@
var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0; var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0;
var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0; var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0;
var showWordCompanionHeader = User.Identity?.IsAuthenticated == true && await OnboardingService.ShouldShowWordCompanionHeaderAsync(); var showWordCompanionHeader = User.Identity?.IsAuthenticated == true && await OnboardingService.ShouldShowWordCompanionHeaderAsync();
var storyIntelligenceProgress = User.Identity?.IsAuthenticated == true && !isMarketingPage
? await StoryIntelligenceGlobalProgress.GetForCurrentUserAsync()
: new StoryIntelligenceGlobalProgressViewModel();
var seoMetadata = ViewData["Seo"] as SeoMetadata; var seoMetadata = ViewData["Seo"] as SeoMetadata;
var defaultTitle = PublicSeo.DisplayTitle(ViewData["Title"] as string); var defaultTitle = PublicSeo.DisplayTitle(ViewData["Title"] as string);
var seoTitle = ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle; var seoTitle = ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle;
@ -99,6 +103,7 @@
<link rel="stylesheet" href="~/css/plotline-theme.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/plotline-theme.css" asp-append-version="true" />
</environment> </environment>
<link rel="stylesheet" href="~/css/onboarding.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/onboarding.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/story-intelligence-global-progress.css" asp-append-version="true" />
<link rel="icon" href="~/favicon.ico" sizes="any" /> <link rel="icon" href="~/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="~/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="~/favicon.svg" />
@ -223,6 +228,39 @@
</div> </div>
</div> </div>
</nav> </nav>
@if (storyIntelligenceProgress.HasActiveWork)
{
var primaryBook = storyIntelligenceProgress.Books.FirstOrDefault(book => book.HasActiveRuns);
<div class="story-intelligence-global-strip"
data-story-intelligence-global-strip
data-progress-percent="@storyIntelligenceProgress.ProgressPercent"
data-active-books="@storyIntelligenceProgress.ActiveBookCount"
data-book-id="@primaryBook?.BookID"
data-project-id="@primaryBook?.ProjectID"
role="status"
aria-live="polite">
<div class="story-intelligence-global-strip__inner">
<div class="story-intelligence-global-strip__copy">
<span class="story-intelligence-global-strip__dot" aria-hidden="true"></span>
<strong data-story-intelligence-global-title>@storyIntelligenceProgress.SummaryText</strong>
<span data-story-intelligence-global-detail>
@storyIntelligenceProgress.CompletedScenes.ToString("N0") of @storyIntelligenceProgress.TotalDetectedScenes.ToString("N0") scenes analysed
@if (primaryBook is not null)
{
<text> &middot; @primaryBook.EstimatedRemaining remaining</text>
}
</span>
</div>
<div class="story-intelligence-global-strip__meter" aria-hidden="true">
<span data-story-intelligence-global-bar style="width: @storyIntelligenceProgress.ProgressPercent%"></span>
</div>
@if (primaryBook?.BookID is int progressBookId)
{
<a class="btn btn-outline-secondary btn-sm" asp-controller="Books" asp-action="Details" asp-route-id="@progressBookId">Book</a>
}
</div>
</div>
}
</header> </header>
<div class="plotline-help-layout" data-help-layout> <div class="plotline-help-layout" data-help-layout>
<div class="plotline-help-main"> <div class="plotline-help-main">
@ -322,6 +360,7 @@
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
{ {
<script src="~/js/word-companion-presence.js" asp-append-version="true"></script> <script src="~/js/word-companion-presence.js" asp-append-version="true"></script>
<script src="~/js/story-intelligence-global-progress.js" asp-append-version="true"></script>
} }
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)

View File

@ -28,6 +28,11 @@
"ChapterStructureModel": "gpt-5-mini", "ChapterStructureModel": "gpt-5-mini",
"SceneIntelligenceModel": "gpt-5-mini", "SceneIntelligenceModel": "gpt-5-mini",
"SceneIntelligenceMaxOutputTokens": 10000, "SceneIntelligenceMaxOutputTokens": 10000,
"MaxGlobalConcurrentAiRequests": 4,
"MaxConcurrentAiRequestsPerBook": 2,
"MaxConcurrentAiRequestsPerUser": 3,
"QueuePollingIntervalSeconds": 3,
"ClaimLeaseMinutes": 90,
"AutomaticImageGenerationDuringImportEnabled": false, "AutomaticImageGenerationDuringImportEnabled": false,
"RequireImageGenerationApproval": true, "RequireImageGenerationApproval": true,
"MaxImagesPerImport": 12, "MaxImagesPerImport": 12,

View File

@ -0,0 +1,87 @@
.story-intelligence-global-strip {
margin: -0.65rem 0 0.85rem;
border-bottom: 1px solid rgba(84, 68, 48, 0.14);
background: rgba(251, 248, 240, 0.96);
box-shadow: 0 8px 20px rgba(62, 45, 28, 0.05);
}
.story-intelligence-global-strip__inner {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, 320px) auto;
align-items: center;
gap: 0.85rem;
min-height: 44px;
padding: 0.45rem max(0.75rem, env(safe-area-inset-left)) 0.45rem max(0.75rem, env(safe-area-inset-right));
}
.story-intelligence-global-strip__copy {
display: flex;
align-items: center;
min-width: 0;
gap: 0.55rem;
color: var(--plotline-text);
font-size: 0.9rem;
}
.story-intelligence-global-strip__copy strong,
.story-intelligence-global-strip__copy span:not(.story-intelligence-global-strip__dot) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-intelligence-global-strip__copy strong {
flex: 0 1 auto;
max-width: min(34vw, 28rem);
}
.story-intelligence-global-strip__copy span:not(.story-intelligence-global-strip__dot) {
color: var(--plotline-text-muted);
}
.story-intelligence-global-strip__dot {
flex: 0 0 auto;
width: 0.62rem;
height: 0.62rem;
border-radius: 50%;
background: var(--plotline-secondary);
box-shadow: 0 0 0 0.22rem rgba(49, 95, 103, 0.14);
}
.story-intelligence-global-strip__meter {
overflow: hidden;
width: 100%;
height: 0.44rem;
border-radius: 999px;
background: rgba(84, 68, 48, 0.14);
}
.story-intelligence-global-strip__meter span {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--plotline-secondary), var(--plotline-accent));
transition: width 0.25s ease;
}
@media (max-width: 720px) {
.story-intelligence-global-strip__inner {
grid-template-columns: minmax(0, 1fr) auto;
}
.story-intelligence-global-strip__meter {
grid-column: 1 / -1;
order: 3;
}
.story-intelligence-global-strip__copy {
flex-wrap: wrap;
gap: 0.25rem 0.55rem;
}
.story-intelligence-global-strip__copy strong,
.story-intelligence-global-strip__copy span:not(.story-intelligence-global-strip__dot) {
max-width: calc(100vw - 7rem);
}
}

View File

@ -0,0 +1,90 @@
(() => {
const strip = document.querySelector("[data-story-intelligence-global-strip]");
if (!strip || !window.signalR) {
return;
}
const title = strip.querySelector("[data-story-intelligence-global-title]");
const detail = strip.querySelector("[data-story-intelligence-global-detail]");
const bar = strip.querySelector("[data-story-intelligence-global-bar]");
const readNumber = (value) => {
const parsed = Number.parseInt(value || "", 10);
return Number.isInteger(parsed) ? parsed : null;
};
const trackedBookId = readNumber(strip.dataset.bookId);
const trackedProjectId = readNumber(strip.dataset.projectId);
const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0));
const estimateRemaining = (elapsedMs, completed, total) => {
if (!total || !completed) {
return "Calculating";
}
if (completed >= total) {
return "Finalising";
}
const remainingMs = (elapsedMs / completed) * (total - completed);
const remainingSeconds = Math.max(1, Math.round(remainingMs / 1000));
if (remainingSeconds >= 60) {
return `${Math.max(1, Math.round(remainingSeconds / 60))} min`;
}
return `${remainingSeconds} sec`;
};
const update = (progress) => {
if (!progress) {
return;
}
const bookId = readNumber(progress.bookID ?? progress.bookId);
const projectId = readNumber(progress.projectID ?? progress.projectId);
if (trackedBookId && bookId && trackedBookId !== bookId) {
return;
}
if (!trackedBookId && trackedProjectId && projectId && trackedProjectId !== projectId) {
return;
}
const status = progress.status || progress.Status || "";
const total = readNumber(progress.totalDetectedScenes ?? progress.TotalDetectedScenes) || 0;
const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0;
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0;
const elapsedMs = readNumber(progress.totalDurationMs ?? progress.TotalDurationMs) || 0;
const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0;
if (bar) {
bar.style.width = `${percent}%`;
}
if (detail) {
const remaining = estimateRemaining(elapsedMs, completed, total);
detail.textContent = `${formatCount(completed)} of ${formatCount(total)} scenes analysed \u00b7 ${remaining} remaining`;
}
if (title && status) {
if (status === "Failed") {
title.textContent = "Story Intelligence needs attention";
} else if (status === "Cancelled") {
title.textContent = "Story Intelligence cancelled";
} else if (status === "Completed" || status === "CompletedWithWarnings") {
title.textContent = failed > 0 ? "Story Intelligence completed with notes" : "Story Intelligence complete";
window.setTimeout(() => {
strip.hidden = true;
}, 10000);
}
}
};
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/story-intelligence")
.withAutomaticReconnect()
.build();
connection.on("StoryIntelligenceRunProgressChanged", update);
connection.start().catch(() => {
strip.dataset.connection = "offline";
});
})();