Phase 20P Implement a real persisted Story Intelligence job runner.

This commit is contained in:
Nick Beckley 2026-07-04 21:51:34 +01:00
parent 650febab70
commit 9f9688bcb5
11 changed files with 1136 additions and 0 deletions

View File

@ -93,6 +93,20 @@ public sealed class AdminController(
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId });
} }
[HttpPost("chapter-story-intelligence-test/queue")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> QueueChapterStoryIntelligenceRun([Bind(Prefix = "Form")] ChapterStoryIntelligenceDryRunForm form)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var runId = await storyIntelligenceResults.QueueAdminTextRunAsync(userId, form);
TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} queued. The background worker will process it progressively.";
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId });
}
[HttpGet("story-intelligence-runs")] [HttpGet("story-intelligence-runs")]
public async Task<IActionResult> StoryIntelligenceRuns() public async Task<IActionResult> StoryIntelligenceRuns()
{ {
@ -106,6 +120,15 @@ public sealed class AdminController(
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }
[HttpPost("story-intelligence-runs/{id:int}/cancel")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CancelStoryIntelligenceRun(int id)
{
await storyIntelligenceResults.CancelRunAsync(id);
TempData["AdminMessage"] = $"Cancellation requested for Story Intelligence run {id:N0}.";
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id });
}
[HttpGet("feature-requests")] [HttpGet("feature-requests")]
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort) public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
{ {

View File

@ -7,6 +7,28 @@ namespace PlotLine.Data;
public interface IStoryIntelligenceResultRepository public interface IStoryIntelligenceResultRepository
{ {
Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request); Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request);
Task<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request);
Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync();
Task UpdateProgressAsync(
int runId,
string? status = null,
string? currentStage = null,
string? currentMessage = null,
int? totalDetectedScenes = null,
int? completedScenes = null,
int? failedScenes = null,
int? totalInputTokens = null,
int? totalOutputTokens = null,
int? totalTokens = null,
long? totalDurationMs = null,
decimal? estimatedCostGBP = null,
decimal? estimatedCostUSD = null);
Task CompleteRunAsync(int runId, string status, int? totalInputTokens, int? totalOutputTokens, int? totalTokens, long? totalDurationMs, decimal? estimatedCostGBP, decimal? estimatedCostUSD);
Task FailRunAsync(int runId, string failureStage, string errorMessage, string? errorDetail, long? totalDurationMs);
Task RequestCancelAsync(int runId);
Task CancelRunAsync(int runId, long? totalDurationMs);
Task<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter);
Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene);
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync(); Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId); Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId); Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
@ -131,6 +153,186 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
} }
} }
public async Task<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.StoryIntelligenceRun_QueueAdminText",
new
{
request.UserID,
request.ProjectID,
request.BookID,
request.SourceType,
request.SourceLabel,
SourceText = request.ChapterText,
request.SourceWordCount,
request.SourceCharacterCount,
request.SourceChapterCount,
request.Model,
request.PromptVersionsSummary
},
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceQueuedRun>(
"dbo.StoryIntelligenceRun_ClaimNextPending",
commandType: CommandType.StoredProcedure);
}
public async Task UpdateProgressAsync(
int runId,
string? status = null,
string? currentStage = null,
string? currentMessage = null,
int? totalDetectedScenes = null,
int? completedScenes = null,
int? failedScenes = null,
int? totalInputTokens = null,
int? totalOutputTokens = null,
int? totalTokens = null,
long? totalDurationMs = null,
decimal? estimatedCostGBP = null,
decimal? estimatedCostUSD = null)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceRun_UpdateProgress",
new
{
StoryIntelligenceRunID = runId,
Status = status,
CurrentStage = currentStage,
CurrentMessage = currentMessage,
TotalDetectedScenes = totalDetectedScenes,
CompletedScenes = completedScenes,
FailedScenes = failedScenes,
TotalInputTokens = totalInputTokens,
TotalOutputTokens = totalOutputTokens,
TotalTokens = totalTokens,
TotalDurationMs = totalDurationMs,
EstimatedCostGBP = estimatedCostGBP,
EstimatedCostUSD = estimatedCostUSD
},
commandType: CommandType.StoredProcedure);
}
public async Task CompleteRunAsync(int runId, string status, int? totalInputTokens, int? totalOutputTokens, int? totalTokens, long? totalDurationMs, decimal? estimatedCostGBP, decimal? estimatedCostUSD)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceRun_Complete",
new
{
StoryIntelligenceRunID = runId,
Status = status,
TotalInputTokens = totalInputTokens,
TotalOutputTokens = totalOutputTokens,
TotalTokens = totalTokens,
TotalDurationMs = totalDurationMs,
EstimatedCostGBP = estimatedCostGBP,
EstimatedCostUSD = estimatedCostUSD
},
commandType: CommandType.StoredProcedure);
}
public async Task FailRunAsync(int runId, string failureStage, string errorMessage, string? errorDetail, long? totalDurationMs)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceRun_Fail",
new
{
StoryIntelligenceRunID = runId,
FailureStage = failureStage,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
TotalDurationMs = totalDurationMs
},
commandType: CommandType.StoredProcedure);
}
public async Task RequestCancelAsync(int runId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceRun_RequestCancel",
new { StoryIntelligenceRunID = runId },
commandType: CommandType.StoredProcedure);
}
public async Task CancelRunAsync(int runId, long? totalDurationMs)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceRun_Cancel",
new { StoryIntelligenceRunID = runId, TotalDurationMs = totalDurationMs },
commandType: CommandType.StoredProcedure);
}
public async Task<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.StoryIntelligenceChapterResult_Save",
new
{
StoryIntelligenceRunID = runId,
chapter.ProjectID,
chapter.BookID,
chapter.ChapterID,
chapter.ChapterNumber,
chapter.SourceLabel,
chapter.PromptVersion,
chapter.Model,
chapter.RawResponseJson,
chapter.OutputTextJson,
chapter.ParsedJson,
chapter.ValidationErrorsCount,
chapter.ValidationWarningsCount,
chapter.InputTokens,
chapter.OutputTokens,
chapter.TotalTokens,
chapter.DurationMs
},
commandType: CommandType.StoredProcedure);
}
public async Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.StoryIntelligenceSceneResult_Save",
new
{
StoryIntelligenceRunID = runId,
ChapterResultID = chapterResultId,
scene.ProjectID,
scene.BookID,
scene.ChapterID,
scene.SceneID,
scene.TemporarySceneNumber,
scene.StartParagraph,
scene.EndParagraph,
scene.SourceLabel,
scene.PromptVersion,
scene.Model,
scene.RawResponseJson,
scene.OutputTextJson,
scene.ParsedJson,
scene.ValidationErrorsCount,
scene.ValidationWarningsCount,
scene.InputTokens,
scene.OutputTokens,
scene.TotalTokens,
scene.DurationMs
},
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync() public async Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync()
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -73,6 +73,13 @@ public sealed class StoryIntelligenceOptions
public int TimeoutSeconds { get; init; } = 120; public int TimeoutSeconds { get; init; } = 120;
} }
public sealed class StoryIntelligencePricingOptions
{
public decimal? InputPerMillionUsd { get; init; }
public decimal? OutputPerMillionUsd { get; init; }
public decimal? UsdToGbpRate { get; init; }
}
public sealed class StoryIntelligenceRun public sealed class StoryIntelligenceRun
{ {
public Guid RunId { get; init; } = Guid.NewGuid(); public Guid RunId { get; init; } = Guid.NewGuid();

View File

@ -55,6 +55,21 @@ public sealed class StoryIntelligenceRunSaveRequest
public IReadOnlyList<StoryIntelligenceSceneResultSaveRequest> SceneResults { get; init; } = []; public IReadOnlyList<StoryIntelligenceSceneResultSaveRequest> SceneResults { get; init; } = [];
} }
public sealed class StoryIntelligenceRunQueueRequest
{
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string SourceType { get; init; } = "AdminText";
public string SourceLabel { get; init; } = string.Empty;
public string ChapterText { get; init; } = string.Empty;
public int? SourceWordCount { get; init; }
public int? SourceCharacterCount { get; init; }
public int? SourceChapterCount { get; init; }
public string Model { get; init; } = string.Empty;
public string PromptVersionsSummary { get; init; } = string.Empty;
}
public sealed class StoryIntelligenceChapterResultSaveRequest public sealed class StoryIntelligenceChapterResultSaveRequest
{ {
public int? ProjectID { get; init; } public int? ProjectID { get; init; }
@ -138,6 +153,13 @@ public sealed class StoryIntelligenceSavedRun
public int? SourceDetectedTablesCount { get; init; } public int? SourceDetectedTablesCount { get; init; }
public int? SourceDetectedFootnotesCount { get; init; } public int? SourceDetectedFootnotesCount { get; init; }
public int? SourceDetectedCommentsCount { get; init; } public int? SourceDetectedCommentsCount { get; init; }
public string? CurrentStage { get; init; }
public string? CurrentMessage { get; init; }
public int? TotalDetectedScenes { get; init; }
public int? CompletedScenes { get; init; }
public int? FailedScenes { get; init; }
public DateTime? CancellationRequestedUtc { get; init; }
public DateTime? CancelledUtc { get; init; }
public string PromptVersion { get; init; } = string.Empty; public string PromptVersion { get; init; } = string.Empty;
public string? PromptVersionsSummary { get; init; } public string? PromptVersionsSummary { get; init; }
public string Model { get; init; } = string.Empty; public string Model { get; init; } = string.Empty;
@ -156,6 +178,45 @@ public sealed class StoryIntelligenceSavedRun
public DateTime UpdatedUtc { get; init; } public DateTime UpdatedUtc { get; init; }
} }
public sealed class StoryIntelligenceQueuedRun
{
public int StoryIntelligenceRunID { get; init; }
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string Status { get; init; } = string.Empty;
public string SourceType { get; init; } = string.Empty;
public string? SourceLabel { get; init; }
public string? SourceText { get; init; }
public int? SourceWordCount { get; init; }
public int? SourceCharacterCount { get; init; }
public int? SourceChapterCount { get; init; }
public string PromptVersion { get; init; } = string.Empty;
public string? PromptVersionsSummary { get; init; }
public string Model { get; init; } = string.Empty;
public DateTime StartedUtc { get; init; }
public DateTime? CompletedUtc { get; init; }
public string? FailureStage { get; init; }
public int? TotalInputTokens { get; init; }
public int? TotalOutputTokens { get; init; }
public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; }
public decimal? EstimatedCostGBP { get; init; }
public decimal? EstimatedCostUSD { get; init; }
public string? ErrorMessage { get; init; }
public string? ErrorDetail { get; init; }
public string? CurrentStage { get; init; }
public string? CurrentMessage { get; init; }
public int? TotalDetectedScenes { get; init; }
public int? CompletedScenes { get; init; }
public int? FailedScenes { get; init; }
public DateTime? CancellationRequestedUtc { get; init; }
public DateTime? CancelledUtc { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
public bool CancellationRequested => CancellationRequestedUtc.HasValue;
}
public sealed class StoryIntelligenceSavedChapterResult public sealed class StoryIntelligenceSavedChapterResult
{ {
public int ChapterResultID { get; init; } public int ChapterResultID { get; init; }

View File

@ -42,6 +42,7 @@ public class Program
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe")); builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage")); builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage"));
builder.Services.Configure<StoryIntelligenceOptions>(builder.Configuration.GetSection("OpenAI")); builder.Services.Configure<StoryIntelligenceOptions>(builder.Configuration.GetSection("OpenAI"));
builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing"));
builder.Services.AddDataProtection() builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
@ -195,6 +196,7 @@ public class Program
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>(); builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>(); builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>(); builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>(); builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>(); builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>(); builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
@ -210,6 +212,7 @@ public class Program
builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>(); builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>();
builder.Services.AddHostedService<EmailQueueWorker>(); builder.Services.AddHostedService<EmailQueueWorker>();
builder.Services.AddHostedService<StoryIntelligenceWorker>(); builder.Services.AddHostedService<StoryIntelligenceWorker>();
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
var app = builder.Build(); var app = builder.Build();

