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 forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard),
|
||||
("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)
|
||||
@ -239,6 +240,36 @@ foreach (var test in tests)
|
||||
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()
|
||||
{
|
||||
var root = PlotLineRoot();
|
||||
|
||||
@ -148,7 +148,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
var existing = await storyIntelligence.ResumeBookAsync(bookId);
|
||||
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
|
||||
@ -160,7 +161,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
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)
|
||||
{
|
||||
|
||||
@ -9,6 +9,7 @@ public interface IStoryIntelligenceResultRepository
|
||||
Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request);
|
||||
Task<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request);
|
||||
Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync();
|
||||
Task<StoryIntelligenceQueuedRun?> ClaimNextPendingFairAsync(int maxConcurrentRunsPerBook, int maxConcurrentRunsPerUser, int claimLeaseMinutes);
|
||||
Task UpdateProgressAsync(
|
||||
int runId,
|
||||
string? status = null,
|
||||
@ -31,6 +32,7 @@ public interface IStoryIntelligenceResultRepository
|
||||
Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene);
|
||||
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
|
||||
Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId);
|
||||
Task<IReadOnlyList<StoryIntelligenceActiveBookRunSummary>> ListActiveBookRunSummariesForUserAsync(int userId);
|
||||
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
|
||||
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
|
||||
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
|
||||
@ -321,6 +323,23 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
||||
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(
|
||||
int runId,
|
||||
string? status = null,
|
||||
@ -490,6 +509,16 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
||||
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)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -114,6 +114,11 @@ public sealed class StoryIntelligenceOptions
|
||||
public int MaxOutputTokens { get; init; } = 4000;
|
||||
public int? SceneIntelligenceMaxOutputTokens { get; init; }
|
||||
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 EffectiveChapterStructureModel => FirstConfigured(ChapterStructureModel, Model);
|
||||
|
||||
@ -340,6 +340,31 @@ public sealed class StoryIntelligenceQueuedRun
|
||||
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 int Value { get; init; }
|
||||
|
||||
@ -236,6 +236,7 @@ public class Program
|
||||
builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>();
|
||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceGlobalProgressService, StoryIntelligenceGlobalProgressService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceEntitlementService, StoryIntelligenceEntitlementService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceCompletionNotificationService, StoryIntelligenceCompletionNotificationService>();
|
||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||
|
||||
@ -8,7 +8,7 @@ namespace PlotLine.Services;
|
||||
|
||||
public interface IPersistedStoryIntelligenceRunner
|
||||
{
|
||||
Task ProcessNextAsync(CancellationToken cancellationToken);
|
||||
Task<bool> ProcessNextAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class PersistedStoryIntelligenceRunner(
|
||||
@ -45,15 +45,19 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
private readonly StoryIntelligenceOptions settings = options.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)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
await ProcessAsync(run, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task ProcessAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken)
|
||||
@ -68,9 +72,21 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
var completedScenes = 0;
|
||||
var hasWarnings = false;
|
||||
var currentFailureStage = StoryIntelligenceFailureStages.DocumentRead;
|
||||
var queueWaitMs = Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.CreatedUtc).TotalMilliseconds));
|
||||
|
||||
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(
|
||||
run,
|
||||
"Import started",
|
||||
@ -79,6 +95,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
"Story Intelligence has started reading this chapter.",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
|
||||
var preparationStopwatch = Stopwatch.StartNew();
|
||||
await entitlement.EnsureCanExecuteAsync(run.UserID);
|
||||
|
||||
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
|
||||
@ -118,15 +135,21 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
hasWarnings = mode.HasWarnings;
|
||||
failedScenes += sceneBlocks.Count(block => !block.SplitValid);
|
||||
currentFailureStage = StoryIntelligenceFailureStages.SceneSplit;
|
||||
preparationStopwatch.Stop();
|
||||
|
||||
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,
|
||||
run.StoryIntelligenceRunID,
|
||||
run.ProjectID,
|
||||
run.BookID,
|
||||
run.ChapterID,
|
||||
sceneBlocks.Count);
|
||||
sceneBlocks.Count,
|
||||
queueWaitMs,
|
||||
preparationStopwatch.ElapsedMilliseconds,
|
||||
run.PromptVersionsSummary,
|
||||
stageModels.ChapterStructureModel,
|
||||
stageModels.SceneIntelligenceModel);
|
||||
|
||||
await repository.UpdateProgressAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
@ -202,15 +225,21 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
|
||||
try
|
||||
{
|
||||
var sceneStopwatch = Stopwatch.StartNew();
|
||||
var promptStopwatch = Stopwatch.StartNew();
|
||||
currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence;
|
||||
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText);
|
||||
promptStopwatch.Stop();
|
||||
var openAiStopwatch = Stopwatch.StartNew();
|
||||
var sceneClientResult = await client.ExecutePromptAsync(
|
||||
completedScenePrompt,
|
||||
scenePromptVersion,
|
||||
cancellationToken,
|
||||
stageModels.SceneIntelligenceModel,
|
||||
SceneIntelligenceMaxOutputTokens());
|
||||
openAiStopwatch.Stop();
|
||||
totals.Add(sceneClientResult, pricing);
|
||||
var parseStopwatch = Stopwatch.StartNew();
|
||||
var sceneAttempt = await ParseAiJsonWithRetryAsync<SceneIntelligenceScene>(
|
||||
completedScenePrompt,
|
||||
scenePromptVersion,
|
||||
@ -219,6 +248,8 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
totals,
|
||||
"Scene Intelligence",
|
||||
cancellationToken);
|
||||
parseStopwatch.Stop();
|
||||
var validationStopwatch = Stopwatch.StartNew();
|
||||
var validation = sceneValidator.Validate(sceneAttempt.Parsed);
|
||||
foreach (var warning in sceneAttempt.Warnings)
|
||||
{
|
||||
@ -232,7 +263,9 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
}
|
||||
|
||||
hasWarnings = hasWarnings || validation.Warnings.Count > 0;
|
||||
validationStopwatch.Stop();
|
||||
|
||||
var persistenceStopwatch = Stopwatch.StartNew();
|
||||
var sceneResultId = await repository.SaveSceneResultAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
chapterResultId,
|
||||
@ -258,6 +291,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens),
|
||||
DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds)
|
||||
});
|
||||
persistenceStopwatch.Stop();
|
||||
var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null;
|
||||
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.",
|
||||
@ -266,6 +300,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
run.ChapterNumber,
|
||||
block.TemporarySceneNumber,
|
||||
sceneResultId);
|
||||
var postProcessingStopwatch = Stopwatch.StartNew();
|
||||
if (importSessionId.HasValue)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
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++;
|
||||
await PublishAsync(
|
||||
@ -371,6 +433,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
? StoryIntelligenceRunStatuses.CompletedWithWarnings
|
||||
: StoryIntelligenceRunStatuses.Completed;
|
||||
|
||||
var finalisationStopwatch = Stopwatch.StartNew();
|
||||
await repository.CompleteRunAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
finalStatus,
|
||||
@ -394,6 +457,21 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
failedScenes: failedScenes,
|
||||
totalTokens: totals.TotalTokens);
|
||||
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)
|
||||
{
|
||||
|
||||
@ -1,19 +1,43 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public sealed class PersistedStoryIntelligenceWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<StoryIntelligenceOptions> options,
|
||||
ILogger<PersistedStoryIntelligenceWorker> logger) : BackgroundService
|
||||
{
|
||||
private readonly StoryIntelligenceOptions settings = options.Value;
|
||||
|
||||
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)
|
||||
{
|
||||
var processedRun = false;
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var runner = scope.ServiceProvider.GetRequiredService<IPersistedStoryIntelligenceRunner>();
|
||||
await runner.ProcessNextAsync(stoppingToken);
|
||||
processedRun = await runner.ProcessNextAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@ -21,10 +45,13 @@ public sealed class PersistedStoryIntelligenceWorker(
|
||||
}
|
||||
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++;
|
||||
await DelayForRetryAsync(attempt, cancellationToken, response.Headers.RetryAfter);
|
||||
continue;
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@ -309,7 +311,6 @@ public sealed class StoryIntelligenceClient(
|
||||
continue;
|
||||
}
|
||||
|
||||
await DelayForRetryAsync(attempt, cancellationToken);
|
||||
}
|
||||
|
||||
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("exceeded your current quota", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken)
|
||||
=> Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken);
|
||||
private static Task DelayForRetryAsync(
|
||||
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)
|
||||
=> 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 IFeatureRequestService FeatureRequestService
|
||||
@inject IOnboardingService OnboardingService
|
||||
@inject IStoryIntelligenceGlobalProgressService StoryIntelligenceGlobalProgress
|
||||
@{
|
||||
var shellClass = ViewData["ShellClass"] as string ?? string.Empty;
|
||||
var isMarketingPage = shellClass.Contains("marketing-shell", StringComparison.OrdinalIgnoreCase);
|
||||
@ -16,6 +17,9 @@
|
||||
var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0;
|
||||
var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0;
|
||||
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 defaultTitle = PublicSeo.DisplayTitle(ViewData["Title"] as string);
|
||||
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" />
|
||||
</environment>
|
||||
<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" type="image/svg+xml" href="~/favicon.svg" />
|
||||
@ -223,6 +228,39 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<div class="plotline-help-layout" data-help-layout>
|
||||
<div class="plotline-help-main">
|
||||
@ -322,6 +360,7 @@
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<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)
|
||||
|
||||
@ -28,6 +28,11 @@
|
||||
"ChapterStructureModel": "gpt-5-mini",
|
||||
"SceneIntelligenceModel": "gpt-5-mini",
|
||||
"SceneIntelligenceMaxOutputTokens": 10000,
|
||||
"MaxGlobalConcurrentAiRequests": 4,
|
||||
"MaxConcurrentAiRequestsPerBook": 2,
|
||||
"MaxConcurrentAiRequestsPerUser": 3,
|
||||
"QueuePollingIntervalSeconds": 3,
|
||||
"ClaimLeaseMinutes": 90,
|
||||
"AutomaticImageGenerationDuringImportEnabled": false,
|
||||
"RequireImageGenerationApproval": true,
|
||||
"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