Add concurrent story intelligence execution
This commit is contained in:
parent
6d2340ae46
commit
96343138ea
@ -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();
|
||||||
|
|||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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>();
|
||||||
|
|||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
PlotLine/Services/StoryIntelligenceGlobalProgressService.cs
Normal file
44
PlotLine/Services/StoryIntelligenceGlobalProgressService.cs
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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];
|
||||||
|
|||||||
@ -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
|
||||||
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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> · @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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
87
PlotLine/wwwroot/css/story-intelligence-global-progress.css
Normal file
87
PlotLine/wwwroot/css/story-intelligence-global-progress.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
PlotLine/wwwroot/js/story-intelligence-global-progress.js
Normal file
90
PlotLine/wwwroot/js/story-intelligence-global-progress.js
Normal 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";
|
||||||
|
});
|
||||||
|
})();
|
||||||
Loading…
x
Reference in New Issue
Block a user