View File

@ -0,0 +1,439 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IPersistedStoryIntelligenceRunner
{
Task ProcessNextAsync(CancellationToken cancellationToken);
}
public sealed class PersistedStoryIntelligenceRunner(
IStoryIntelligenceResultRepository repository,
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryPromptBuilder scenePromptBuilder,
IStoryIntelligenceClient client,
IChapterStructureValidator chapterValidator,
IStorySceneValidator sceneValidator,
IOptions<StoryIntelligencePricingOptions> pricingOptions,
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
{
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V1.md";
private const string ScenePromptFile = "Scene-Prompt-V1.md";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value;
public async Task ProcessNextAsync(CancellationToken cancellationToken)
{
var run = await repository.ClaimNextPendingAsync();
if (run is null)
{
return;
}
await ProcessAsync(run, cancellationToken);
}
private async Task ProcessAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var totals = new TokenTotals();
var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile);
var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile);
int? chapterResultId = null;
var failedScenes = 0;
var completedScenes = 0;
var hasWarnings = false;
try
{
if (string.IsNullOrWhiteSpace(run.SourceText))
{
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.DocumentRead,
"Story Intelligence run has no source text.");
}
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
var paragraphs = SplitParagraphs(run.SourceText);
if (paragraphs.Count == 0)
{
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.DocumentRead,
"Story Intelligence run source text does not contain readable paragraphs.");
}
var chapterContextJson = BuildChapterContextJson(run);
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, run.SourceText);
await repository.UpdateProgressAsync(
run.StoryIntelligenceRunID,
currentStage: StoryIntelligenceFailureStages.ChapterStructure,
currentMessage: "Running Chapter Structure analysis.",
totalDurationMs: stopwatch.ElapsedMilliseconds);
var chapterClientResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken);
totals.Add(chapterClientResult);
var chapterJson = ExtractOutputText(chapterClientResult.RawResponseText);
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
var chapterValidation = chapterValidator.Validate(parsedChapter, paragraphs.Count);
hasWarnings = chapterValidation.Warnings.Count > 0;
chapterResultId = await repository.SaveChapterResultAsync(
run.StoryIntelligenceRunID,
new StoryIntelligenceChapterResultSaveRequest
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = null,
ChapterNumber = 1,
SourceLabel = SourceLabel(run),
PromptVersion = chapterPromptVersion,
Model = chapterClientResult.Model,
RawResponseJson = chapterClientResult.RawResponseText,
OutputTextJson = chapterJson,
ParsedJson = SerializeParsed(parsedChapter),
ValidationErrorsCount = chapterValidation.Errors.Count,
ValidationWarningsCount = chapterValidation.Warnings.Count,
InputTokens = chapterClientResult.InputTokens,
OutputTokens = chapterClientResult.OutputTokens,
TotalTokens = SumTokens(chapterClientResult.InputTokens, chapterClientResult.OutputTokens),
DurationMs = Convert.ToInt64(chapterClientResult.Duration.TotalMilliseconds)
});
if (!chapterValidation.IsValid || parsedChapter?.SceneBoundaries is null)
{
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.ChapterStructure,
"Chapter Structure validation failed.",
BuildValidationDetail(chapterValidation));
}
var sceneBlocks = parsedChapter.SceneBoundaries
.Select(boundary => BuildSceneBlock(run, paragraphs, boundary))
.ToList();
failedScenes += sceneBlocks.Count(block => !block.SplitValid);
await repository.UpdateProgressAsync(
run.StoryIntelligenceRunID,
currentStage: StoryIntelligenceFailureStages.SceneSplit,
currentMessage: $"Detected {sceneBlocks.Count:N0} suggested scene(s).",
totalDetectedScenes: sceneBlocks.Count,
failedScenes: failedScenes,
totalInputTokens: totals.InputTokens,
totalOutputTokens: totals.OutputTokens,
totalTokens: totals.TotalTokens,
totalDurationMs: stopwatch.ElapsedMilliseconds,
estimatedCostUSD: EstimateUsd(totals),
estimatedCostGBP: EstimateGbp(totals));
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
foreach (var block in sceneBlocks)
{
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
if (!block.SplitValid)
{
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, "Scene boundary range is invalid for the submitted chapter paragraphs.");
continue;
}
await repository.UpdateProgressAsync(
run.StoryIntelligenceRunID,
currentStage: StoryIntelligenceFailureStages.SceneIntelligence,
currentMessage: $"Running Scene Intelligence for suggested scene {block.TemporarySceneNumber:N0}.",
completedScenes: completedScenes,
failedScenes: failedScenes,
totalDurationMs: stopwatch.ElapsedMilliseconds);
try
{
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText);
var sceneClientResult = await client.ExecutePromptAsync(completedScenePrompt, scenePromptVersion, cancellationToken);
totals.Add(sceneClientResult);
var sceneJson = ExtractOutputText(sceneClientResult.RawResponseText);
var parsedScene = JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions);
var validation = sceneValidator.Validate(parsedScene);
hasWarnings = hasWarnings || validation.Warnings.Count > 0;
await repository.SaveSceneResultAsync(
run.StoryIntelligenceRunID,
chapterResultId,
new StoryIntelligenceSceneResultSaveRequest
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = null,
SceneID = null,
TemporarySceneNumber = block.TemporarySceneNumber,
StartParagraph = block.StartParagraph,
EndParagraph = block.EndParagraph,
SourceLabel = block.SourceLabel,
PromptVersion = scenePromptVersion,
Model = sceneClientResult.Model,
RawResponseJson = sceneClientResult.RawResponseText,
OutputTextJson = sceneJson,
ParsedJson = SerializeParsed(parsedScene),
ValidationErrorsCount = validation.Errors.Count,
ValidationWarningsCount = validation.Warnings.Count,
InputTokens = sceneClientResult.InputTokens,
OutputTokens = sceneClientResult.OutputTokens,
TotalTokens = SumTokens(sceneClientResult.InputTokens, sceneClientResult.OutputTokens),
DurationMs = Convert.ToInt64(sceneClientResult.Duration.TotalMilliseconds)
});
completedScenes++;
if (!validation.IsValid || validation.Warnings.Count > 0)
{
failedScenes += validation.Errors.Count > 0 ? 1 : 0;
}
}
catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))
{
failedScenes++;
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message);
logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber);
}
await repository.UpdateProgressAsync(
run.StoryIntelligenceRunID,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalInputTokens: totals.InputTokens,
totalOutputTokens: totals.OutputTokens,
totalTokens: totals.TotalTokens,
totalDurationMs: stopwatch.ElapsedMilliseconds,
estimatedCostUSD: EstimateUsd(totals),
estimatedCostGBP: EstimateGbp(totals));
}
var finalStatus = failedScenes > 0 || hasWarnings
? StoryIntelligenceRunStatuses.CompletedWithWarnings
: StoryIntelligenceRunStatuses.Completed;
await repository.CompleteRunAsync(
run.StoryIntelligenceRunID,
finalStatus,
totals.InputTokens,
totals.OutputTokens,
totals.TotalTokens,
stopwatch.ElapsedMilliseconds,
EstimateGbp(totals),
EstimateUsd(totals));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds);
}
catch (StoryIntelligenceRunFailureException ex)
{
await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await repository.FailRunAsync(
run.StoryIntelligenceRunID,
ClassifyFailureStage(ex),
ex.Message,
ex.ToString(),
stopwatch.ElapsedMilliseconds);
}
}
private async Task ThrowIfCancellationRequestedAsync(int runId, long elapsedMs, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var latest = await repository.GetRunAsync(runId);
if (latest?.CancellationRequestedUtc.HasValue == true || string.Equals(latest?.Status, StoryIntelligenceRunStatuses.Cancelled, StringComparison.Ordinal))
{
await repository.CancelRunAsync(runId, elapsedMs);
throw new OperationCanceledException();
}
}
private async Task PersistFailedSceneAsync(
StoryIntelligenceQueuedRun run,
int? chapterResultId,
StorySceneTextBlock block,
string scenePromptVersion,
string message)
{
await repository.SaveSceneResultAsync(
run.StoryIntelligenceRunID,
chapterResultId,
new StoryIntelligenceSceneResultSaveRequest
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = null,
SceneID = null,
TemporarySceneNumber = block.TemporarySceneNumber,
StartParagraph = block.StartParagraph,
EndParagraph = block.EndParagraph,
SourceLabel = block.SourceLabel,
PromptVersion = scenePromptVersion,
Model = run.Model,
RawResponseJson = string.Empty,
OutputTextJson = string.Empty,
ParsedJson = JsonSerializer.Serialize(new { error = message }, JsonOptions),
ValidationErrorsCount = 1,
ValidationWarningsCount = 0
});
}
private decimal? EstimateUsd(TokenTotals totals)
{
if (!pricing.InputPerMillionUsd.HasValue || !pricing.OutputPerMillionUsd.HasValue)
{
return null;
}
var inputCost = (totals.InputTokens ?? 0) / 1_000_000m * pricing.InputPerMillionUsd.Value;
var outputCost = (totals.OutputTokens ?? 0) / 1_000_000m * pricing.OutputPerMillionUsd.Value;
return inputCost + outputCost;
}
private decimal? EstimateGbp(TokenTotals totals)
{
var usd = EstimateUsd(totals);
return usd.HasValue && pricing.UsdToGbpRate.HasValue ? usd.Value * pricing.UsdToGbpRate.Value : null;
}
private static StorySceneTextBlock BuildSceneBlock(StoryIntelligenceQueuedRun run, IReadOnlyList<string> paragraphs, ChapterSceneBoundary boundary)
{
var sceneNumber = boundary.SceneNumber ?? 0;
var start = boundary.StartParagraph ?? 0;
var end = boundary.EndParagraph ?? 0;
var sourceLabel = $"{SourceLabel(run)}, suggested scene {sceneNumber}";
if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count)
{
return new StorySceneTextBlock(sceneNumber, start, end, sourceLabel, string.Empty, string.Empty, false);
}
var sceneText = string.Join(Environment.NewLine + Environment.NewLine, paragraphs.Skip(start - 1).Take(end - start + 1));
return new StorySceneTextBlock(
sceneNumber,
start,
end,
sourceLabel,
sceneText,
BuildSceneContextJson(run, boundary),
true);
}
private static string BuildChapterContextJson(StoryIntelligenceQueuedRun run)
=> JsonSerializer.Serialize(new
{
projectId = run.ProjectID,
bookId = run.BookID,
chapterId = (int?)null,
chapterNumber = 1,
sourceLabel = SourceLabel(run)
}, JsonOptions);
private static string BuildSceneContextJson(StoryIntelligenceQueuedRun run, ChapterSceneBoundary boundary)
=> JsonSerializer.Serialize(new
{
projectId = run.ProjectID,
bookId = run.BookID,
chapterId = (int?)null,
sceneId = (int?)null,
chapterNumber = 1,
sceneNumber = boundary.SceneNumber,
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}"
}, JsonOptions);
private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText)
=> promptTemplate
.Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal)
.Replace("{{CHAPTER_TEXT}}", chapterText ?? string.Empty, StringComparison.Ordinal);
private static string ExtractOutputText(string rawResponseText)
{
var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(rawResponseText, JsonOptions)
?? throw new JsonException("OpenAI response envelope was empty.");
var outputText = response.Output?
.SelectMany(item => item.Content ?? [])
.FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(content.Text))
?.Text;
return string.IsNullOrWhiteSpace(outputText)
? throw new JsonException("OpenAI response did not contain output_text content.")
: outputText.Trim();
}
private static List<string> SplitParagraphs(string chapterText)
=> chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries)
.Select(paragraph => paragraph.Trim())
.Where(paragraph => !string.IsNullOrWhiteSpace(paragraph))
.ToList();
private static string SerializeParsed<T>(T? value)
=> value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions);
private static int? SumTokens(int? inputTokens, int? outputTokens)
=> inputTokens.HasValue || outputTokens.HasValue ? (inputTokens ?? 0) + (outputTokens ?? 0) : null;
private static string SourceLabel(StoryIntelligenceQueuedRun run)
=> string.IsNullOrWhiteSpace(run.SourceLabel) ? "Admin persisted Story Intelligence chapter" : run.SourceLabel.Trim();
private static string BuildValidationDetail(PlotLine.Models.StoryIntelligence.ValidationResult validation)
=> string.Join(
Environment.NewLine,
validation.Errors.Concat(validation.Warnings).Select(issue => $"{issue.Severity} {issue.Path}: {issue.Message}"));
private static bool IsFatal(Exception ex)
=> ex is InvalidOperationException invalid
&& (invalid.Message.Contains("API key", StringComparison.OrdinalIgnoreCase)
|| invalid.Message.Contains("model is not configured", StringComparison.OrdinalIgnoreCase)
|| invalid.Message.Contains("prompt", StringComparison.OrdinalIgnoreCase));
private static string ClassifyFailureStage(Exception ex)
=> ex is FileNotFoundException
? StoryIntelligenceFailureStages.ChapterStructure
: ex is JsonException
? StoryIntelligenceFailureStages.Validation
: StoryIntelligenceFailureStages.Persistence;
private sealed class StoryIntelligenceRunFailureException(string failureStage, string message, string? detail = null) : Exception(message)
{
public string FailureStage { get; } = failureStage;
public string? Detail { get; } = detail;
}
private sealed record StorySceneTextBlock(
int TemporarySceneNumber,
int StartParagraph,
int EndParagraph,
string SourceLabel,
string SceneText,
string SceneContextJson,
bool SplitValid);
private sealed class TokenTotals
{
public int? InputTokens { get; private set; }
public int? OutputTokens { get; private set; }
public int? TotalTokens => InputTokens.HasValue || OutputTokens.HasValue ? (InputTokens ?? 0) + (OutputTokens ?? 0) : null;
public void Add(StoryIntelligenceClientResult result)
{
InputTokens = SumNullable(InputTokens, result.InputTokens);
OutputTokens = SumNullable(OutputTokens, result.OutputTokens);
}
private static int? SumNullable(int? current, int? next)
=> current.HasValue || next.HasValue ? (current ?? 0) + (next ?? 0) : null;
}
}

