Phase 20R reliability/admin test harness, not a new product feature.

This commit is contained in:
Nick Beckley 2026-07-04 22:15:53 +01:00
parent 7ccf334c93
commit ebeca1e3e1
12 changed files with 565 additions and 4 deletions

View File

@ -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<IActionResult> 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<IActionResult> StoryIntelligenceRuns()
{

View File

@ -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,

View File

@ -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<string> 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; }

View File

@ -198,6 +198,7 @@ public class Program
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();

View File

@ -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,

View File

@ -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<StoryIntelligenceFullChapterTestViewModel> AnalyzeAsync(StoryIntelligenceFullChapterTestForm form, IFormFile? textFile);
Task<int> QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile);
}
public sealed class StoryIntelligenceFullChapterTestService(
IStoryIntelligenceResultRepository runs,
IStoryIntelligenceClient client,
IOptions<StoryIntelligencePricingOptions> pricingOptions,
ILogger<StoryIntelligenceFullChapterTestService> 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<StoryIntelligenceFullChapterTestViewModel> 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<int> 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<FullChapterSource> 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<string> BuildWarnings(string text, int characterCount, int paragraphCount)
{
var warnings = new List<string>();
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<string> 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);
}
}

View File

@ -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

View File

@ -85,6 +85,7 @@
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceTest">Dry run</a>
<a class="btn btn-outline-primary btn-sm" asp-action="ChapterStructureTest">Chapter test</a>
<a class="btn btn-outline-primary btn-sm" asp-action="ChapterStoryIntelligenceTest">Pipeline test</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceFullChapterTest">Full chapter test</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceExistingChapter">Queue chapter</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRuns">Saved runs</a>
</div>

View File

@ -0,0 +1,134 @@
@model StoryIntelligenceFullChapterTestViewModel
@{
ViewData["Title"] = "Full Chapter Story Intelligence Test";
var canQueue = Model.Preflight is not null && string.IsNullOrWhiteSpace(Model.ErrorMessage);
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin utility</p>
<h1>Full chapter test</h1>
<p class="lead-text">Queue one large chapter into the persisted Story Intelligence runner without changing PlotDirector story data.</p>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<span class="muted"> / </span>
<a asp-action="Index">Admin</a>
<span class="muted"> / Full chapter test</span>
</nav>
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<div class="alert alert-danger">@Model.ErrorMessage</div>
}
<section class="edit-panel">
<form asp-action="StoryIntelligenceFullChapterTest" method="post" enctype="multipart/form-data">
@Html.AntiForgeryToken()
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Form.SourceLabel">Source label</label>
<input class="form-control" asp-for="Form.SourceLabel" placeholder="Optional label for this test run" />
</div>
<div class="col-md-2">
<label class="form-label" asp-for="Form.ProjectID">Project ID</label>
<input class="form-control" asp-for="Form.ProjectID" />
</div>
<div class="col-md-2">
<label class="form-label" asp-for="Form.BookID">Book ID</label>
<input class="form-control" asp-for="Form.BookID" />
</div>
<div class="col-md-2">
<label class="form-label" asp-for="Form.ChapterID">Chapter ID</label>
<input class="form-control" asp-for="Form.ChapterID" />
</div>
</div>
<div class="mt-3">
<label class="form-label" for="textFile">Upload .txt file</label>
<input class="form-control" id="textFile" name="textFile" type="file" accept=".txt,text/plain" />
@if (!string.IsNullOrWhiteSpace(Model.SourceFileName))
{
<p class="form-text mb-0">Loaded @Model.SourceFileName (@DisplayBytes(Model.SourceFileSizeBytes)).</p>
}
</div>
<div class="mt-3">
<label class="form-label" asp-for="Form.ChapterText">Chapter text</label>
<textarea class="form-control font-monospace" asp-for="Form.ChapterText" rows="24" maxlength="500000"></textarea>
</div>
<div class="button-row mt-3">
<button class="btn btn-outline-primary" type="submit" name="submitAction" value="Analyze">Analyze pre-flight</button>
<button class="btn btn-primary" type="submit" name="submitAction" value="Queue" disabled="@(!canQueue)">Queue persisted run</button>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRuns">Open monitor</a>
</div>
</form>
</section>
@if (Model.Preflight is not null)
{
<section class="edit-panel">
<h2>Pre-flight</h2>
<div class="row g-3">
<div class="col-sm-6 col-lg-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Characters</p>
<h3 class="mb-0">@Model.Preflight.CharacterCount.ToString("N0")</h3>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Words</p>
<h3 class="mb-0">@Model.Preflight.WordCount.ToString("N0")</h3>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Paragraphs</p>
<h3 class="mb-0">@Model.Preflight.ParagraphCount.ToString("N0")</h3>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Input tokens</p>
<h3 class="mb-0">@Model.Preflight.EstimatedInputTokens.ToString("N0")</h3>
</div>
</div>
</div>
<dl class="mt-3 mb-0">
<dt>Estimated pipeline input tokens</dt>
<dd>@Model.Preflight.EstimatedPipelineInputTokens.ToString("N0")</dd>
<dt>Estimated input cost</dt>
<dd>@DisplayCost(Model.Preflight.EstimatedInputCostGBP, "GBP") / @DisplayCost(Model.Preflight.EstimatedInputCostUSD, "USD")</dd>
</dl>
@if (Model.Preflight.Warnings.Count > 0)
{
<div class="alert alert-warning mt-3 mb-0">
<ul class="mb-0">
@foreach (var warning in Model.Preflight.Warnings)
{
<li>@warning</li>
}
</ul>
</div>
}
else
{
<div class="alert alert-success mt-3 mb-0">No pre-flight warnings detected.</div>
}
</section>
}
@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";
}

View File

@ -75,6 +75,8 @@ else
<dd>@Display(run.CurrentStage)</dd>
<dt>Current message</dt>
<dd>@Display(run.CurrentMessage)</dd>
<dt>Chapter structure status</dt>
<dd>@ChapterStructureStatus(Model.ChapterResult, run)</dd>
<dt>Scene progress</dt>
<dd>
Detected: @Display(run.TotalDetectedScenes),
@ -196,6 +198,15 @@ else
<dd>@scene.PromptVersion</dd>
<dt>Validation</dt>
<dd>@scene.ValidationErrorsCount.ToString("N0") error(s), @scene.ValidationWarningsCount.ToString("N0") warning(s)</dd>
@if (IsFailedScene(scene))
{
<dt>Failure stage</dt>
<dd>@SceneFailureStage(scene)</dd>
<dt>Error message</dt>
<dd>@SceneErrorMessage(scene)</dd>
<dt>Retry attempted</dt>
<dd>No</dd>
}
<dt>Duration</dt>
<dd>@DisplayDuration(scene.DurationMs)</dd>
<dt>Tokens</dt>
@ -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.";
}
}

View File

@ -26,6 +26,7 @@
<section class="edit-panel">
<div class="button-row mb-3">
<a class="btn btn-primary" asp-action="ChapterStoryIntelligenceTest">Run pipeline test</a>
<a class="btn btn-outline-primary" asp-action="StoryIntelligenceFullChapterTest">Full chapter test</a>
<a class="btn btn-outline-primary" asp-action="StoryIntelligenceExistingChapter">Queue from existing chapter</a>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Diagnostics</a>
</div>

View File

@ -22,7 +22,7 @@
"OpenAI": {
"Model": "gpt-5",
"MaxOutputTokens": 4000,
"TimeoutSeconds": 120
"TimeoutSeconds": 300
},
"Site": {
"PublicBaseUrl": "https://plotdirector.com"