From 9f9688bcb5b4a0045105574189fb8ba07780db2f Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 4 Jul 2026 21:51:34 +0100 Subject: [PATCH] Phase 20P Implement a real persisted Story Intelligence job runner. --- PlotLine/Controllers/AdminController.cs | 23 + .../Data/StoryIntelligenceResultRepository.cs | 202 ++++++++ PlotLine/Models/StoryIntelligenceModels.cs | 7 + .../StoryIntelligencePersistenceModels.cs | 61 +++ PlotLine/Program.cs | 3 + .../PersistedStoryIntelligenceRunner.cs | 439 ++++++++++++++++++ .../PersistedStoryIntelligenceWorker.cs | 30 ++ ...oryIntelligenceResultPersistenceService.cs | 31 ++ ...se20K_PersistedStoryIntelligenceRunner.sql | 311 +++++++++++++ .../Admin/ChapterStoryIntelligenceTest.cshtml | 1 + .../Admin/StoryIntelligenceRunDetails.cshtml | 28 ++ 11 files changed, 1136 insertions(+) create mode 100644 PlotLine/Services/PersistedStoryIntelligenceRunner.cs create mode 100644 PlotLine/Services/PersistedStoryIntelligenceWorker.cs create mode 100644 PlotLine/Sql/117_Phase20K_PersistedStoryIntelligenceRunner.sql diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index 130c76c..849ef7b 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -93,6 +93,20 @@ public sealed class AdminController( return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); } + [HttpPost("chapter-story-intelligence-test/queue")] + [ValidateAntiForgeryToken] + public async Task 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")] public async Task StoryIntelligenceRuns() { @@ -106,6 +120,15 @@ public sealed class AdminController( return model is null ? NotFound() : View(model); } + [HttpPost("story-intelligence-runs/{id:int}/cancel")] + [ValidateAntiForgeryToken] + public async Task 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")] public async Task FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort) { diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index 2ac5103..be351e2 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -7,6 +7,28 @@ namespace PlotLine.Data; public interface IStoryIntelligenceResultRepository { Task SaveAsync(StoryIntelligenceRunSaveRequest request); + Task QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request); + Task 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 SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter); + Task SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene); Task> ListRunsAsync(); Task GetRunAsync(int runId); Task GetChapterResultAsync(int runId); @@ -131,6 +153,186 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn } } + public async Task QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "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 ClaimNextPendingAsync() + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "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 SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "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 SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "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> ListRunsAsync() { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/StoryIntelligenceModels.cs b/PlotLine/Models/StoryIntelligenceModels.cs index 4344e8c..1950e57 100644 --- a/PlotLine/Models/StoryIntelligenceModels.cs +++ b/PlotLine/Models/StoryIntelligenceModels.cs @@ -73,6 +73,13 @@ public sealed class StoryIntelligenceOptions 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 Guid RunId { get; init; } = Guid.NewGuid(); diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 826352c..8d900f7 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -55,6 +55,21 @@ public sealed class StoryIntelligenceRunSaveRequest public IReadOnlyList 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 int? ProjectID { get; init; } @@ -138,6 +153,13 @@ public sealed class StoryIntelligenceSavedRun public int? SourceDetectedTablesCount { get; init; } public int? SourceDetectedFootnotesCount { 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? PromptVersionsSummary { get; init; } public string Model { get; init; } = string.Empty; @@ -156,6 +178,45 @@ public sealed class StoryIntelligenceSavedRun 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 int ChapterResultID { get; init; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index ce1170e..9988ff8 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -42,6 +42,7 @@ public class Program builder.Services.Configure(builder.Configuration.GetSection("Stripe")); builder.Services.Configure(builder.Configuration.GetSection("Storage")); builder.Services.Configure(builder.Configuration.GetSection("OpenAI")); + builder.Services.Configure(builder.Configuration.GetSection("StoryIntelligence:Pricing")); builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) @@ -195,6 +196,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -210,6 +212,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs new file mode 100644 index 0000000..9621def --- /dev/null +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -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 pricingOptions, + ILogger 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(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(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 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(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 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? 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; + } +} diff --git a/PlotLine/Services/PersistedStoryIntelligenceWorker.cs b/PlotLine/Services/PersistedStoryIntelligenceWorker.cs new file mode 100644 index 0000000..a1f01aa --- /dev/null +++ b/PlotLine/Services/PersistedStoryIntelligenceWorker.cs @@ -0,0 +1,30 @@ +namespace PlotLine.Services; + +public sealed class PersistedStoryIntelligenceWorker( + IServiceScopeFactory scopeFactory, + ILogger 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(); + 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); + } + } +} diff --git a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs index fad9fa1..8a8400c 100644 --- a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs +++ b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs @@ -8,12 +8,15 @@ namespace PlotLine.Services; public interface IStoryIntelligenceResultPersistenceService { Task SaveDisplayedRunAsync(int userId, string payload); + Task QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form); + Task CancelRunAsync(int runId); Task ListRunsAsync(); Task GetRunDetailAsync(int runId); } public sealed class StoryIntelligenceResultPersistenceService( IStoryIntelligenceResultRepository repository, + IStoryIntelligenceClient client, ILogger logger) : IStoryIntelligenceResultPersistenceService { private static readonly JsonSerializerOptions JsonOptions = new() @@ -57,6 +60,34 @@ public sealed class StoryIntelligenceResultPersistenceService( return runId; } + public async Task 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 ListRunsAsync() => new() { Runs = await repository.ListRunsAsync() }; diff --git a/PlotLine/Sql/117_Phase20K_PersistedStoryIntelligenceRunner.sql b/PlotLine/Sql/117_Phase20K_PersistedStoryIntelligenceRunner.sql new file mode 100644 index 0000000..86a8ca3 --- /dev/null +++ b/PlotLine/Sql/117_Phase20K_PersistedStoryIntelligenceRunner.sql @@ -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 diff --git a/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml b/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml index 1590d03..b1d595d 100644 --- a/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml +++ b/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml @@ -63,6 +63,7 @@
+ Back to diagnostics
diff --git a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml index ecf7e03..34d77d8 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml @@ -2,6 +2,13 @@ @{ ViewData["Title"] = "Story Intelligence 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) +{ + }
@@ -64,6 +71,16 @@ else
+
Current stage
+
@Display(run.CurrentStage)
+
Current message
+
@Display(run.CurrentMessage)
+
Scene progress
+
+ Detected: @Display(run.TotalDetectedScenes), + completed: @Display(run.CompletedScenes), + failed: @Display(run.FailedScenes) +
Source type
@run.SourceType
Source file
@@ -97,6 +114,10 @@ else
@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC
Completed
@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC
+
Cancellation requested
+
@(run.CancellationRequestedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC
+
Cancelled
+
@(run.CancelledUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC
Failure stage
@Display(run.FailureStage)
Input tokens
@@ -110,6 +131,13 @@ else
Error detail
@Display(run.ErrorDetail)
+ + @if (canCancel) + { +
+ +
+ }