View File

@ -0,0 +1,30 @@
namespace PlotLine.Services;
public sealed class PersistedStoryIntelligenceWorker(
IServiceScopeFactory scopeFactory,
ILogger<PersistedStoryIntelligenceWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var runner = scope.ServiceProvider.GetRequiredService<IPersistedStoryIntelligenceRunner>();
await runner.ProcessNextAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
logger.LogError(ex, "Persisted Story Intelligence worker failed while processing the next queued run.");
}
await timer.WaitForNextTickAsync(stoppingToken);
}
}
}

View File

@ -8,12 +8,15 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceResultPersistenceService public interface IStoryIntelligenceResultPersistenceService
{ {
Task<int> SaveDisplayedRunAsync(int userId, string payload); Task<int> SaveDisplayedRunAsync(int userId, string payload);
Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form);
Task CancelRunAsync(int runId);
Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync(); Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync();
Task<StoryIntelligenceSavedRunDetailViewModel?> GetRunDetailAsync(int runId); Task<StoryIntelligenceSavedRunDetailViewModel?> GetRunDetailAsync(int runId);
} }
public sealed class StoryIntelligenceResultPersistenceService( public sealed class StoryIntelligenceResultPersistenceService(
IStoryIntelligenceResultRepository repository, IStoryIntelligenceResultRepository repository,
IStoryIntelligenceClient client,
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@ -57,6 +60,34 @@ public sealed class StoryIntelligenceResultPersistenceService(
return runId; return runId;
} }
public async Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form)
{
if (string.IsNullOrWhiteSpace(form.ChapterText))
{
throw new InvalidOperationException("Enter chapter text before queueing a persisted Story Intelligence run.");
}
var promptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1";
var clientStatus = client.GetConfigurationStatus();
return await repository.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{
UserID = userId,
ProjectID = form.ProjectID,
BookID = form.BookID,
SourceType = "AdminText",
SourceLabel = CleanSourceLabel(form.SourceLabel),
ChapterText = form.ChapterText,
SourceWordCount = CountWords(form.ChapterText),
SourceCharacterCount = form.ChapterText.Length,
SourceChapterCount = 1,
Model = clientStatus.Model,
PromptVersionsSummary = promptVersionsSummary
});
}
public Task CancelRunAsync(int runId)
=> repository.RequestCancelAsync(runId);
public async Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync() public async Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync()
=> new() { Runs = await repository.ListRunsAsync() }; => new() { Runs = await repository.ListRunsAsync() };

