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,
|
IChapterStructureDryRunService chapterStructureDryRun,
|
||||||
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun,
|
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun,
|
||||||
IStoryIntelligenceResultPersistenceService storyIntelligenceResults,
|
IStoryIntelligenceResultPersistenceService storyIntelligenceResults,
|
||||||
IStoryIntelligenceExistingChapterQueueService existingChapterQueue) : Controller
|
IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
|
||||||
|
IStoryIntelligenceFullChapterTestService fullChapterTest) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
[HttpGet("index")]
|
[HttpGet("index")]
|
||||||
@ -133,6 +134,50 @@ public sealed class AdminController(
|
|||||||
return View(nameof(StoryIntelligenceExistingChapter), model);
|
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")]
|
[HttpGet("story-intelligence-runs")]
|
||||||
public async Task<IActionResult> StoryIntelligenceRuns()
|
public async Task<IActionResult> StoryIntelligenceRuns()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -168,6 +168,8 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
request.SourceType,
|
request.SourceType,
|
||||||
request.SourceLabel,
|
request.SourceLabel,
|
||||||
SourceText = request.ChapterText,
|
SourceText = request.ChapterText,
|
||||||
|
request.SourceFileName,
|
||||||
|
request.SourceFileSizeBytes,
|
||||||
request.SourceWordCount,
|
request.SourceWordCount,
|
||||||
request.SourceCharacterCount,
|
request.SourceCharacterCount,
|
||||||
request.SourceChapterCount,
|
request.SourceChapterCount,
|
||||||
|
|||||||
@ -65,6 +65,8 @@ public sealed class StoryIntelligenceRunQueueRequest
|
|||||||
public string SourceType { get; init; } = "AdminText";
|
public string SourceType { get; init; } = "AdminText";
|
||||||
public string SourceLabel { get; init; } = string.Empty;
|
public string SourceLabel { get; init; } = string.Empty;
|
||||||
public string ChapterText { 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? SourceWordCount { get; init; }
|
||||||
public int? SourceCharacterCount { get; init; }
|
public int? SourceCharacterCount { get; init; }
|
||||||
public int? SourceChapterCount { get; init; }
|
public int? SourceChapterCount { get; init; }
|
||||||
@ -267,6 +269,36 @@ public sealed class StoryIntelligenceExistingChapterQueueViewModel
|
|||||||
public int? QueuedRunID { get; init; }
|
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 sealed class StoryIntelligenceSavedChapterResult
|
||||||
{
|
{
|
||||||
public int ChapterResultID { get; init; }
|
public int ChapterResultID { get; init; }
|
||||||
|
|||||||
@ -198,6 +198,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
||||||
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
||||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
|
|||||||
@ -54,6 +54,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
var failedScenes = 0;
|
var failedScenes = 0;
|
||||||
var completedScenes = 0;
|
var completedScenes = 0;
|
||||||
var hasWarnings = false;
|
var hasWarnings = false;
|
||||||
|
var currentFailureStage = StoryIntelligenceFailureStages.DocumentRead;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -76,6 +77,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
var chapterContextJson = BuildChapterContextJson(run);
|
var chapterContextJson = BuildChapterContextJson(run);
|
||||||
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
|
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
|
||||||
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, run.SourceText);
|
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, run.SourceText);
|
||||||
|
currentFailureStage = StoryIntelligenceFailureStages.ChapterStructure;
|
||||||
|
|
||||||
await repository.UpdateProgressAsync(
|
await repository.UpdateProgressAsync(
|
||||||
run.StoryIntelligenceRunID,
|
run.StoryIntelligenceRunID,
|
||||||
@ -123,6 +125,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
.Select(boundary => BuildSceneBlock(run, paragraphs, boundary))
|
.Select(boundary => BuildSceneBlock(run, paragraphs, boundary))
|
||||||
.ToList();
|
.ToList();
|
||||||
failedScenes += sceneBlocks.Count(block => !block.SplitValid);
|
failedScenes += sceneBlocks.Count(block => !block.SplitValid);
|
||||||
|
currentFailureStage = StoryIntelligenceFailureStages.SceneSplit;
|
||||||
|
|
||||||
await repository.UpdateProgressAsync(
|
await repository.UpdateProgressAsync(
|
||||||
run.StoryIntelligenceRunID,
|
run.StoryIntelligenceRunID,
|
||||||
@ -157,6 +160,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence;
|
||||||
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText);
|
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText);
|
||||||
var sceneClientResult = await client.ExecutePromptAsync(completedScenePrompt, scenePromptVersion, cancellationToken);
|
var sceneClientResult = await client.ExecutePromptAsync(completedScenePrompt, scenePromptVersion, cancellationToken);
|
||||||
totals.Add(sceneClientResult);
|
totals.Add(sceneClientResult);
|
||||||
@ -230,10 +234,19 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
EstimateGbp(totals),
|
EstimateGbp(totals),
|
||||||
EstimateUsd(totals));
|
EstimateUsd(totals));
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
catch (StoryIntelligenceRunCancelledException)
|
||||||
{
|
{
|
||||||
await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds);
|
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)
|
catch (StoryIntelligenceRunFailureException ex)
|
||||||
{
|
{
|
||||||
await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds);
|
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))
|
if (latest?.CancellationRequestedUtc.HasValue == true || string.Equals(latest?.Status, StoryIntelligenceRunStatuses.Cancelled, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
await repository.CancelRunAsync(runId, elapsedMs);
|
await repository.CancelRunAsync(runId, elapsedMs);
|
||||||
throw new OperationCanceledException();
|
throw new StoryIntelligenceRunCancelledException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -415,6 +428,10 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
public string? Detail { get; } = detail;
|
public string? Detail { get; } = detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class StoryIntelligenceRunCancelledException : Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record StorySceneTextBlock(
|
private sealed record StorySceneTextBlock(
|
||||||
int TemporarySceneNumber,
|
int TemporarySceneNumber,
|
||||||
int StartParagraph,
|
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="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="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="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="StoryIntelligenceExistingChapter">Queue chapter</a>
|
||||||
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRuns">Saved runs</a>
|
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRuns">Saved runs</a>
|
||||||
</div>
|
</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>
|
<dd>@Display(run.CurrentStage)</dd>
|
||||||
<dt>Current message</dt>
|
<dt>Current message</dt>
|
||||||
<dd>@Display(run.CurrentMessage)</dd>
|
<dd>@Display(run.CurrentMessage)</dd>
|
||||||
|
<dt>Chapter structure status</dt>
|
||||||
|
<dd>@ChapterStructureStatus(Model.ChapterResult, run)</dd>
|
||||||
<dt>Scene progress</dt>
|
<dt>Scene progress</dt>
|
||||||
<dd>
|
<dd>
|
||||||
Detected: @Display(run.TotalDetectedScenes),
|
Detected: @Display(run.TotalDetectedScenes),
|
||||||
@ -196,6 +198,15 @@ else
|
|||||||
<dd>@scene.PromptVersion</dd>
|
<dd>@scene.PromptVersion</dd>
|
||||||
<dt>Validation</dt>
|
<dt>Validation</dt>
|
||||||
<dd>@scene.ValidationErrorsCount.ToString("N0") error(s), @scene.ValidationWarningsCount.ToString("N0") warning(s)</dd>
|
<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>
|
<dt>Duration</dt>
|
||||||
<dd>@DisplayDuration(scene.DurationMs)</dd>
|
<dd>@DisplayDuration(scene.DurationMs)</dd>
|
||||||
<dt>Tokens</dt>
|
<dt>Tokens</dt>
|
||||||
@ -248,4 +259,45 @@ else
|
|||||||
? $"Chapter {chapterNumber.Value:0.##} (ID {chapterId.Value:N0})"
|
? $"Chapter {chapterNumber.Value:0.##} (ID {chapterId.Value:N0})"
|
||||||
: $"Chapter ID {chapterId.Value:N0}"
|
: $"Chapter ID {chapterId.Value:N0}"
|
||||||
: "None";
|
: "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">
|
<section class="edit-panel">
|
||||||
<div class="button-row mb-3">
|
<div class="button-row mb-3">
|
||||||
<a class="btn btn-primary" asp-action="ChapterStoryIntelligenceTest">Run pipeline test</a>
|
<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-primary" asp-action="StoryIntelligenceExistingChapter">Queue from existing chapter</a>
|
||||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Diagnostics</a>
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Diagnostics</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
"OpenAI": {
|
"OpenAI": {
|
||||||
"Model": "gpt-5",
|
"Model": "gpt-5",
|
||||||
"MaxOutputTokens": 4000,
|
"MaxOutputTokens": 4000,
|
||||||
"TimeoutSeconds": 120
|
"TimeoutSeconds": 300
|
||||||
},
|
},
|
||||||
"Site": {
|
"Site": {
|
||||||
"PublicBaseUrl": "https://plotdirector.com"
|
"PublicBaseUrl": "https://plotdirector.com"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user