Phase 20R reliability/admin test harness, not a new product feature.
This commit is contained in:
parent
7ccf334c93
commit
ebeca1e3e1
@ -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()
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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,
|
||||
|
||||
238
PlotLine/Services/StoryIntelligenceFullChapterTestService.cs
Normal file
238
PlotLine/Services/StoryIntelligenceFullChapterTestService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
38
PlotLine/Sql/119_Phase20R_FullChapterTestHarness.sql
Normal file
38
PlotLine/Sql/119_Phase20R_FullChapterTestHarness.sql
Normal 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
|
||||
@ -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>
|
||||
|
||||
134
PlotLine/Views/Admin/StoryIntelligenceFullChapterTest.cshtml
Normal file
134
PlotLine/Views/Admin/StoryIntelligenceFullChapterTest.cshtml
Normal 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";
|
||||
}
|
||||
@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
"OpenAI": {
|
||||
"Model": "gpt-5",
|
||||
"MaxOutputTokens": 4000,
|
||||
"TimeoutSeconds": 120
|
||||
"TimeoutSeconds": 300
|
||||
},
|
||||
"Site": {
|
||||
"PublicBaseUrl": "https://plotdirector.com"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user