View File

@ -0,0 +1,311 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceLabel') IS NULL
ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceLabel nvarchar(300) NULL;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceText') IS NULL
ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceText nvarchar(max) NULL;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'CurrentStage') IS NULL
ALTER TABLE dbo.StoryIntelligenceRuns ADD CurrentStage nvarchar(80) NULL;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'CurrentMessage') IS NULL
ALTER TABLE dbo.StoryIntelligenceRuns ADD CurrentMessage nvarchar(400) NULL;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'TotalDetectedScenes') IS NULL
ALTER TABLE dbo.StoryIntelligenceRuns ADD TotalDetectedScenes int NULL;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'CompletedScenes') IS NULL
ALTER TABLE dbo.StoryIntelligenceRuns ADD CompletedScenes int NULL;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'FailedScenes') IS NULL
ALTER TABLE dbo.StoryIntelligenceRuns ADD FailedScenes int NULL;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'CancellationRequestedUtc') IS NULL
ALTER TABLE dbo.StoryIntelligenceRuns ADD CancellationRequestedUtc datetime2 NULL;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'CancelledUtc') IS NULL
ALTER TABLE dbo.StoryIntelligenceRuns ADD CancelledUtc datetime2 NULL;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_QueueAdminText
@UserID int,
@ProjectID int = NULL,
@BookID int = NULL,
@SourceType nvarchar(50),
@SourceLabel nvarchar(300),
@SourceText nvarchar(max),
@SourceWordCount int = NULL,
@SourceCharacterCount int = NULL,
@SourceChapterCount int = NULL,
@Model nvarchar(100),
@PromptVersionsSummary nvarchar(500)
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.StoryIntelligenceRuns
(
UserID, ProjectID, BookID, Status, SourceType, SourceLabel, SourceText,
SourceWordCount, SourceCharacterCount, SourceChapterCount, Model,
PromptVersion, PromptVersionsSummary, StartedUtc, CurrentStage, CurrentMessage,
CompletedScenes, FailedScenes
)
VALUES
(
@UserID, @ProjectID, @BookID, N'Pending', @SourceType, @SourceLabel, @SourceText,
@SourceWordCount, @SourceCharacterCount, @SourceChapterCount, @Model,
@PromptVersionsSummary, @PromptVersionsSummary, SYSUTCDATETIME(), N'Pending',
N'Queued for Story Intelligence processing.', 0, 0
);
SELECT CAST(SCOPE_IDENTITY() AS int) AS StoryIntelligenceRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ClaimNextPending
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @StoryIntelligenceRunID int;
SELECT TOP (1) @StoryIntelligenceRunID = StoryIntelligenceRunID
FROM dbo.StoryIntelligenceRuns WITH (UPDLOCK, READPAST)
WHERE Status = N'Pending'
ORDER BY CreatedUtc;
IF @StoryIntelligenceRunID IS NULL
BEGIN
SELECT TOP (0)
StoryIntelligenceRunID, UserID, ProjectID, BookID, Status, SourceType, SourceLabel,
SourceText, SourceWordCount, SourceCharacterCount, SourceChapterCount, PromptVersion,
PromptVersionsSummary, 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;
UPDATE dbo.StoryIntelligenceRuns
SET Status = N'Running',
StartedUtc = SYSUTCDATETIME(),
CurrentStage = N'ChapterStructure',
CurrentMessage = N'Running Chapter Structure analysis.',
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status = N'Pending';
SELECT
StoryIntelligenceRunID, UserID, ProjectID, BookID, Status, SourceType, SourceLabel,
SourceText, SourceWordCount, SourceCharacterCount, SourceChapterCount, PromptVersion,
PromptVersionsSummary, 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_UpdateProgress
@StoryIntelligenceRunID int,
@Status nvarchar(50) = NULL,
@CurrentStage nvarchar(80) = NULL,
@CurrentMessage nvarchar(400) = NULL,
@TotalDetectedScenes int = NULL,
@CompletedScenes int = NULL,
@FailedScenes int = NULL,
@TotalInputTokens int = NULL,
@TotalOutputTokens int = NULL,
@TotalTokens int = NULL,
@TotalDurationMs bigint = NULL,
@EstimatedCostGBP decimal(19,6) = NULL,
@EstimatedCostUSD decimal(19,6) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.StoryIntelligenceRuns
SET Status = COALESCE(@Status, Status),
CurrentStage = COALESCE(@CurrentStage, CurrentStage),
CurrentMessage = COALESCE(@CurrentMessage, CurrentMessage),
TotalDetectedScenes = COALESCE(@TotalDetectedScenes, TotalDetectedScenes),
CompletedScenes = COALESCE(@CompletedScenes, CompletedScenes),
FailedScenes = COALESCE(@FailedScenes, FailedScenes),
TotalInputTokens = COALESCE(@TotalInputTokens, TotalInputTokens),
TotalOutputTokens = COALESCE(@TotalOutputTokens, TotalOutputTokens),
TotalTokens = COALESCE(@TotalTokens, TotalTokens),
TotalDurationMs = COALESCE(@TotalDurationMs, TotalDurationMs),
EstimatedCostGBP = COALESCE(@EstimatedCostGBP, EstimatedCostGBP),
EstimatedCostUSD = COALESCE(@EstimatedCostUSD, EstimatedCostUSD),
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status NOT IN (N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled');
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_Complete
@StoryIntelligenceRunID int,
@Status nvarchar(50),
@TotalInputTokens int = NULL,
@TotalOutputTokens int = NULL,
@TotalTokens int = NULL,
@TotalDurationMs bigint = NULL,
@EstimatedCostGBP decimal(19,6) = NULL,
@EstimatedCostUSD decimal(19,6) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.StoryIntelligenceRuns
SET Status = @Status,
CompletedUtc = SYSUTCDATETIME(),
CurrentStage = N'Completed',
CurrentMessage = CASE WHEN @Status = N'CompletedWithWarnings'
THEN N'Story Intelligence completed with warnings.'
ELSE N'Story Intelligence completed.'
END,
TotalInputTokens = COALESCE(@TotalInputTokens, TotalInputTokens),
TotalOutputTokens = COALESCE(@TotalOutputTokens, TotalOutputTokens),
TotalTokens = COALESCE(@TotalTokens, TotalTokens),
TotalDurationMs = COALESCE(@TotalDurationMs, TotalDurationMs),
EstimatedCostGBP = COALESCE(@EstimatedCostGBP, EstimatedCostGBP),
EstimatedCostUSD = COALESCE(@EstimatedCostUSD, EstimatedCostUSD),
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status NOT IN (N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled');
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_Fail
@StoryIntelligenceRunID int,
@FailureStage nvarchar(80),
@ErrorMessage nvarchar(max),
@ErrorDetail nvarchar(max) = NULL,
@TotalDurationMs bigint = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.StoryIntelligenceRuns
SET Status = N'Failed',
CompletedUtc = SYSUTCDATETIME(),
FailureStage = @FailureStage,
ErrorMessage = @ErrorMessage,
ErrorDetail = @ErrorDetail,
CurrentStage = @FailureStage,
CurrentMessage = N'Story Intelligence failed.',
TotalDurationMs = COALESCE(@TotalDurationMs, TotalDurationMs),
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status NOT IN (N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled');
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_RequestCancel
@StoryIntelligenceRunID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.StoryIntelligenceRuns
SET CancellationRequestedUtc = COALESCE(CancellationRequestedUtc, SYSUTCDATETIME()),
CurrentMessage = CASE WHEN Status = N'Pending'
THEN N'Cancellation requested before processing.'
ELSE N'Cancellation requested. Processing will stop at the next safe checkpoint.'
END,
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status IN (N'Pending', N'Running');
UPDATE dbo.StoryIntelligenceRuns
SET Status = N'Cancelled',
CompletedUtc = SYSUTCDATETIME(),
CancelledUtc = SYSUTCDATETIME(),
CurrentStage = N'Cancelled',
CurrentMessage = N'Story Intelligence run was cancelled before processing.',
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status = N'Pending';
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_Cancel
@StoryIntelligenceRunID int,
@TotalDurationMs bigint = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.StoryIntelligenceRuns
SET Status = N'Cancelled',
CompletedUtc = SYSUTCDATETIME(),
CancelledUtc = SYSUTCDATETIME(),
CurrentStage = N'Cancelled',
CurrentMessage = N'Story Intelligence run was cancelled.',
TotalDurationMs = COALESCE(@TotalDurationMs, TotalDurationMs),
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status NOT IN (N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled');
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_GetAdmin
@StoryIntelligenceRunID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
r.StoryIntelligenceRunID,
r.UserID,
r.ProjectID,
p.ProjectName AS ProjectTitle,
r.BookID,
b.BookTitle,
r.Status,
r.SourceType,
r.SourceFileName,
r.SourceFileSizeBytes,
r.SourceWordCount,
r.SourceCharacterCount,
r.SourceChapterCount,
r.SourceDetectedImagesCount,
r.SourceDetectedTablesCount,
r.SourceDetectedFootnotesCount,
r.SourceDetectedCommentsCount,
r.CurrentStage,
r.CurrentMessage,
r.TotalDetectedScenes,
r.CompletedScenes,
r.FailedScenes,
r.CancellationRequestedUtc,
r.CancelledUtc,
r.PromptVersion,
r.PromptVersionsSummary,
r.Model,
r.StartedUtc,
r.CompletedUtc,
r.FailureStage,
r.TotalInputTokens,
r.TotalOutputTokens,
r.TotalTokens,
r.TotalDurationMs,
r.EstimatedCostGBP,
r.EstimatedCostUSD,
r.ErrorMessage,
r.ErrorDetail,
r.CreatedUtc,
r.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.StoryIntelligenceRunID = @StoryIntelligenceRunID;
END;
GO

View File

@ -63,6 +63,7 @@
</div> </div>
<div class="button-row mt-3"> <div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Run combined test</button> <button class="btn btn-primary" type="submit">Run combined test</button>
<button class="btn btn-outline-primary" type="submit" formaction="@Url.Action("QueueChapterStoryIntelligenceRun")">Queue persisted run</button>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Back to diagnostics</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Back to diagnostics</a>
</div> </div>
</form> </form>

View File

@ -2,6 +2,13 @@
@{ @{
ViewData["Title"] = "Story Intelligence Run"; ViewData["Title"] = "Story Intelligence Run";
var run = Model.Run; var run = Model.Run;
var canCancel = run is not null && (run.Status == StoryIntelligenceRunStatuses.Pending || run.Status == StoryIntelligenceRunStatuses.Running);
var shouldRefresh = run is not null && (canCancel || run.Status == "Running");
}
@if (shouldRefresh)
{
<meta http-equiv="refresh" content="10" />
} }
<div class="page-heading compact"> <div class="page-heading compact">
@ -64,6 +71,16 @@ else
</div> </div>
<dl class="mt-3 mb-0"> <dl class="mt-3 mb-0">
<dt>Current stage</dt>
<dd>@Display(run.CurrentStage)</dd>
<dt>Current message</dt>
<dd>@Display(run.CurrentMessage)</dd>
<dt>Scene progress</dt>
<dd>
Detected: @Display(run.TotalDetectedScenes),
completed: @Display(run.CompletedScenes),
failed: @Display(run.FailedScenes)
</dd>
<dt>Source type</dt> <dt>Source type</dt>
<dd>@run.SourceType</dd> <dd>@run.SourceType</dd>
<dt>Source file</dt> <dt>Source file</dt>
@ -97,6 +114,10 @@ else
<dd>@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd> <dd>@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd>
<dt>Completed</dt> <dt>Completed</dt>
<dd>@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd> <dd>@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
<dt>Cancellation requested</dt>
<dd>@(run.CancellationRequestedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
<dt>Cancelled</dt>
<dd>@(run.CancelledUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
<dt>Failure stage</dt> <dt>Failure stage</dt>
<dd>@Display(run.FailureStage)</dd> <dd>@Display(run.FailureStage)</dd>
<dt>Input tokens</dt> <dt>Input tokens</dt>
@ -110,6 +131,13 @@ else
<dt>Error detail</dt> <dt>Error detail</dt>
<dd><pre class="mb-0">@Display(run.ErrorDetail)</pre></dd> <dd><pre class="mb-0">@Display(run.ErrorDetail)</pre></dd>
</dl> </dl>
@if (canCancel)
{
<form asp-action="CancelStoryIntelligenceRun" asp-route-id="@run.StoryIntelligenceRunID" method="post" class="mt-3">
<button class="btn btn-outline-danger" type="submit">Cancel run</button>
</form>
}
</section> </section>
<section class="edit-panel"> <section class="edit-panel">