From ebeca1e3e115c6d3acfba4e0fb0fe5b65d51cb29 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 4 Jul 2026 22:15:53 +0100 Subject: [PATCH] Phase 20R reliability/admin test harness, not a new product feature. --- PlotLine/Controllers/AdminController.cs | 47 +++- .../Data/StoryIntelligenceResultRepository.cs | 2 + .../StoryIntelligencePersistenceModels.cs | 32 +++ PlotLine/Program.cs | 1 + .../PersistedStoryIntelligenceRunner.cs | 21 +- ...StoryIntelligenceFullChapterTestService.cs | 238 ++++++++++++++++++ .../119_Phase20R_FullChapterTestHarness.sql | 38 +++ PlotLine/Views/Admin/Index.cshtml | 1 + .../StoryIntelligenceFullChapterTest.cshtml | 134 ++++++++++ .../Admin/StoryIntelligenceRunDetails.cshtml | 52 ++++ .../Views/Admin/StoryIntelligenceRuns.cshtml | 1 + PlotLine/appsettings.json | 2 +- 12 files changed, 565 insertions(+), 4 deletions(-) create mode 100644 PlotLine/Services/StoryIntelligenceFullChapterTestService.cs create mode 100644 PlotLine/Sql/119_Phase20R_FullChapterTestHarness.sql create mode 100644 PlotLine/Views/Admin/StoryIntelligenceFullChapterTest.cshtml diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index 95573a0..f7c021d 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -17,7 +17,8 @@ public sealed class AdminController( IChapterStructureDryRunService chapterStructureDryRun, IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun, IStoryIntelligenceResultPersistenceService storyIntelligenceResults, - IStoryIntelligenceExistingChapterQueueService existingChapterQueue) : Controller + IStoryIntelligenceExistingChapterQueueService existingChapterQueue, + IStoryIntelligenceFullChapterTestService fullChapterTest) : Controller { [HttpGet("")] [HttpGet("index")] @@ -133,6 +134,50 @@ public sealed class AdminController( return View(nameof(StoryIntelligenceExistingChapter), model); } + [HttpGet("story-intelligence-full-chapter-test")] + public IActionResult StoryIntelligenceFullChapterTest() + { + return View(fullChapterTest.GetDefault()); + } + + [HttpPost("story-intelligence-full-chapter-test")] + [ValidateAntiForgeryToken] + [RequestSizeLimit(5 * 1024 * 1024)] + public async Task StoryIntelligenceFullChapterTest( + [Bind(Prefix = "Form")] StoryIntelligenceFullChapterTestForm form, + IFormFile? textFile, + string submitAction) + { + if (string.Equals(submitAction, "Queue", StringComparison.OrdinalIgnoreCase)) + { + if (currentUser.UserId is not int userId) + { + return Forbid(); + } + + try + { + var runId = await fullChapterTest.QueueAsync(userId, form, textFile); + TempData["AdminMessage"] = $"Full chapter Story Intelligence run {runId:N0} queued. The browser does not need to remain open."; + return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); + } + catch (InvalidOperationException ex) + { + var model = await fullChapterTest.AnalyzeAsync(form, textFile); + return View(new StoryIntelligenceFullChapterTestViewModel + { + Form = model.Form, + Preflight = model.Preflight, + SourceFileName = model.SourceFileName, + SourceFileSizeBytes = model.SourceFileSizeBytes, + ErrorMessage = ex.Message + }); + } + } + + return View(await fullChapterTest.AnalyzeAsync(form, textFile)); + } + [HttpGet("story-intelligence-runs")] public async Task StoryIntelligenceRuns() { diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index 5dbb392..58ee0a7 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -168,6 +168,8 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn request.SourceType, request.SourceLabel, SourceText = request.ChapterText, + request.SourceFileName, + request.SourceFileSizeBytes, request.SourceWordCount, request.SourceCharacterCount, request.SourceChapterCount, diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index b66ce5d..51916f7 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -65,6 +65,8 @@ public sealed class StoryIntelligenceRunQueueRequest public string SourceType { get; init; } = "AdminText"; public string SourceLabel { get; init; } = string.Empty; public string ChapterText { get; init; } = string.Empty; + public string? SourceFileName { get; init; } + public long? SourceFileSizeBytes { get; init; } public int? SourceWordCount { get; init; } public int? SourceCharacterCount { get; init; } public int? SourceChapterCount { get; init; } @@ -267,6 +269,36 @@ public sealed class StoryIntelligenceExistingChapterQueueViewModel public int? QueuedRunID { get; init; } } +public sealed class StoryIntelligenceFullChapterTestForm +{ + public string? SourceLabel { get; init; } + public int? ProjectID { get; init; } + public int? BookID { get; init; } + public int? ChapterID { get; init; } + public string? ChapterText { get; init; } +} + +public sealed class StoryIntelligenceFullChapterPreflight +{ + public int CharacterCount { get; init; } + public int WordCount { get; init; } + public int ParagraphCount { get; init; } + public int EstimatedInputTokens { get; init; } + public int EstimatedPipelineInputTokens { get; init; } + public decimal? EstimatedInputCostUSD { get; init; } + public decimal? EstimatedInputCostGBP { get; init; } + public IReadOnlyList Warnings { get; init; } = []; +} + +public sealed class StoryIntelligenceFullChapterTestViewModel +{ + public StoryIntelligenceFullChapterTestForm Form { get; init; } = new(); + public StoryIntelligenceFullChapterPreflight? Preflight { get; init; } + public string? SourceFileName { get; init; } + public long? SourceFileSizeBytes { get; init; } + public string? ErrorMessage { get; init; } +} + public sealed class StoryIntelligenceSavedChapterResult { public int ChapterResultID { get; init; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 083d51e..f329096 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -198,6 +198,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(); diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 15cfccc..1b5b932 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -54,6 +54,7 @@ public sealed class PersistedStoryIntelligenceRunner( var failedScenes = 0; var completedScenes = 0; var hasWarnings = false; + var currentFailureStage = StoryIntelligenceFailureStages.DocumentRead; try { @@ -76,6 +77,7 @@ public sealed class PersistedStoryIntelligenceRunner( var chapterContextJson = BuildChapterContextJson(run); var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, run.SourceText); + currentFailureStage = StoryIntelligenceFailureStages.ChapterStructure; await repository.UpdateProgressAsync( run.StoryIntelligenceRunID, @@ -123,6 +125,7 @@ public sealed class PersistedStoryIntelligenceRunner( .Select(boundary => BuildSceneBlock(run, paragraphs, boundary)) .ToList(); failedScenes += sceneBlocks.Count(block => !block.SplitValid); + currentFailureStage = StoryIntelligenceFailureStages.SceneSplit; await repository.UpdateProgressAsync( run.StoryIntelligenceRunID, @@ -157,6 +160,7 @@ public sealed class PersistedStoryIntelligenceRunner( try { + currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence; var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText); var sceneClientResult = await client.ExecutePromptAsync(completedScenePrompt, scenePromptVersion, cancellationToken); totals.Add(sceneClientResult); @@ -230,10 +234,19 @@ public sealed class PersistedStoryIntelligenceRunner( EstimateGbp(totals), EstimateUsd(totals)); } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + catch (StoryIntelligenceRunCancelledException) { await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds); } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + await repository.FailRunAsync( + run.StoryIntelligenceRunID, + currentFailureStage, + "Story Intelligence request timed out or was cancelled before the run completed.", + ex.ToString(), + stopwatch.ElapsedMilliseconds); + } catch (StoryIntelligenceRunFailureException ex) { await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds); @@ -256,7 +269,7 @@ public sealed class PersistedStoryIntelligenceRunner( if (latest?.CancellationRequestedUtc.HasValue == true || string.Equals(latest?.Status, StoryIntelligenceRunStatuses.Cancelled, StringComparison.Ordinal)) { await repository.CancelRunAsync(runId, elapsedMs); - throw new OperationCanceledException(); + throw new StoryIntelligenceRunCancelledException(); } } @@ -415,6 +428,10 @@ public sealed class PersistedStoryIntelligenceRunner( public string? Detail { get; } = detail; } + private sealed class StoryIntelligenceRunCancelledException : Exception + { + } + private sealed record StorySceneTextBlock( int TemporarySceneNumber, int StartParagraph, diff --git a/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs b/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs new file mode 100644 index 0000000..2e192ab --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs @@ -0,0 +1,238 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceFullChapterTestService +{ + StoryIntelligenceFullChapterTestViewModel GetDefault(); + Task AnalyzeAsync(StoryIntelligenceFullChapterTestForm form, IFormFile? textFile); + Task QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile); +} + +public sealed class StoryIntelligenceFullChapterTestService( + IStoryIntelligenceResultRepository runs, + IStoryIntelligenceClient client, + IOptions pricingOptions, + ILogger logger) : IStoryIntelligenceFullChapterTestService +{ + private const int LargeChapterCharacterThreshold = 40000; + private const int VeryLargeChapterCharacterThreshold = 60000; + private const int LowParagraphCountThreshold = 5; + private const int MaxUploadBytes = 2 * 1024 * 1024; + private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1"; + private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value; + + public StoryIntelligenceFullChapterTestViewModel GetDefault() + => new(); + + public async Task AnalyzeAsync(StoryIntelligenceFullChapterTestForm form, IFormFile? textFile) + { + var source = await ReadSourceAsync(form, textFile); + if (!string.IsNullOrWhiteSpace(source.ErrorMessage)) + { + return new StoryIntelligenceFullChapterTestViewModel + { + Form = form, + ErrorMessage = source.ErrorMessage + }; + } + + return BuildViewModel(form, source, queued: false); + } + + public async Task QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile) + { + var source = await ReadSourceAsync(form, textFile); + if (!string.IsNullOrWhiteSpace(source.ErrorMessage)) + { + throw new InvalidOperationException(source.ErrorMessage); + } + + if (string.IsNullOrWhiteSpace(source.Text)) + { + throw new InvalidOperationException("Paste chapter text or upload a .txt chapter file before queueing."); + } + + var preflight = Analyze(source.Text); + var clientStatus = client.GetConfigurationStatus(); + var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest + { + UserID = userId, + ProjectID = form.ProjectID, + BookID = form.BookID, + ChapterID = form.ChapterID, + SourceType = "AdminFullChapterText", + SourceLabel = CleanSourceLabel(form.SourceLabel, source.SourceFileName), + ChapterText = source.Text, + SourceFileName = source.SourceFileName, + SourceFileSizeBytes = source.SourceFileSizeBytes, + SourceWordCount = preflight.WordCount, + SourceCharacterCount = preflight.CharacterCount, + SourceChapterCount = 1, + Model = clientStatus.Model, + PromptVersionsSummary = PromptVersionsSummary + }); + + logger.LogInformation( + "Queued full chapter Story Intelligence test run {StoryIntelligenceRunID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} CharacterCount={CharacterCount} WordCount={WordCount} ParagraphCount={ParagraphCount} EstimatedInputTokens={EstimatedInputTokens} SourceFileName={SourceFileName}", + runId, + userId, + form.ProjectID, + form.BookID, + form.ChapterID, + preflight.CharacterCount, + preflight.WordCount, + preflight.ParagraphCount, + preflight.EstimatedInputTokens, + source.SourceFileName); + + return runId; + } + + private StoryIntelligenceFullChapterTestViewModel BuildViewModel( + StoryIntelligenceFullChapterTestForm form, + FullChapterSource source, + bool queued) + => new() + { + Form = new StoryIntelligenceFullChapterTestForm + { + SourceLabel = form.SourceLabel, + ProjectID = form.ProjectID, + BookID = form.BookID, + ChapterID = form.ChapterID, + ChapterText = queued ? string.Empty : source.Text + }, + Preflight = string.IsNullOrWhiteSpace(source.Text) ? null : Analyze(source.Text), + SourceFileName = source.SourceFileName, + SourceFileSizeBytes = source.SourceFileSizeBytes + }; + + private StoryIntelligenceFullChapterPreflight Analyze(string text) + { + var characterCount = text.Length; + var wordCount = CountWords(text); + var paragraphCount = SplitParagraphs(text).Count; + var estimatedInputTokens = EstimateTokens(text); + var estimatedPipelineInputTokens = estimatedInputTokens * 2; + var warnings = BuildWarnings(text, characterCount, paragraphCount); + + return new StoryIntelligenceFullChapterPreflight + { + CharacterCount = characterCount, + WordCount = wordCount, + ParagraphCount = paragraphCount, + EstimatedInputTokens = estimatedInputTokens, + EstimatedPipelineInputTokens = estimatedPipelineInputTokens, + EstimatedInputCostUSD = EstimateInputCost(estimatedPipelineInputTokens, pricing.InputPerMillionUsd), + EstimatedInputCostGBP = EstimateInputCost(estimatedPipelineInputTokens, pricing.InputPerMillionUsd, pricing.UsdToGbpRate), + Warnings = warnings + }; + } + + private async Task ReadSourceAsync(StoryIntelligenceFullChapterTestForm form, IFormFile? textFile) + { + if (textFile is not null && textFile.Length > 0) + { + if (!textFile.FileName.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) + { + return FullChapterSource.Error("Only .txt uploads are supported for the full chapter test harness."); + } + + if (textFile.Length > MaxUploadBytes) + { + return FullChapterSource.Error("The uploaded file is too large for this test harness. Use a .txt file up to 2 MB."); + } + + await using var stream = textFile.OpenReadStream(); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + var text = await reader.ReadToEndAsync(); + return new FullChapterSource(text, Path.GetFileName(textFile.FileName), textFile.Length, null); + } + + return new FullChapterSource(form.ChapterText?.Trim() ?? string.Empty, null, null, null); + } + + private static IReadOnlyList BuildWarnings(string text, int characterCount, int paragraphCount) + { + var warnings = new List(); + if (characterCount >= VeryLargeChapterCharacterThreshold) + { + warnings.Add("This is a very large chapter. Processing may take several minutes and may expose prompt, model, or timeout limits."); + } + else if (characterCount >= LargeChapterCharacterThreshold) + { + warnings.Add("This is a large chapter. Monitor the persisted run for timeout, validation, and scene split behaviour."); + } + + if (paragraphCount > 0 && paragraphCount < LowParagraphCountThreshold) + { + warnings.Add("The paragraph count is very low. Chapter Structure depends on clear paragraph boundaries, so scene splitting may be poor."); + } + + if (text.Any(ch => char.IsControl(ch) && ch is not '\r' and not '\n' and not '\t')) + { + warnings.Add("The text contains unusual control characters. Remove binary or corrupted content before queueing if possible."); + } + + if (text.Contains('\uFFFD')) + { + warnings.Add("The text contains replacement characters, which may indicate an encoding problem."); + } + + if (Regex.IsMatch(text, "<(html|body|script|style|table|div|span)\\b", RegexOptions.IgnoreCase)) + { + warnings.Add("The text appears to contain HTML or markup. Plain manuscript text is safer for this test."); + } + + var longestLine = text.Split(["\r\n", "\n", "\r"], StringSplitOptions.None).DefaultIfEmpty(string.Empty).Max(line => line.Length); + if (longestLine > 8000) + { + warnings.Add("The text contains an unusually long line. Paragraph breaks may not have been preserved."); + } + + return warnings; + } + + private static string CleanSourceLabel(string? sourceLabel, string? fileName) + => !string.IsNullOrWhiteSpace(sourceLabel) + ? sourceLabel.Trim() + : !string.IsNullOrWhiteSpace(fileName) + ? $"Full chapter upload: {fileName}" + : "Admin full chapter test"; + + private static int CountWords(string value) + => string.IsNullOrWhiteSpace(value) + ? 0 + : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + + private static IReadOnlyList SplitParagraphs(string value) + => value.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries) + .Select(paragraph => paragraph.Trim()) + .Where(paragraph => !string.IsNullOrWhiteSpace(paragraph)) + .ToList(); + + private static int EstimateTokens(string value) + => Math.Max(1, (int)Math.Ceiling(value.Length / 4m)); + + private static decimal? EstimateInputCost(int tokens, decimal? inputPerMillionUsd, decimal? usdToGbp = null) + { + if (!inputPerMillionUsd.HasValue) + { + return null; + } + + var usd = tokens / 1_000_000m * inputPerMillionUsd.Value; + return usdToGbp.HasValue ? usd * usdToGbp.Value : usd; + } + + private sealed record FullChapterSource(string Text, string? SourceFileName, long? SourceFileSizeBytes, string? ErrorMessage) + { + public static FullChapterSource Error(string message) => new(string.Empty, null, null, message); + } +} diff --git a/PlotLine/Sql/119_Phase20R_FullChapterTestHarness.sql b/PlotLine/Sql/119_Phase20R_FullChapterTestHarness.sql new file mode 100644 index 0000000..0259300 --- /dev/null +++ b/PlotLine/Sql/119_Phase20R_FullChapterTestHarness.sql @@ -0,0 +1,38 @@ +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_QueueAdminText + @UserID int, + @ProjectID int = NULL, + @BookID int = NULL, + @ChapterID int = NULL, + @ChapterNumber decimal(9, 2) = NULL, + @SourceType nvarchar(50), + @SourceLabel nvarchar(300), + @SourceText nvarchar(max), + @SourceFileName nvarchar(260) = NULL, + @SourceFileSizeBytes bigint = NULL, + @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, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, SourceText, + SourceFileName, SourceFileSizeBytes, SourceWordCount, SourceCharacterCount, SourceChapterCount, Model, + PromptVersion, PromptVersionsSummary, StartedUtc, CurrentStage, CurrentMessage, + CompletedScenes, FailedScenes + ) + VALUES + ( + @UserID, @ProjectID, @BookID, @ChapterID, @ChapterNumber, N'Pending', @SourceType, @SourceLabel, @SourceText, + @SourceFileName, @SourceFileSizeBytes, @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 diff --git a/PlotLine/Views/Admin/Index.cshtml b/PlotLine/Views/Admin/Index.cshtml index dddd770..1c94461 100644 --- a/PlotLine/Views/Admin/Index.cshtml +++ b/PlotLine/Views/Admin/Index.cshtml @@ -85,6 +85,7 @@ Dry run Chapter test Pipeline test + Full chapter test Queue chapter Saved runs diff --git a/PlotLine/Views/Admin/StoryIntelligenceFullChapterTest.cshtml b/PlotLine/Views/Admin/StoryIntelligenceFullChapterTest.cshtml new file mode 100644 index 0000000..6a30700 --- /dev/null +++ b/PlotLine/Views/Admin/StoryIntelligenceFullChapterTest.cshtml @@ -0,0 +1,134 @@ +@model StoryIntelligenceFullChapterTestViewModel +@{ + ViewData["Title"] = "Full Chapter Story Intelligence Test"; + var canQueue = Model.Preflight is not null && string.IsNullOrWhiteSpace(Model.ErrorMessage); +} + +
+
+

Admin utility

+

Full chapter test

+

Queue one large chapter into the persisted Story Intelligence runner without changing PlotDirector story data.

+
+
+ + + +@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage)) +{ +
@Model.ErrorMessage
+} + +
+
+ @Html.AntiForgeryToken() + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + @if (!string.IsNullOrWhiteSpace(Model.SourceFileName)) + { +

Loaded @Model.SourceFileName (@DisplayBytes(Model.SourceFileSizeBytes)).

+ } +
+ +
+ + +
+ +
+ + + Open monitor +
+
+
+ +@if (Model.Preflight is not null) +{ +
+

Pre-flight

+
+
+
+

Characters

+

@Model.Preflight.CharacterCount.ToString("N0")

+
+
+
+
+

Words

+

@Model.Preflight.WordCount.ToString("N0")

+
+
+
+
+

Paragraphs

+

@Model.Preflight.ParagraphCount.ToString("N0")

+
+
+
+
+

Input tokens

+

@Model.Preflight.EstimatedInputTokens.ToString("N0")

+
+
+
+ +
+
Estimated pipeline input tokens
+
@Model.Preflight.EstimatedPipelineInputTokens.ToString("N0")
+
Estimated input cost
+
@DisplayCost(Model.Preflight.EstimatedInputCostGBP, "GBP") / @DisplayCost(Model.Preflight.EstimatedInputCostUSD, "USD")
+
+ + @if (Model.Preflight.Warnings.Count > 0) + { +
+
    + @foreach (var warning in Model.Preflight.Warnings) + { +
  • @warning
  • + } +
+
+ } + else + { +
No pre-flight warnings detected.
+ } +
+} + +@functions { + private static string DisplayBytes(long? bytes) + => bytes.HasValue ? $"{bytes.Value:N0} bytes" : "None"; + + private static string DisplayCost(decimal? value, string currency) + => value.HasValue ? $"{value.Value:0.000000} {currency}" : "Not configured"; +} diff --git a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml index 7299bdc..75ffcb0 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml @@ -75,6 +75,8 @@ else
@Display(run.CurrentStage)
Current message
@Display(run.CurrentMessage)
+
Chapter structure status
+
@ChapterStructureStatus(Model.ChapterResult, run)
Scene progress
Detected: @Display(run.TotalDetectedScenes), @@ -196,6 +198,15 @@ else
@scene.PromptVersion
Validation
@scene.ValidationErrorsCount.ToString("N0") error(s), @scene.ValidationWarningsCount.ToString("N0") warning(s)
+ @if (IsFailedScene(scene)) + { +
Failure stage
+
@SceneFailureStage(scene)
+
Error message
+
@SceneErrorMessage(scene)
+
Retry attempted
+
No
+ }
Duration
@DisplayDuration(scene.DurationMs)
Tokens
@@ -248,4 +259,45 @@ else ? $"Chapter {chapterNumber.Value:0.##} (ID {chapterId.Value:N0})" : $"Chapter ID {chapterId.Value:N0}" : "None"; + + private static string ChapterStructureStatus(StoryIntelligenceSavedChapterResult? chapter, StoryIntelligenceSavedRun run) + { + if (chapter is not null) + { + return chapter.ValidationErrorsCount > 0 + ? $"Saved with {chapter.ValidationErrorsCount:N0} validation error(s)." + : "Saved."; + } + + return string.Equals(run.FailureStage, StoryIntelligenceFailureStages.ChapterStructure, StringComparison.Ordinal) + ? "Failed before a valid chapter structure result was saved." + : "Not saved yet."; + } + + private static bool IsFailedScene(StoryIntelligenceSavedSceneResult scene) + => scene.ValidationErrorsCount > 0 + && string.IsNullOrWhiteSpace(scene.RawResponseJson) + && !string.IsNullOrWhiteSpace(scene.ParsedJson); + + private static string SceneFailureStage(StoryIntelligenceSavedSceneResult scene) + => SceneErrorMessage(scene).Contains("boundary", StringComparison.OrdinalIgnoreCase) + ? StoryIntelligenceFailureStages.SceneSplit + : StoryIntelligenceFailureStages.SceneIntelligence; + + private static string SceneErrorMessage(StoryIntelligenceSavedSceneResult scene) + { + try + { + using var document = System.Text.Json.JsonDocument.Parse(scene.ParsedJson); + if (document.RootElement.TryGetProperty("error", out var error) && error.ValueKind == System.Text.Json.JsonValueKind.String) + { + return error.GetString() ?? "Scene failed."; + } + } + catch (System.Text.Json.JsonException) + { + } + + return "Scene failed. See parsed JSON for details."; + } } diff --git a/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml index 17b5d6b..5edc9c9 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml @@ -26,6 +26,7 @@
diff --git a/PlotLine/appsettings.json b/PlotLine/appsettings.json index 2be337f..3be68e2 100644 --- a/PlotLine/appsettings.json +++ b/PlotLine/appsettings.json @@ -22,7 +22,7 @@ "OpenAI": { "Model": "gpt-5", "MaxOutputTokens": 4000, - "TimeoutSeconds": 120 + "TimeoutSeconds": 300 }, "Site": { "PublicBaseUrl": "https://plotdirector.com"