Phase 20R.3 This phase is an A/B model comparison test.

This commit is contained in:
Nick Beckley 2026-07-05 12:40:21 +01:00
parent a2d9cb7c0a
commit 0bc45de4d8
21 changed files with 1515 additions and 128 deletions

View File

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\PlotLine\PlotLine.csproj" />
</ItemGroup>
</Project>

122
PlotLine.Tests/Program.cs Normal file
View File

@ -0,0 +1,122 @@
using System.Text.Json;
using PlotLine.Models;
using PlotLine.Services;
var tests = new (string Name, Action Test)[]
{
("Malformed confidence 0. is repaired to 0.0", RepairsTrailingDecimalConfidence),
("Markdown fences are stripped", StripsMarkdownFence),
("Unescaped backslash in evidence is repaired", RepairsUnescapedBackslash),
("Truncated JSON is rejected", RejectsTruncatedJson),
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput)
};
foreach (var test in tests)
{
test.Test();
Console.WriteLine($"PASS {test.Name}");
}
static void RepairsTrailingDecimalConfidence()
{
var result = StoryIntelligenceJsonRepair.Repair("""{"observations":[{"confidence":0. }]}""");
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
Assert(result.RepairedJson.Contains("\"confidence\":0.0", StringComparison.Ordinal), result.RepairedJson);
}
static void StripsMarkdownFence()
{
var result = StoryIntelligenceJsonRepair.Repair("""
```json
{"schemaVersion":"1.0"}
```
""");
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
Assert(result.RepairedJson == """{"schemaVersion":"1.0"}""", result.RepairedJson);
}
static void RepairsUnescapedBackslash()
{
var result = StoryIntelligenceJsonRepair.Repair("""{"evidence":"Mara reads C:\qnotes"}""");
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
using var document = JsonDocument.Parse(result.RepairedJson);
var evidence = document.RootElement.GetProperty("evidence").GetString();
Assert(evidence == @"Mara reads C:\qnotes", evidence ?? "Evidence was null.");
}
static void RejectsTruncatedJson()
{
var result = StoryIntelligenceJsonRepair.Repair("""{"observations":[{"confidence":""");
Assert(!result.Success, "Truncated JSON should not be silently repaired.");
}
static void RepairedJsonDeserialises()
{
var result = StoryIntelligenceJsonRepair.Repair("""
{
"schemaVersion": "1.0",
"sceneReference": { "sceneId": null },
"summary": { "short": "A scene.", "detailed": "A scene.", "confidence": 0. },
"observations": []
}
""");
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
var parsed = JsonSerializer.Deserialize<SceneIntelligenceScene>(
result.RepairedJson,
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
});
Assert(parsed is not null, "Scene JSON did not deserialise.");
}
static void ChapterStructureConfidenceRepairs()
{
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(
"""
{
"schemaVersion": "1.0",
"chapterSummary": "A chapter.",
"sceneBoundaries": [
{
"sceneNumber": 1,
"startParagraph": 1,
"endParagraph": 2,
"confidence": 0. ,
"reason": "Opening scene."
}
]
}
""",
JsonOptions());
Assert(result.Success, result.ErrorMessage ?? "Chapter Structure parse failed.");
Assert(result.Parsed?.SceneBoundaries?[0].Confidence == 0.0m, "Confidence was not repaired to 0.0.");
Assert(result.Warnings.Any(warning => warning.Contains("$.sceneBoundaries[0].confidence", StringComparison.Ordinal)), "Repair warning did not include JSON path.");
}
static void SharedParserReportsRawOutput()
{
const string raw = """{"schemaVersion":"1.0","sceneBoundaries":[""";
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(raw, JsonOptions());
Assert(!result.Success, "Unrecoverable JSON should fail.");
Assert(result.RawJson == raw, "Raw malformed output was not retained.");
}
static JsonSerializerOptions JsonOptions()
=> new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
static void Assert(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}

View File

@ -1,3 +1,4 @@
<Solution> <Solution>
<Project Path="PlotLine/PlotLine.csproj" /> <Project Path="PlotLine/PlotLine.csproj" />
<Project Path="PlotLine.Tests/PlotLine.Tests.csproj" />
</Solution> </Solution>

View File

@ -135,9 +135,9 @@ public sealed class AdminController(
} }
[HttpGet("story-intelligence-full-chapter-test")] [HttpGet("story-intelligence-full-chapter-test")]
public IActionResult StoryIntelligenceFullChapterTest() public async Task<IActionResult> StoryIntelligenceFullChapterTest(int? sourceRunId)
{ {
return View(fullChapterTest.GetDefault()); return View(await fullChapterTest.GetDefaultAsync(sourceRunId));
} }
[HttpPost("story-intelligence-full-chapter-test")] [HttpPost("story-intelligence-full-chapter-test")]

View File

@ -55,6 +55,10 @@ Never invent paragraph numbers.
Never return an `endParagraph` greater than the highest paragraph number supplied. Never return an `endParagraph` greater than the highest paragraph number supplied.
Do not create a separate final scene for a closing line unless it corresponds to an actual supplied paragraph number. If the final beat belongs to the previous scene, include it in the previous scene.
Do not create placeholder scenes or invalid ranges to "ensure coverage". If the previous scene already ends at the final supplied paragraph, stop there.
Scene boundaries must: Scene boundaries must:
- start at paragraph `1`; - start at paragraph `1`;
@ -62,6 +66,8 @@ Scene boundaries must:
- not overlap; - not overlap;
- not leave gaps; - not leave gaps;
- be contiguous and non-overlapping where possible; - be contiguous and non-overlapping where possible;
- correspond to actual supplied paragraph numbers;
- never use a `startParagraph` greater than `endParagraph`;
- use increasing `sceneNumber` values starting at `1`. - use increasing `sceneNumber` values starting at `1`.
## Output Requirements ## Output Requirements

View File

@ -69,15 +69,99 @@ public sealed class StoryIntelligenceOptions
{ {
public string ApiKey { get; init; } = string.Empty; public string ApiKey { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty; public string Model { get; init; } = string.Empty;
public string ChapterStructureModel { get; init; } = string.Empty;
public string SceneIntelligenceModel { get; init; } = string.Empty;
public int MaxOutputTokens { get; init; } = 4000; public int MaxOutputTokens { get; init; } = 4000;
public int? SceneIntelligenceMaxOutputTokens { get; init; }
public int TimeoutSeconds { get; init; } = 120; public int TimeoutSeconds { get; init; } = 120;
public string EffectiveModel => FirstConfigured(Model);
public string EffectiveChapterStructureModel => FirstConfigured(ChapterStructureModel, Model);
public string EffectiveSceneIntelligenceModel => FirstConfigured(SceneIntelligenceModel, Model);
public static string BuildModelSummary(string? chapterStructureModel, string? sceneIntelligenceModel)
=> $"Chapter={FirstConfigured(chapterStructureModel)}; Scene={FirstConfigured(sceneIntelligenceModel)}";
public static StoryIntelligenceStageModels ParseModelSummary(
string? modelSummary,
string defaultChapterStructureModel,
string defaultSceneIntelligenceModel)
{
var chapterModel = defaultChapterStructureModel;
var sceneModel = defaultSceneIntelligenceModel;
if (!string.IsNullOrWhiteSpace(modelSummary))
{
var segments = modelSummary.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var matchedStage = false;
foreach (var segment in segments)
{
var parts = segment.Split('=', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[1]))
{
continue;
} }
if (string.Equals(parts[0], "Chapter", StringComparison.OrdinalIgnoreCase))
{
chapterModel = parts[1];
matchedStage = true;
}
else if (string.Equals(parts[0], "Scene", StringComparison.OrdinalIgnoreCase))
{
sceneModel = parts[1];
matchedStage = true;
}
}
if (!matchedStage && !string.IsNullOrWhiteSpace(modelSummary))
{
chapterModel = modelSummary.Trim();
sceneModel = modelSummary.Trim();
}
}
return new StoryIntelligenceStageModels(
FirstConfigured(chapterModel, defaultChapterStructureModel),
FirstConfigured(sceneModel, defaultSceneIntelligenceModel));
}
private static string FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
}
public sealed record StoryIntelligenceStageModels(string ChapterStructureModel, string SceneIntelligenceModel);
public sealed class StoryIntelligencePricingOptions public sealed class StoryIntelligencePricingOptions
{ {
public decimal? InputPerMillionUsd { get; init; } public decimal? InputPerMillionUsd { get; init; }
public decimal? OutputPerMillionUsd { get; init; } public decimal? OutputPerMillionUsd { get; init; }
public decimal? UsdToGbpRate { get; init; } public decimal? UsdToGbpRate { get; init; }
public Dictionary<string, StoryIntelligenceModelPricingOptions> Models { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public StoryIntelligenceModelPricingOptions? GetPricingForModel(string? model)
{
if (!string.IsNullOrWhiteSpace(model)
&& Models.TryGetValue(model.Trim(), out var modelPricing)
&& (modelPricing.InputPerMillionUsd.HasValue || modelPricing.OutputPerMillionUsd.HasValue))
{
return modelPricing;
}
return InputPerMillionUsd.HasValue || OutputPerMillionUsd.HasValue
? new StoryIntelligenceModelPricingOptions
{
InputPerMillionUsd = InputPerMillionUsd,
OutputPerMillionUsd = OutputPerMillionUsd
}
: null;
}
}
public sealed class StoryIntelligenceModelPricingOptions
{
public decimal? InputPerMillionUsd { get; init; }
public decimal? OutputPerMillionUsd { get; init; }
} }
public sealed class StoryIntelligenceRun public sealed class StoryIntelligenceRun

View File

@ -280,6 +280,8 @@ public sealed class StoryIntelligenceFullChapterTestForm
public int? ProjectID { get; init; } public int? ProjectID { get; init; }
public int? BookID { get; init; } public int? BookID { get; init; }
public int? ChapterID { get; init; } public int? ChapterID { get; init; }
public string? ChapterStructureModel { get; init; }
public string? SceneIntelligenceModel { get; init; }
public string? ChapterText { get; init; } public string? ChapterText { get; init; }
} }
@ -298,6 +300,7 @@ public sealed class StoryIntelligenceFullChapterPreflight
public sealed class StoryIntelligenceFullChapterTestViewModel public sealed class StoryIntelligenceFullChapterTestViewModel
{ {
public StoryIntelligenceFullChapterTestForm Form { get; init; } = new(); public StoryIntelligenceFullChapterTestForm Form { get; init; } = new();
public IReadOnlyList<string> ModelOptions { get; init; } = [];
public StoryIntelligenceFullChapterPreflight? Preflight { get; init; } public StoryIntelligenceFullChapterPreflight? Preflight { get; init; }
public string? SourceFileName { get; init; } public string? SourceFileName { get; init; }
public long? SourceFileSizeBytes { get; init; } public long? SourceFileSizeBytes { get; init; }

View File

@ -41,7 +41,11 @@ public class Program
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings")); builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe")); builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage")); builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage"));
builder.Services.Configure<StoryIntelligenceOptions>(builder.Configuration.GetSection("OpenAI")); builder.Services.Configure<StoryIntelligenceOptions>(options =>
{
builder.Configuration.GetSection("OpenAI").Bind(options);
builder.Configuration.GetSection("StoryIntelligence").Bind(options);
});
builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing")); builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing"));
builder.Services.AddDataProtection() builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));

View File

@ -52,13 +52,20 @@ public sealed class ChapterStoryIntelligenceDryRunService(
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs); var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText); var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText);
var chapterResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken); var chapterResult = await client.ExecutePromptAsync(
chapterPrompt,
chapterPromptVersion,
cancellationToken,
clientStatus.ChapterStructureModel);
var chapterJson = ExtractOutputText(chapterResult.RawResponseText); var chapterJson = ExtractOutputText(chapterResult.RawResponseText);
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions); var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
var chapterValidation = chapterValidator.Validate(parsedChapter, paragraphs.Count); var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count);
var chapterForProcessing = chapterNormalisation.ChapterStructure;
var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);
ChapterStructureBoundaryNormaliser.AddIssuesTo(chapterValidation, chapterNormalisation);
var sceneBlocks = new List<ChapterStorySceneBlockViewModel>(); var sceneBlocks = new List<ChapterStorySceneBlockViewModel>();
if (!chapterValidation.IsValid || parsedChapter?.SceneBoundaries is null) if (!chapterValidation.IsValid || chapterForProcessing?.SceneBoundaries is null)
{ {
totalStopwatch.Stop(); totalStopwatch.Stop();
var stoppedResult = new ChapterStoryIntelligenceDryRunResultViewModel var stoppedResult = new ChapterStoryIntelligenceDryRunResultViewModel
@ -68,7 +75,9 @@ public sealed class ChapterStoryIntelligenceDryRunService(
StopReason = "Chapter Structure validation failed. Scene Intelligence was not run.", StopReason = "Chapter Structure validation failed. Scene Intelligence was not run.",
ChapterPromptVersion = chapterPromptVersion, ChapterPromptVersion = chapterPromptVersion,
ScenePromptVersion = scenePromptVersion, ScenePromptVersion = scenePromptVersion,
Model = chapterResult.Model, Model = StoryIntelligenceOptions.BuildModelSummary(
chapterResult.Model,
clientStatus.SceneIntelligenceModel),
ChapterContextJson = chapterContextJson, ChapterContextJson = chapterContextJson,
ParagraphCount = paragraphs.Count, ParagraphCount = paragraphs.Count,
TotalDuration = FormatDuration(totalStopwatch.Elapsed), TotalDuration = FormatDuration(totalStopwatch.Elapsed),
@ -82,7 +91,7 @@ public sealed class ChapterStoryIntelligenceDryRunService(
ChapterOutputTokens = chapterResult.OutputTokens, ChapterOutputTokens = chapterResult.OutputTokens,
ChapterRawResponseText = chapterResult.RawResponseText, ChapterRawResponseText = chapterResult.RawResponseText,
ChapterJsonText = chapterJson, ChapterJsonText = chapterJson,
ParsedChapter = parsedChapter, ParsedChapter = chapterForProcessing,
ChapterValidation = chapterValidation, ChapterValidation = chapterValidation,
SceneBlocks = sceneBlocks SceneBlocks = sceneBlocks
}; };
@ -92,7 +101,7 @@ public sealed class ChapterStoryIntelligenceDryRunService(
} }
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
foreach (var boundary in parsedChapter.SceneBoundaries) foreach (var boundary in chapterForProcessing.SceneBoundaries)
{ {
var block = BuildSceneBlock(form, paragraphs, boundary); var block = BuildSceneBlock(form, paragraphs, boundary);
if (!block.SplitValid) if (!block.SplitValid)
@ -103,7 +112,13 @@ public sealed class ChapterStoryIntelligenceDryRunService(
var sceneContextJson = BuildSceneContextJson(form, boundary); var sceneContextJson = BuildSceneContextJson(form, boundary);
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, sceneContextJson, block.SceneText); var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, sceneContextJson, block.SceneText);
sceneBlocks.Add(await ExecuteSceneAsync(block, sceneContextJson, completedScenePrompt, scenePromptVersion, cancellationToken)); sceneBlocks.Add(await ExecuteSceneAsync(
block,
sceneContextJson,
completedScenePrompt,
scenePromptVersion,
clientStatus.SceneIntelligenceModel,
cancellationToken));
} }
totalStopwatch.Stop(); totalStopwatch.Stop();
@ -113,7 +128,9 @@ public sealed class ChapterStoryIntelligenceDryRunService(
PipelineStopped = false, PipelineStopped = false,
ChapterPromptVersion = chapterPromptVersion, ChapterPromptVersion = chapterPromptVersion,
ScenePromptVersion = scenePromptVersion, ScenePromptVersion = scenePromptVersion,
Model = chapterResult.Model, Model = StoryIntelligenceOptions.BuildModelSummary(
chapterResult.Model,
clientStatus.SceneIntelligenceModel),
ChapterContextJson = chapterContextJson, ChapterContextJson = chapterContextJson,
ParagraphCount = paragraphs.Count, ParagraphCount = paragraphs.Count,
TotalDuration = FormatDuration(totalStopwatch.Elapsed), TotalDuration = FormatDuration(totalStopwatch.Elapsed),
@ -127,7 +144,7 @@ public sealed class ChapterStoryIntelligenceDryRunService(
ChapterOutputTokens = chapterResult.OutputTokens, ChapterOutputTokens = chapterResult.OutputTokens,
ChapterRawResponseText = chapterResult.RawResponseText, ChapterRawResponseText = chapterResult.RawResponseText,
ChapterJsonText = chapterJson, ChapterJsonText = chapterJson,
ParsedChapter = parsedChapter, ParsedChapter = chapterForProcessing,
ChapterValidation = chapterValidation, ChapterValidation = chapterValidation,
SceneBlocks = sceneBlocks SceneBlocks = sceneBlocks
}; };
@ -145,7 +162,9 @@ public sealed class ChapterStoryIntelligenceDryRunService(
StopReason = "The combined pipeline could not complete.", StopReason = "The combined pipeline could not complete.",
ChapterPromptVersion = chapterPromptVersion, ChapterPromptVersion = chapterPromptVersion,
ScenePromptVersion = scenePromptVersion, ScenePromptVersion = scenePromptVersion,
Model = clientStatus.Model, Model = StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel),
ChapterContextJson = chapterContextJson, ChapterContextJson = chapterContextJson,
ParagraphCount = paragraphs.Count, ParagraphCount = paragraphs.Count,
TotalDuration = FormatDuration(totalStopwatch.Elapsed), TotalDuration = FormatDuration(totalStopwatch.Elapsed),
@ -158,7 +177,9 @@ public sealed class ChapterStoryIntelligenceDryRunService(
"Admin Chapter Story Intelligence pipeline failed. ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} TotalDurationMs={TotalDurationMs} Success={Success}", "Admin Chapter Story Intelligence pipeline failed. ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} TotalDurationMs={TotalDurationMs} Success={Success}",
chapterPromptVersion, chapterPromptVersion,
scenePromptVersion, scenePromptVersion,
clientStatus.Model, StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel),
totalStopwatch.ElapsedMilliseconds, totalStopwatch.ElapsedMilliseconds,
false); false);
} }
@ -171,11 +192,16 @@ public sealed class ChapterStoryIntelligenceDryRunService(
string sceneContextJson, string sceneContextJson,
string completedScenePrompt, string completedScenePrompt,
string scenePromptVersion, string scenePromptVersion,
string sceneModel,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
try try
{ {
var result = await client.ExecutePromptAsync(completedScenePrompt, scenePromptVersion, cancellationToken); var result = await client.ExecutePromptAsync(
completedScenePrompt,
scenePromptVersion,
cancellationToken,
sceneModel);
var sceneJson = ExtractOutputText(result.RawResponseText); var sceneJson = ExtractOutputText(result.RawResponseText);
var parsedScene = JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions); var parsedScene = JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions);
var validation = sceneValidator.Validate(parsedScene); var validation = sceneValidator.Validate(parsedScene);

View File

@ -0,0 +1,164 @@
using System.Text.Json;
using PlotLine.Models;
using PlotLine.Models.StoryIntelligence;
namespace PlotLine.Services;
public static class ChapterStructureBoundaryNormaliser
{
public static ChapterStructureBoundaryNormalisationResult Normalise(ChapterStructureModel? chapterStructure, int paragraphCount)
{
if (chapterStructure?.SceneBoundaries is null || chapterStructure.SceneBoundaries.Count < 2 || paragraphCount <= 0)
{
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
var boundaries = chapterStructure.SceneBoundaries;
var finalIndex = boundaries.Count - 1;
var final = boundaries[finalIndex];
var previous = boundaries[finalIndex - 1];
var phantomParagraph = paragraphCount + 1;
if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph)
|| IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph))
{
var normalisedBoundaries = boundaries
.Take(finalIndex)
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
.ToList();
var normalised = new ChapterStructureModel
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = normalisedBoundaries,
ExtensionData = chapterStructure.ExtensionData
};
var issue = new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{finalIndex}]",
Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph),
SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph."
};
return new ChapterStructureBoundaryNormalisationResult(normalised, [issue]);
}
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation)
{
foreach (var issue in normalisation.Issues)
{
validation.Warnings.Add(issue);
}
}
public static string SerialiseParsedModel(ChapterStructureModel? chapterStructure, ChapterStructureBoundaryNormalisationResult normalisation, JsonSerializerOptions jsonOptions)
{
if (chapterStructure is null)
{
return string.Empty;
}
if (normalisation.Issues.Count == 0)
{
return JsonSerializer.Serialize(chapterStructure, jsonOptions);
}
return JsonSerializer.Serialize(
new
{
chapterStructure.SchemaVersion,
chapterStructure.ChapterSummary,
chapterStructure.SceneBoundaries,
Normalisation = normalisation.Issues.Select(issue => new
{
issue.Severity,
issue.Path,
issue.Message,
issue.SuggestedFix
})
},
jsonOptions);
}
private static ChapterSceneBoundary CopyBoundary(ChapterSceneBoundary boundary, int sceneNumber)
=> new()
{
SceneNumber = sceneNumber,
StartParagraph = boundary.StartParagraph,
EndParagraph = boundary.EndParagraph,
Confidence = boundary.Confidence,
Reason = boundary.Reason,
ExtensionData = boundary.ExtensionData
};
private static bool IsTrailingEmptyBoundary(
ChapterSceneBoundary final,
ChapterSceneBoundary previous,
int paragraphCount,
int phantomParagraph)
=> final.StartParagraph == phantomParagraph
&& final.EndParagraph == phantomParagraph
&& previous.EndParagraph == paragraphCount
&& final.StartParagraph > paragraphCount
&& final.EndParagraph > paragraphCount;
private static bool IsTrailingInvalidPlaceholder(
ChapterSceneBoundary final,
ChapterSceneBoundary previous,
int paragraphCount,
int phantomParagraph)
{
if (!final.StartParagraph.HasValue || !final.EndParagraph.HasValue || previous.EndParagraph != paragraphCount)
{
return false;
}
var invalidTrailingRange = final.StartParagraph.Value > paragraphCount
&& (final.EndParagraph.Value <= paragraphCount
|| final.EndParagraph.Value == phantomParagraph
|| final.EndParagraph.Value < final.StartParagraph.Value);
return invalidTrailingRange && HasPlaceholderSignal(final);
}
private static bool HasPlaceholderSignal(ChapterSceneBoundary boundary)
{
if (boundary.Confidence <= 0.05m)
{
return true;
}
var reason = boundary.Reason ?? string.Empty;
return reason.Contains("invalid", StringComparison.OrdinalIgnoreCase)
|| reason.Contains("placeholder", StringComparison.OrdinalIgnoreCase)
|| reason.Contains("ensure coverage", StringComparison.OrdinalIgnoreCase)
|| reason.Contains("coverage", StringComparison.OrdinalIgnoreCase);
}
private static string BuildNormalisationMessage(
ChapterSceneBoundary final,
int finalIndex,
int paragraphCount,
int phantomParagraph)
{
var start = final.StartParagraph ?? 0;
var end = final.EndParagraph ?? 0;
if (start == phantomParagraph && end == phantomParagraph)
{
return $"Discarded trailing empty scene boundary {phantomParagraph}-{phantomParagraph} because submitted chapter has {paragraphCount} paragraphs and previous scene already ended at {paragraphCount}.";
}
var sceneNumber = final.SceneNumber ?? finalIndex + 1;
return $"Discarded trailing invalid placeholder scene {sceneNumber} with range {start}-{end} because previous scene already ended at paragraph {paragraphCount}.";
}
}
public sealed record ChapterStructureBoundaryNormalisationResult(
ChapterStructureModel? ChapterStructure,
IReadOnlyList<ValidationIssue> Issues);

View File

@ -19,6 +19,7 @@ public sealed class PersistedStoryIntelligenceRunner(
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IChapterStructureValidator chapterValidator, IChapterStructureValidator chapterValidator,
IStorySceneValidator sceneValidator, IStorySceneValidator sceneValidator,
IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions, IOptions<StoryIntelligencePricingOptions> pricingOptions,
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
{ {
@ -31,6 +32,7 @@ public sealed class PersistedStoryIntelligenceRunner(
WriteIndented = true WriteIndented = true
}; };
private readonly StoryIntelligenceOptions settings = options.Value;
private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value; private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value;
public async Task ProcessNextAsync(CancellationToken cancellationToken) public async Task ProcessNextAsync(CancellationToken cancellationToken)
@ -50,6 +52,7 @@ public sealed class PersistedStoryIntelligenceRunner(
var totals = new TokenTotals(); var totals = new TokenTotals();
var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile); var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile);
var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile); var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile);
var stageModels = ResolveStageModels(run);
int? chapterResultId = null; int? chapterResultId = null;
var failedScenes = 0; var failedScenes = 0;
var completedScenes = 0; var completedScenes = 0;
@ -86,11 +89,70 @@ public sealed class PersistedStoryIntelligenceRunner(
currentMessage: "Running Chapter Structure analysis.", currentMessage: "Running Chapter Structure analysis.",
totalDurationMs: stopwatch.ElapsedMilliseconds); totalDurationMs: stopwatch.ElapsedMilliseconds);
var chapterClientResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken); var chapterClientResult = await client.ExecutePromptAsync(
totals.Add(chapterClientResult); chapterPrompt,
var chapterJson = ExtractOutputText(chapterClientResult.RawResponseText); chapterPromptVersion,
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions); cancellationToken,
var chapterValidation = chapterValidator.Validate(parsedChapter, paragraphs.Count); stageModels.ChapterStructureModel);
totals.Add(chapterClientResult, pricing);
AiJsonParseResult<ChapterStructureModel> chapterAttempt;
try
{
chapterAttempt = await ParseAiJsonWithRetryAsync<ChapterStructureModel>(
chapterPrompt,
chapterPromptVersion,
stageModels.ChapterStructureModel,
chapterClientResult,
totals,
"Chapter Structure",
cancellationToken);
}
catch (AiJsonParseException ex)
{
await repository.SaveChapterResultAsync(
run.StoryIntelligenceRunID,
new StoryIntelligenceChapterResultSaveRequest
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
ChapterNumber = ChapterNumber(run),
SourceLabel = SourceLabel(run),
PromptVersion = chapterPromptVersion,
Model = stageModels.ChapterStructureModel,
RawResponseJson = ex.RawResponseJson,
OutputTextJson = ex.RawAssistantOutput,
ParsedJson = SerialiseFailedAiJsonParse(ex),
ValidationErrorsCount = 1,
ValidationWarningsCount = ex.RetryAttempted ? 1 : 0,
InputTokens = ex.InputTokens,
OutputTokens = ex.OutputTokens,
TotalTokens = SumTokens(ex.InputTokens, ex.OutputTokens),
DurationMs = Convert.ToInt64(ex.Duration.TotalMilliseconds)
});
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.Validation,
"Chapter Structure JSON could not be parsed after one retry.",
ex.ToString());
}
var parsedChapter = chapterAttempt.Parsed;
var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count);
var chapterForProcessing = chapterNormalisation.ChapterStructure;
var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);
foreach (var warning in chapterAttempt.Warnings)
{
chapterValidation.Warnings.Add(new PlotLine.Models.StoryIntelligence.ValidationIssue
{
Severity = "Warning",
Path = "$",
Message = warning,
SuggestedFix = "Review the raw and repaired Chapter Structure JSON."
});
}
ChapterStructureBoundaryNormaliser.AddIssuesTo(chapterValidation, chapterNormalisation);
hasWarnings = chapterValidation.Warnings.Count > 0; hasWarnings = chapterValidation.Warnings.Count > 0;
chapterResultId = await repository.SaveChapterResultAsync( chapterResultId = await repository.SaveChapterResultAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
@ -102,19 +164,19 @@ public sealed class PersistedStoryIntelligenceRunner(
ChapterNumber = ChapterNumber(run), ChapterNumber = ChapterNumber(run),
SourceLabel = SourceLabel(run), SourceLabel = SourceLabel(run),
PromptVersion = chapterPromptVersion, PromptVersion = chapterPromptVersion,
Model = chapterClientResult.Model, Model = chapterAttempt.Model,
RawResponseJson = chapterClientResult.RawResponseText, RawResponseJson = chapterAttempt.RawResponseJson,
OutputTextJson = chapterJson, OutputTextJson = chapterAttempt.RawAssistantOutput,
ParsedJson = SerializeParsed(parsedChapter), ParsedJson = SerialiseChapterParseAudit(chapterForProcessing, chapterNormalisation, chapterAttempt),
ValidationErrorsCount = chapterValidation.Errors.Count, ValidationErrorsCount = chapterValidation.Errors.Count,
ValidationWarningsCount = chapterValidation.Warnings.Count, ValidationWarningsCount = chapterValidation.Warnings.Count,
InputTokens = chapterClientResult.InputTokens, InputTokens = chapterAttempt.InputTokens,
OutputTokens = chapterClientResult.OutputTokens, OutputTokens = chapterAttempt.OutputTokens,
TotalTokens = SumTokens(chapterClientResult.InputTokens, chapterClientResult.OutputTokens), TotalTokens = SumTokens(chapterAttempt.InputTokens, chapterAttempt.OutputTokens),
DurationMs = Convert.ToInt64(chapterClientResult.Duration.TotalMilliseconds) DurationMs = Convert.ToInt64(chapterAttempt.Duration.TotalMilliseconds)
}); });
if (!chapterValidation.IsValid || parsedChapter?.SceneBoundaries is null) if (!chapterValidation.IsValid || chapterForProcessing?.SceneBoundaries is null)
{ {
throw new StoryIntelligenceRunFailureException( throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.ChapterStructure, StoryIntelligenceFailureStages.ChapterStructure,
@ -122,7 +184,7 @@ public sealed class PersistedStoryIntelligenceRunner(
BuildValidationDetail(chapterValidation)); BuildValidationDetail(chapterValidation));
} }
var sceneBlocks = parsedChapter.SceneBoundaries var sceneBlocks = chapterForProcessing.SceneBoundaries
.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);
@ -138,8 +200,8 @@ public sealed class PersistedStoryIntelligenceRunner(
totalOutputTokens: totals.OutputTokens, totalOutputTokens: totals.OutputTokens,
totalTokens: totals.TotalTokens, totalTokens: totals.TotalTokens,
totalDurationMs: stopwatch.ElapsedMilliseconds, totalDurationMs: stopwatch.ElapsedMilliseconds,
estimatedCostUSD: EstimateUsd(totals), estimatedCostUSD: totals.EstimatedCostUSD,
estimatedCostGBP: EstimateGbp(totals)); estimatedCostGBP: totals.EstimatedCostGBP);
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
foreach (var block in sceneBlocks) foreach (var block in sceneBlocks)
@ -163,11 +225,33 @@ public sealed class PersistedStoryIntelligenceRunner(
{ {
currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence; 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(
totals.Add(sceneClientResult); completedScenePrompt,
var sceneJson = ExtractOutputText(sceneClientResult.RawResponseText); scenePromptVersion,
var parsedScene = JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions); cancellationToken,
var validation = sceneValidator.Validate(parsedScene); stageModels.SceneIntelligenceModel,
SceneIntelligenceMaxOutputTokens());
totals.Add(sceneClientResult, pricing);
var sceneAttempt = await ParseAiJsonWithRetryAsync<SceneIntelligenceScene>(
completedScenePrompt,
scenePromptVersion,
stageModels.SceneIntelligenceModel,
sceneClientResult,
totals,
"Scene Intelligence",
cancellationToken);
var validation = sceneValidator.Validate(sceneAttempt.Parsed);
foreach (var warning in sceneAttempt.Warnings)
{
validation.Warnings.Add(new PlotLine.Models.StoryIntelligence.ValidationIssue
{
Severity = "Warning",
Path = "$",
Message = warning,
SuggestedFix = "Review the raw and repaired Scene Intelligence JSON."
});
}
hasWarnings = hasWarnings || validation.Warnings.Count > 0; hasWarnings = hasWarnings || validation.Warnings.Count > 0;
await repository.SaveSceneResultAsync( await repository.SaveSceneResultAsync(
@ -185,15 +269,15 @@ public sealed class PersistedStoryIntelligenceRunner(
SourceLabel = block.SourceLabel, SourceLabel = block.SourceLabel,
PromptVersion = scenePromptVersion, PromptVersion = scenePromptVersion,
Model = sceneClientResult.Model, Model = sceneClientResult.Model,
RawResponseJson = sceneClientResult.RawResponseText, RawResponseJson = sceneAttempt.RawResponseJson,
OutputTextJson = sceneJson, OutputTextJson = sceneAttempt.RawAssistantOutput,
ParsedJson = SerializeParsed(parsedScene), ParsedJson = SerialiseSceneParseAudit(sceneAttempt, validation.IsValid),
ValidationErrorsCount = validation.Errors.Count, ValidationErrorsCount = validation.Errors.Count,
ValidationWarningsCount = validation.Warnings.Count, ValidationWarningsCount = validation.Warnings.Count,
InputTokens = sceneClientResult.InputTokens, InputTokens = sceneAttempt.InputTokens,
OutputTokens = sceneClientResult.OutputTokens, OutputTokens = sceneAttempt.OutputTokens,
TotalTokens = SumTokens(sceneClientResult.InputTokens, sceneClientResult.OutputTokens), TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens),
DurationMs = Convert.ToInt64(sceneClientResult.Duration.TotalMilliseconds) DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds)
}); });
completedScenes++; completedScenes++;
@ -205,7 +289,7 @@ public sealed class PersistedStoryIntelligenceRunner(
catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex)) catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))
{ {
failedScenes++; failedScenes++;
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message); await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message, ex as AiJsonParseException);
logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber); logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber);
} }
@ -217,8 +301,8 @@ public sealed class PersistedStoryIntelligenceRunner(
totalOutputTokens: totals.OutputTokens, totalOutputTokens: totals.OutputTokens,
totalTokens: totals.TotalTokens, totalTokens: totals.TotalTokens,
totalDurationMs: stopwatch.ElapsedMilliseconds, totalDurationMs: stopwatch.ElapsedMilliseconds,
estimatedCostUSD: EstimateUsd(totals), estimatedCostUSD: totals.EstimatedCostUSD,
estimatedCostGBP: EstimateGbp(totals)); estimatedCostGBP: totals.EstimatedCostGBP);
} }
var finalStatus = failedScenes > 0 || hasWarnings var finalStatus = failedScenes > 0 || hasWarnings
@ -232,8 +316,8 @@ public sealed class PersistedStoryIntelligenceRunner(
totals.OutputTokens, totals.OutputTokens,
totals.TotalTokens, totals.TotalTokens,
stopwatch.ElapsedMilliseconds, stopwatch.ElapsedMilliseconds,
EstimateGbp(totals), totals.EstimatedCostGBP,
EstimateUsd(totals)); totals.EstimatedCostUSD);
} }
catch (StoryIntelligenceRunCancelledException) catch (StoryIntelligenceRunCancelledException)
{ {
@ -274,12 +358,99 @@ public sealed class PersistedStoryIntelligenceRunner(
} }
} }
private async Task<AiJsonParseResult<T>> ParseAiJsonWithRetryAsync<T>(
string completedPrompt,
string promptVersion,
string model,
StoryIntelligenceClientResult initialClientResult,
TokenTotals totals,
string label,
CancellationToken cancellationToken)
{
var initialRawOutput = TryExtractOutputText(initialClientResult.RawResponseText, out var initialExtractError);
var initialParse = StoryIntelligenceAiJsonParser.Parse<T>(initialRawOutput, JsonOptions);
if (initialParse.Success)
{
return AiJsonParseResult<T>.Success(
initialClientResult.Model,
initialClientResult.RawResponseText,
initialRawOutput,
initialParse.RawJson,
initialParse.RepairedJson,
initialParse.Parsed,
initialClientResult.InputTokens,
initialClientResult.OutputTokens,
initialClientResult.Duration,
retryAttempted: false,
warnings: initialParse.Warnings);
}
var initialError = initialExtractError ?? initialParse.ErrorMessage ?? $"{label} JSON could not be parsed.";
var retryPrompt = BuildJsonRetryPrompt(completedPrompt, label, initialError, initialRawOutput);
var retryClientResult = await client.ExecutePromptAsync(
retryPrompt,
promptVersion,
cancellationToken,
model,
MaxOutputTokensForLabel(label));
totals.Add(retryClientResult, pricing);
var retryRawOutput = TryExtractOutputText(retryClientResult.RawResponseText, out var retryExtractError);
var retryParse = StoryIntelligenceAiJsonParser.Parse<T>(retryRawOutput, JsonOptions);
if (retryParse.Success)
{
var warnings = new List<string> { $"{label} JSON parse failed once and succeeded after one retry." };
warnings.AddRange(initialParse.Warnings);
warnings.AddRange(retryParse.Warnings);
return AiJsonParseResult<T>.Success(
retryClientResult.Model,
CombineRawResponses(initialClientResult.RawResponseText, retryClientResult.RawResponseText),
CombineAssistantOutputs(initialRawOutput, retryRawOutput),
retryParse.RawJson,
retryParse.RepairedJson,
retryParse.Parsed,
SumTokens(initialClientResult.InputTokens, retryClientResult.InputTokens),
SumTokens(initialClientResult.OutputTokens, retryClientResult.OutputTokens),
initialClientResult.Duration + retryClientResult.Duration,
retryAttempted: true,
warnings: warnings,
initialError: initialError);
}
var retryError = retryExtractError ?? retryParse.ErrorMessage ?? $"{label} retry JSON could not be parsed.";
throw new AiJsonParseException(
$"{label} JSON could not be parsed after one retry.",
CombineRawResponses(initialClientResult.RawResponseText, retryClientResult.RawResponseText),
CombineAssistantOutputs(initialRawOutput, retryRawOutput),
initialError,
retryAttempted: true,
retryError: retryError,
inputTokens: SumTokens(initialClientResult.InputTokens, retryClientResult.InputTokens),
outputTokens: SumTokens(initialClientResult.OutputTokens, retryClientResult.OutputTokens),
duration: initialClientResult.Duration + retryClientResult.Duration);
}
private static string TryExtractOutputText(string rawResponseText, out string? error)
{
try
{
error = null;
return ExtractOutputText(rawResponseText);
}
catch (JsonException ex)
{
error = ex.Message;
return string.Empty;
}
}
private async Task PersistFailedSceneAsync( private async Task PersistFailedSceneAsync(
StoryIntelligenceQueuedRun run, StoryIntelligenceQueuedRun run,
int? chapterResultId, int? chapterResultId,
StorySceneTextBlock block, StorySceneTextBlock block,
string scenePromptVersion, string scenePromptVersion,
string message) string message,
AiJsonParseException? parseException = null)
{ {
await repository.SaveSceneResultAsync( await repository.SaveSceneResultAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
@ -295,32 +466,40 @@ public sealed class PersistedStoryIntelligenceRunner(
EndParagraph = block.EndParagraph, EndParagraph = block.EndParagraph,
SourceLabel = block.SourceLabel, SourceLabel = block.SourceLabel,
PromptVersion = scenePromptVersion, PromptVersion = scenePromptVersion,
Model = run.Model, Model = ResolveStageModels(run).SceneIntelligenceModel,
RawResponseJson = string.Empty, RawResponseJson = parseException?.RawResponseJson ?? string.Empty,
OutputTextJson = string.Empty, OutputTextJson = parseException?.RawAssistantOutput ?? string.Empty,
ParsedJson = JsonSerializer.Serialize(new { error = message }, JsonOptions), ParsedJson = JsonSerializer.Serialize(new
{
error = message,
parseError = parseException?.InitialError,
retryAttempted = parseException?.RetryAttempted ?? false,
retryError = parseException?.RetryError
}, JsonOptions),
ValidationErrorsCount = 1, ValidationErrorsCount = 1,
ValidationWarningsCount = 0 ValidationWarningsCount = parseException?.RetryAttempted == true ? 1 : 0,
InputTokens = parseException?.InputTokens,
OutputTokens = parseException?.OutputTokens,
TotalTokens = SumTokens(parseException?.InputTokens, parseException?.OutputTokens),
DurationMs = parseException is null ? null : Convert.ToInt64(parseException.Duration.TotalMilliseconds)
}); });
} }
private decimal? EstimateUsd(TokenTotals totals) private StoryIntelligenceStageModels ResolveStageModels(StoryIntelligenceQueuedRun run)
{ => StoryIntelligenceOptions.ParseModelSummary(
if (!pricing.InputPerMillionUsd.HasValue || !pricing.OutputPerMillionUsd.HasValue) run.Model,
{ string.IsNullOrWhiteSpace(settings.EffectiveChapterStructureModel) ? settings.EffectiveModel : settings.EffectiveChapterStructureModel,
return null; string.IsNullOrWhiteSpace(settings.EffectiveSceneIntelligenceModel) ? settings.EffectiveModel : settings.EffectiveSceneIntelligenceModel);
}
var inputCost = (totals.InputTokens ?? 0) / 1_000_000m * pricing.InputPerMillionUsd.Value; private int SceneIntelligenceMaxOutputTokens()
var outputCost = (totals.OutputTokens ?? 0) / 1_000_000m * pricing.OutputPerMillionUsd.Value; => settings.SceneIntelligenceMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens) > 0
return inputCost + outputCost; ? settings.SceneIntelligenceMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens)
} : settings.MaxOutputTokens;
private decimal? EstimateGbp(TokenTotals totals) private int MaxOutputTokensForLabel(string label)
{ => label.Contains("Scene", StringComparison.OrdinalIgnoreCase)
var usd = EstimateUsd(totals); ? SceneIntelligenceMaxOutputTokens()
return usd.HasValue && pricing.UsdToGbpRate.HasValue ? usd.Value * pricing.UsdToGbpRate.Value : null; : settings.MaxOutputTokens;
}
private static StorySceneTextBlock BuildSceneBlock(StoryIntelligenceQueuedRun run, IReadOnlyList<string> paragraphs, ChapterSceneBoundary boundary) private static StorySceneTextBlock BuildSceneBlock(StoryIntelligenceQueuedRun run, IReadOnlyList<string> paragraphs, ChapterSceneBoundary boundary)
{ {
@ -390,6 +569,99 @@ public sealed class PersistedStoryIntelligenceRunner(
private static string SerializeParsed<T>(T? value) private static string SerializeParsed<T>(T? value)
=> value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions); => value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions);
private static string SerialiseChapterParseAudit(
ChapterStructureModel? chapterStructure,
ChapterStructureBoundaryNormalisationResult normalisation,
AiJsonParseResult<ChapterStructureModel> parseResult)
{
if (!parseResult.RepairApplied && !parseResult.RetryAttempted && normalisation.Issues.Count == 0)
{
return SerializeParsed(chapterStructure);
}
return JsonSerializer.Serialize(new
{
chapterStructure?.SchemaVersion,
chapterStructure?.ChapterSummary,
chapterStructure?.SceneBoundaries,
repairedJson = parseResult.RepairApplied ? parseResult.RepairedJson : null,
repairApplied = parseResult.RepairApplied,
retryAttempted = parseResult.RetryAttempted,
initialParseError = parseResult.InitialError,
jsonWarnings = parseResult.Warnings,
normalisation = normalisation.Issues.Select(issue => new
{
issue.Severity,
issue.Path,
issue.Message,
issue.SuggestedFix
})
}, JsonOptions);
}
private static string SerialiseSceneParseAudit(AiJsonParseResult<SceneIntelligenceScene> parseResult, bool validationPassed)
{
if (!parseResult.RepairApplied && !parseResult.RetryAttempted)
{
return SerializeParsed(parseResult.Parsed);
}
return JsonSerializer.Serialize(new
{
parsedScene = parseResult.Parsed,
repairedJson = parseResult.RepairApplied ? parseResult.RepairedJson : null,
repairApplied = parseResult.RepairApplied,
retryAttempted = parseResult.RetryAttempted,
initialParseError = parseResult.InitialError,
validationPassed,
warnings = parseResult.Warnings
}, JsonOptions);
}
private static string SerialiseFailedAiJsonParse(AiJsonParseException parseException)
=> JsonSerializer.Serialize(new
{
error = parseException.Message,
parseError = parseException.InitialError,
retryAttempted = parseException.RetryAttempted,
retryError = parseException.RetryError
}, JsonOptions);
private static string BuildJsonRetryPrompt(string originalPrompt, string label, string parseError, string malformedJson)
=> string.Concat(
originalPrompt,
Environment.NewLine,
Environment.NewLine,
"## JSON Repair Retry",
Environment.NewLine,
$"Your previous response was not valid JSON. Return the same {label} object as valid JSON only. Do not include markdown. Do not truncate. All numeric confidence values must be valid JSON numbers such as 0.9 or 0.0.",
Environment.NewLine,
"Parse error:",
Environment.NewLine,
parseError,
Environment.NewLine,
"Malformed JSON returned previously:",
Environment.NewLine,
"```json",
Environment.NewLine,
malformedJson,
Environment.NewLine,
"```");
private static string CombineRawResponses(string initialRawResponse, string retryRawResponse)
=> JsonSerializer.Serialize(new
{
initialRawResponseJson = initialRawResponse,
retryRawResponseJson = retryRawResponse
}, JsonOptions);
private static string CombineAssistantOutputs(string initialOutput, string retryOutput)
=> JsonSerializer.Serialize(new
{
initialAssistantOutput = initialOutput,
retryAssistantOutput = retryOutput
}, JsonOptions);
private static int? SumTokens(int? inputTokens, int? outputTokens) private static int? SumTokens(int? inputTokens, int? outputTokens)
=> inputTokens.HasValue || outputTokens.HasValue ? (inputTokens ?? 0) + (outputTokens ?? 0) : null; => inputTokens.HasValue || outputTokens.HasValue ? (inputTokens ?? 0) + (outputTokens ?? 0) : null;
@ -423,6 +695,75 @@ public sealed class PersistedStoryIntelligenceRunner(
public string? Detail { get; } = detail; public string? Detail { get; } = detail;
} }
private sealed class AiJsonParseException(
string message,
string rawResponseJson,
string rawAssistantOutput,
string initialError,
bool retryAttempted,
string? retryError,
int? inputTokens,
int? outputTokens,
TimeSpan duration) : Exception(message)
{
public string RawResponseJson { get; } = rawResponseJson;
public string RawAssistantOutput { get; } = rawAssistantOutput;
public string InitialError { get; } = initialError;
public bool RetryAttempted { get; } = retryAttempted;
public string? RetryError { get; } = retryError;
public int? InputTokens { get; } = inputTokens;
public int? OutputTokens { get; } = outputTokens;
public TimeSpan Duration { get; } = duration;
}
private sealed record AiJsonParseResult<T>(
string Model,
string RawResponseJson,
string RawAssistantOutput,
string RawJson,
string? RepairedJson,
T? Parsed,
int? InputTokens,
int? OutputTokens,
TimeSpan Duration,
bool RetryAttempted,
IReadOnlyList<string> Warnings,
string? InitialError)
{
public bool RepairApplied => Warnings.Any(warning =>
warning.Contains("markdown", StringComparison.OrdinalIgnoreCase)
|| warning.Contains("Normalised", StringComparison.OrdinalIgnoreCase)
|| warning.Contains("Escaped", StringComparison.OrdinalIgnoreCase)
|| warning.Contains("Trimmed", StringComparison.OrdinalIgnoreCase));
public static AiJsonParseResult<T> Success(
string model,
string rawResponseJson,
string rawAssistantOutput,
string rawJson,
string? repairedJson,
T? parsed,
int? inputTokens,
int? outputTokens,
TimeSpan duration,
bool retryAttempted,
IReadOnlyList<string> warnings,
string? initialError = null)
=> new(
model,
rawResponseJson,
rawAssistantOutput,
rawJson,
repairedJson,
parsed,
inputTokens,
outputTokens,
duration,
retryAttempted,
warnings,
initialError);
}
private sealed class StoryIntelligenceRunCancelledException : Exception private sealed class StoryIntelligenceRunCancelledException : Exception
{ {
} }
@ -441,14 +782,38 @@ public sealed class PersistedStoryIntelligenceRunner(
public int? InputTokens { get; private set; } public int? InputTokens { get; private set; }
public int? OutputTokens { get; private set; } public int? OutputTokens { get; private set; }
public int? TotalTokens => InputTokens.HasValue || OutputTokens.HasValue ? (InputTokens ?? 0) + (OutputTokens ?? 0) : null; public int? TotalTokens => InputTokens.HasValue || OutputTokens.HasValue ? (InputTokens ?? 0) + (OutputTokens ?? 0) : null;
public decimal? EstimatedCostUSD { get; private set; }
public decimal? EstimatedCostGBP { get; private set; }
public void Add(StoryIntelligenceClientResult result) public void Add(StoryIntelligenceClientResult result, StoryIntelligencePricingOptions pricing)
{ {
InputTokens = SumNullable(InputTokens, result.InputTokens); InputTokens = SumNullable(InputTokens, result.InputTokens);
OutputTokens = SumNullable(OutputTokens, result.OutputTokens); OutputTokens = SumNullable(OutputTokens, result.OutputTokens);
AddCost(result, pricing);
} }
private static int? SumNullable(int? current, int? next) private static int? SumNullable(int? current, int? next)
=> current.HasValue || next.HasValue ? (current ?? 0) + (next ?? 0) : null; => current.HasValue || next.HasValue ? (current ?? 0) + (next ?? 0) : null;
private void AddCost(StoryIntelligenceClientResult result, StoryIntelligencePricingOptions pricing)
{
var modelPricing = pricing.GetPricingForModel(result.Model);
if (modelPricing?.InputPerMillionUsd is not decimal inputPrice
|| modelPricing.OutputPerMillionUsd is not decimal outputPrice
|| result.InputTokens is not int inputTokens
|| result.OutputTokens is not int outputTokens)
{
EstimatedCostUSD = null;
EstimatedCostGBP = null;
return;
}
var requestUsd = (inputTokens / 1_000_000m * inputPrice)
+ (outputTokens / 1_000_000m * outputPrice);
EstimatedCostUSD = (EstimatedCostUSD ?? 0m) + requestUsd;
EstimatedCostGBP = pricing.UsdToGbpRate.HasValue
? (EstimatedCostGBP ?? 0m) + (requestUsd * pricing.UsdToGbpRate.Value)
: null;
}
} }
} }

View File

@ -72,7 +72,9 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
SourceCharacterCount = selected.SourceCharacterCount ?? sourceText.Length, SourceCharacterCount = selected.SourceCharacterCount ?? sourceText.Length,
SourceParagraphCount = StoryIntelligenceParagraphs.Split(sourceText).Count, SourceParagraphCount = StoryIntelligenceParagraphs.Split(sourceText).Count,
SourceChapterCount = 1, SourceChapterCount = 1,
Model = clientStatus.Model, Model = StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel),
PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1" PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1"
}); });

View File

@ -9,14 +9,14 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceFullChapterTestService public interface IStoryIntelligenceFullChapterTestService
{ {
StoryIntelligenceFullChapterTestViewModel GetDefault(); Task<StoryIntelligenceFullChapterTestViewModel> GetDefaultAsync(int? sourceRunId = null);
Task<StoryIntelligenceFullChapterTestViewModel> AnalyzeAsync(StoryIntelligenceFullChapterTestForm form, IFormFile? textFile); Task<StoryIntelligenceFullChapterTestViewModel> AnalyzeAsync(StoryIntelligenceFullChapterTestForm form, IFormFile? textFile);
Task<int> QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile); Task<int> QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile);
} }
public sealed class StoryIntelligenceFullChapterTestService( public sealed class StoryIntelligenceFullChapterTestService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
IStoryIntelligenceClient client, IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions, IOptions<StoryIntelligencePricingOptions> pricingOptions,
ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService
{ {
@ -25,10 +25,35 @@ public sealed class StoryIntelligenceFullChapterTestService(
private const int LowParagraphCountThreshold = 5; private const int LowParagraphCountThreshold = 5;
private const int MaxUploadBytes = 2 * 1024 * 1024; private const int MaxUploadBytes = 2 * 1024 * 1024;
private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1"; private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1";
private static readonly IReadOnlyList<string> AllowedModelOptions = ["gpt-5", "gpt-5-mini"];
private readonly StoryIntelligenceOptions settings = options.Value;
private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value; private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value;
public StoryIntelligenceFullChapterTestViewModel GetDefault() public async Task<StoryIntelligenceFullChapterTestViewModel> GetDefaultAsync(int? sourceRunId = null)
=> new(); {
if (sourceRunId is int runId)
{
var run = await runs.GetRunAsync(runId);
if (run is not null && !string.IsNullOrWhiteSpace(run.SourceText))
{
return BuildViewModel(
new StoryIntelligenceFullChapterTestForm
{
SourceLabel = $"{CleanSourceLabel(run.SourceFileName, run.SourceFileName)} rerun",
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
ChapterStructureModel = DefaultChapterStructureModel(),
SceneIntelligenceModel = DefaultSceneIntelligenceModel(),
ChapterText = run.SourceText
},
new FullChapterSource(run.SourceText, run.SourceFileName, run.SourceFileSizeBytes, null),
queued: false);
}
}
return BuildViewModel(new StoryIntelligenceFullChapterTestForm(), new FullChapterSource(string.Empty, null, null, null), queued: false);
}
public async Task<StoryIntelligenceFullChapterTestViewModel> AnalyzeAsync(StoryIntelligenceFullChapterTestForm form, IFormFile? textFile) public async Task<StoryIntelligenceFullChapterTestViewModel> AnalyzeAsync(StoryIntelligenceFullChapterTestForm form, IFormFile? textFile)
{ {
@ -37,7 +62,8 @@ public sealed class StoryIntelligenceFullChapterTestService(
{ {
return new StoryIntelligenceFullChapterTestViewModel return new StoryIntelligenceFullChapterTestViewModel
{ {
Form = form, Form = NormalizeForm(form, keepText: true),
ModelOptions = AllowedModelOptions,
ErrorMessage = source.ErrorMessage ErrorMessage = source.ErrorMessage
}; };
} }
@ -58,8 +84,8 @@ public sealed class StoryIntelligenceFullChapterTestService(
throw new InvalidOperationException("Paste chapter text or upload a .txt chapter file before queueing."); throw new InvalidOperationException("Paste chapter text or upload a .txt chapter file before queueing.");
} }
var preflight = Analyze(source.Text); var modelSelection = NormalizeModelSelection(form);
var clientStatus = client.GetConfigurationStatus(); var preflight = Analyze(source.Text, modelSelection);
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{ {
UserID = userId, UserID = userId,
@ -75,12 +101,12 @@ public sealed class StoryIntelligenceFullChapterTestService(
SourceCharacterCount = preflight.CharacterCount, SourceCharacterCount = preflight.CharacterCount,
SourceParagraphCount = preflight.ParagraphCount, SourceParagraphCount = preflight.ParagraphCount,
SourceChapterCount = 1, SourceChapterCount = 1,
Model = clientStatus.Model, Model = StoryIntelligenceOptions.BuildModelSummary(modelSelection.ChapterStructureModel, modelSelection.SceneIntelligenceModel),
PromptVersionsSummary = PromptVersionsSummary PromptVersionsSummary = PromptVersionsSummary
}); });
logger.LogInformation( 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}", "Queued full chapter Story Intelligence test run {StoryIntelligenceRunID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} CharacterCount={CharacterCount} WordCount={WordCount} ParagraphCount={ParagraphCount} EstimatedInputTokens={EstimatedInputTokens} ChapterStructureModel={ChapterStructureModel} SceneIntelligenceModel={SceneIntelligenceModel} SourceFileName={SourceFileName}",
runId, runId,
userId, userId,
form.ProjectID, form.ProjectID,
@ -90,6 +116,8 @@ public sealed class StoryIntelligenceFullChapterTestService(
preflight.WordCount, preflight.WordCount,
preflight.ParagraphCount, preflight.ParagraphCount,
preflight.EstimatedInputTokens, preflight.EstimatedInputTokens,
modelSelection.ChapterStructureModel,
modelSelection.SceneIntelligenceModel,
source.SourceFileName); source.SourceFileName);
return runId; return runId;
@ -107,14 +135,17 @@ public sealed class StoryIntelligenceFullChapterTestService(
ProjectID = form.ProjectID, ProjectID = form.ProjectID,
BookID = form.BookID, BookID = form.BookID,
ChapterID = form.ChapterID, ChapterID = form.ChapterID,
ChapterStructureModel = NormalizeModel(form.ChapterStructureModel, DefaultChapterStructureModel()),
SceneIntelligenceModel = NormalizeModel(form.SceneIntelligenceModel, DefaultSceneIntelligenceModel()),
ChapterText = queued ? string.Empty : source.Text ChapterText = queued ? string.Empty : source.Text
}, },
Preflight = string.IsNullOrWhiteSpace(source.Text) ? null : Analyze(source.Text), ModelOptions = AllowedModelOptions,
Preflight = string.IsNullOrWhiteSpace(source.Text) ? null : Analyze(source.Text, NormalizeModelSelection(form)),
SourceFileName = source.SourceFileName, SourceFileName = source.SourceFileName,
SourceFileSizeBytes = source.SourceFileSizeBytes SourceFileSizeBytes = source.SourceFileSizeBytes
}; };
private StoryIntelligenceFullChapterPreflight Analyze(string text) private StoryIntelligenceFullChapterPreflight Analyze(string text, StoryIntelligenceStageModels modelSelection)
{ {
var characterCount = text.Length; var characterCount = text.Length;
var wordCount = CountWords(text); var wordCount = CountWords(text);
@ -130,12 +161,53 @@ public sealed class StoryIntelligenceFullChapterTestService(
ParagraphCount = paragraphCount, ParagraphCount = paragraphCount,
EstimatedInputTokens = estimatedInputTokens, EstimatedInputTokens = estimatedInputTokens,
EstimatedPipelineInputTokens = estimatedPipelineInputTokens, EstimatedPipelineInputTokens = estimatedPipelineInputTokens,
EstimatedInputCostUSD = EstimateInputCost(estimatedPipelineInputTokens, pricing.InputPerMillionUsd), EstimatedInputCostUSD = EstimateInputCost(
EstimatedInputCostGBP = EstimateInputCost(estimatedPipelineInputTokens, pricing.InputPerMillionUsd, pricing.UsdToGbpRate), estimatedInputTokens,
pricing.GetPricingForModel(modelSelection.ChapterStructureModel)?.InputPerMillionUsd)
+ EstimateInputCost(
estimatedInputTokens,
pricing.GetPricingForModel(modelSelection.SceneIntelligenceModel)?.InputPerMillionUsd),
EstimatedInputCostGBP = EstimateInputCost(
estimatedInputTokens,
pricing.GetPricingForModel(modelSelection.ChapterStructureModel)?.InputPerMillionUsd,
pricing.UsdToGbpRate)
+ EstimateInputCost(
estimatedInputTokens,
pricing.GetPricingForModel(modelSelection.SceneIntelligenceModel)?.InputPerMillionUsd,
pricing.UsdToGbpRate),
Warnings = warnings Warnings = warnings
}; };
} }
private StoryIntelligenceStageModels NormalizeModelSelection(StoryIntelligenceFullChapterTestForm form)
=> new(
NormalizeModel(form.ChapterStructureModel, DefaultChapterStructureModel()),
NormalizeModel(form.SceneIntelligenceModel, DefaultSceneIntelligenceModel()));
private StoryIntelligenceFullChapterTestForm NormalizeForm(StoryIntelligenceFullChapterTestForm form, bool keepText)
=> new()
{
SourceLabel = form.SourceLabel,
ProjectID = form.ProjectID,
BookID = form.BookID,
ChapterID = form.ChapterID,
ChapterStructureModel = NormalizeModel(form.ChapterStructureModel, DefaultChapterStructureModel()),
SceneIntelligenceModel = NormalizeModel(form.SceneIntelligenceModel, DefaultSceneIntelligenceModel()),
ChapterText = keepText ? form.ChapterText : null
};
private static string NormalizeModel(string? model, string fallback)
{
var value = string.IsNullOrWhiteSpace(model) ? fallback : model.Trim();
return AllowedModelOptions.Contains(value, StringComparer.OrdinalIgnoreCase) ? value : fallback;
}
private string DefaultChapterStructureModel()
=> string.IsNullOrWhiteSpace(settings.EffectiveChapterStructureModel) ? "gpt-5" : settings.EffectiveChapterStructureModel;
private string DefaultSceneIntelligenceModel()
=> string.IsNullOrWhiteSpace(settings.EffectiveSceneIntelligenceModel) ? "gpt-5" : settings.EffectiveSceneIntelligenceModel;
private async Task<FullChapterSource> ReadSourceAsync(StoryIntelligenceFullChapterTestForm form, IFormFile? textFile) private async Task<FullChapterSource> ReadSourceAsync(StoryIntelligenceFullChapterTestForm form, IFormFile? textFile)
{ {
if (textFile is not null && textFile.Length > 0) if (textFile is not null && textFile.Length > 0)

View File

@ -0,0 +1,324 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace PlotLine.Services;
public static partial class StoryIntelligenceJsonRepair
{
public static StoryIntelligenceJsonRepairResult Repair(string value)
{
var warnings = new List<string>();
var extraction = ExtractJsonObject(value);
if (!extraction.Success)
{
return StoryIntelligenceJsonRepairResult.Failed(value, extraction.ErrorMessage);
}
warnings.AddRange(extraction.Warnings);
var numericRepaired = RepairTrailingDecimalNumbers(extraction.Json, warnings);
var slashRepaired = RepairInvalidBackslashesInStrings(numericRepaired, warnings);
if (!IsValidJson(slashRepaired, out var error))
{
return StoryIntelligenceJsonRepairResult.Failed(slashRepaired, error);
}
return new StoryIntelligenceJsonRepairResult(
RepairedJson: slashRepaired,
RepairApplied: warnings.Count > 0,
Warnings: warnings,
ErrorMessage: null);
}
public static StoryIntelligenceJsonExtractionResult ExtractJsonObject(string value)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(value))
{
return StoryIntelligenceJsonExtractionResult.Failed(value, "Output was empty.");
}
var candidate = StripMarkdownFence(value.Trim(), warnings);
candidate = TrimToJsonObject(candidate, warnings);
if (string.IsNullOrWhiteSpace(candidate))
{
return StoryIntelligenceJsonExtractionResult.Failed(value, "No complete JSON object was found.");
}
return new StoryIntelligenceJsonExtractionResult(
Json: candidate,
Warnings: warnings,
ErrorMessage: null);
}
private static string StripMarkdownFence(string value, List<string> warnings)
{
var match = MarkdownFenceRegex().Match(value);
if (!match.Success)
{
return value;
}
warnings.Add("Removed markdown code fence.");
return match.Groups["json"].Value.Trim();
}
private static string TrimToJsonObject(string value, List<string> warnings)
{
var start = value.IndexOf('{');
if (start < 0)
{
return string.Empty;
}
var depth = 0;
var inString = false;
var escaped = false;
for (var i = start; i < value.Length; i++)
{
var ch = value[i];
if (inString)
{
if (escaped)
{
escaped = false;
}
else if (ch == '\\')
{
escaped = true;
}
else if (ch == '"')
{
inString = false;
}
continue;
}
if (ch == '"')
{
inString = true;
}
else if (ch == '{')
{
depth++;
}
else if (ch == '}')
{
depth--;
if (depth == 0)
{
if (start > 0 || i < value.Length - 1)
{
warnings.Add("Trimmed text outside the JSON object.");
}
return value[start..(i + 1)].Trim();
}
}
}
return string.Empty;
}
private static string RepairTrailingDecimalNumbers(string value, List<string> warnings)
{
var repaired = TrailingDecimalRegex().Replace(value, match =>
{
warnings.Add("Normalised trailing decimal number to valid JSON number.");
return $"{match.Groups["prefix"].Value}{match.Groups["number"].Value}.0";
});
return repaired;
}
private static string RepairInvalidBackslashesInStrings(string value, List<string> warnings)
{
var builder = new StringBuilder(value.Length);
var inString = false;
var changed = false;
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
if (!inString)
{
builder.Append(ch);
if (ch == '"')
{
inString = true;
}
continue;
}
if (ch == '"')
{
inString = false;
builder.Append(ch);
continue;
}
if (ch == '\\')
{
var next = i + 1 < value.Length ? value[i + 1] : '\0';
if (IsValidJsonEscape(next))
{
builder.Append(ch);
if (next != '\0')
{
builder.Append(next);
i++;
}
continue;
}
builder.Append(@"\\");
changed = true;
continue;
}
builder.Append(ch);
}
if (changed)
{
warnings.Add("Escaped invalid backslash inside string value.");
}
return builder.ToString();
}
private static bool IsValidJsonEscape(char value)
=> value is '"' or '\\' or '/' or 'b' or 'f' or 'n' or 'r' or 't' or 'u';
private static bool IsValidJson(string value, out string? error)
{
try
{
using var _ = JsonDocument.Parse(value);
error = null;
return true;
}
catch (JsonException ex)
{
error = ex.Message;
return false;
}
}
[GeneratedRegex("^```(?:json)?\\s*(?<json>.*?)\\s*```$", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
private static partial Regex MarkdownFenceRegex();
[GeneratedRegex("(?<prefix>:\\s*)(?<number>-?\\d+)\\.\\s*(?=[,}\\]])")]
private static partial Regex TrailingDecimalRegex();
}
public static class StoryIntelligenceAiJsonParser
{
public static StoryIntelligenceAiJsonParseResult<T> Parse<T>(string rawAssistantOutput, JsonSerializerOptions jsonOptions)
{
var extraction = StoryIntelligenceJsonRepair.ExtractJsonObject(rawAssistantOutput);
if (!extraction.Success)
{
return StoryIntelligenceAiJsonParseResult<T>.Failed(rawAssistantOutput, extraction.ErrorMessage ?? "No complete JSON object was found.");
}
try
{
var parsed = JsonSerializer.Deserialize<T>(extraction.Json, jsonOptions);
return StoryIntelligenceAiJsonParseResult<T>.Ok(
extraction.Json,
repairedJson: null,
parsed,
extraction.Warnings);
}
catch (JsonException strictEx)
{
var repair = StoryIntelligenceJsonRepair.Repair(rawAssistantOutput);
if (!repair.Success)
{
return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repair.ErrorMessage ?? strictEx.Message);
}
try
{
var parsed = JsonSerializer.Deserialize<T>(repair.RepairedJson, jsonOptions);
var warnings = BuildRepairWarnings(repair.Warnings, strictEx.Path);
return StoryIntelligenceAiJsonParseResult<T>.Ok(
extraction.Json,
repair.RepairedJson,
parsed,
warnings);
}
catch (JsonException repairedEx)
{
return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repairedEx.Message);
}
}
}
private static IReadOnlyList<string> BuildRepairWarnings(IReadOnlyList<string> repairWarnings, string? strictErrorPath)
{
var warnings = new List<string>();
foreach (var warning in repairWarnings)
{
if (warning.Contains("trailing decimal", StringComparison.OrdinalIgnoreCase))
{
warnings.Add(string.IsNullOrWhiteSpace(strictErrorPath)
? "Repaired malformed AI JSON numeric value."
: $"Repaired malformed AI JSON numeric value at {strictErrorPath}.");
}
else
{
warnings.Add(warning);
}
}
return warnings;
}
}
public sealed record StoryIntelligenceJsonRepairResult(
string RepairedJson,
bool RepairApplied,
IReadOnlyList<string> Warnings,
string? ErrorMessage)
{
public bool Success => string.IsNullOrWhiteSpace(ErrorMessage);
public static StoryIntelligenceJsonRepairResult Failed(string value, string? errorMessage)
=> new(value, false, [], errorMessage ?? "JSON repair failed.");
}
public sealed record StoryIntelligenceJsonExtractionResult(
string Json,
IReadOnlyList<string> Warnings,
string? ErrorMessage)
{
public bool Success => string.IsNullOrWhiteSpace(ErrorMessage);
public static StoryIntelligenceJsonExtractionResult Failed(string value, string? errorMessage)
=> new(value, [], errorMessage ?? "JSON extraction failed.");
}
public sealed record StoryIntelligenceAiJsonParseResult<T>(
bool Success,
string RawJson,
string? RepairedJson,
T? Parsed,
IReadOnlyList<string> Warnings,
string? ErrorMessage)
{
public static StoryIntelligenceAiJsonParseResult<T> Ok(
string rawJson,
string? repairedJson,
T? parsed,
IReadOnlyList<string> warnings)
=> new(true, rawJson, repairedJson, parsed, warnings, null);
public static StoryIntelligenceAiJsonParseResult<T> Failed(string rawJson, string errorMessage)
=> new(false, rawJson, null, default, [], errorMessage);
}

View File

@ -28,7 +28,12 @@ public interface IStoryPromptVersionService
public interface IStoryIntelligenceClient public interface IStoryIntelligenceClient
{ {
Task<StoryIntelligenceClientResult> ExecutePromptAsync(string completedPrompt, string promptVersion, CancellationToken cancellationToken); Task<StoryIntelligenceClientResult> ExecutePromptAsync(
string completedPrompt,
string promptVersion,
CancellationToken cancellationToken,
string? modelOverride = null,
int? maxOutputTokensOverride = null);
StoryIntelligenceClientConfigurationStatus GetConfigurationStatus(); StoryIntelligenceClientConfigurationStatus GetConfigurationStatus();
} }
@ -131,23 +136,34 @@ public sealed class StoryIntelligenceClient(
=> new() => new()
{ {
ApiKeyConfigured = !string.IsNullOrWhiteSpace(settings.ApiKey), ApiKeyConfigured = !string.IsNullOrWhiteSpace(settings.ApiKey),
Model = settings.Model, Model = settings.EffectiveModel,
ChapterStructureModel = settings.EffectiveChapterStructureModel,
SceneIntelligenceModel = settings.EffectiveSceneIntelligenceModel,
MaxOutputTokens = settings.MaxOutputTokens, MaxOutputTokens = settings.MaxOutputTokens,
TimeoutSeconds = settings.TimeoutSeconds, TimeoutSeconds = settings.TimeoutSeconds,
ClientConstructed = true, ClientConstructed = true,
ConnectionStatus = "Not checked on diagnostics page. Only the admin dry-run page makes a live OpenAI request." ConnectionStatus = "Not checked on diagnostics page. Only the admin dry-run page makes a live OpenAI request."
}; };
public async Task<StoryIntelligenceClientResult> ExecutePromptAsync(string completedPrompt, string promptVersion, CancellationToken cancellationToken) public async Task<StoryIntelligenceClientResult> ExecutePromptAsync(
string completedPrompt,
string promptVersion,
CancellationToken cancellationToken,
string? modelOverride = null,
int? maxOutputTokensOverride = null)
{ {
if (string.IsNullOrWhiteSpace(settings.ApiKey)) if (string.IsNullOrWhiteSpace(settings.ApiKey))
{ {
throw new InvalidOperationException("OpenAI API key is not configured. Add OpenAI:ApiKey to User Secrets or environment variables."); throw new InvalidOperationException("OpenAI API key is not configured. Add OpenAI:ApiKey to User Secrets or environment variables.");
} }
if (string.IsNullOrWhiteSpace(settings.Model)) var model = string.IsNullOrWhiteSpace(modelOverride)
? settings.EffectiveModel
: modelOverride.Trim();
if (string.IsNullOrWhiteSpace(model))
{ {
throw new InvalidOperationException("OpenAI model is not configured. Set OpenAI:Model in configuration."); throw new InvalidOperationException("OpenAI model is not configured. Set OpenAI:Model or StoryIntelligence stage models in configuration.");
} }
if (string.IsNullOrWhiteSpace(completedPrompt)) if (string.IsNullOrWhiteSpace(completedPrompt))
@ -155,11 +171,17 @@ public sealed class StoryIntelligenceClient(
throw new InvalidOperationException("Completed prompt is empty."); throw new InvalidOperationException("Completed prompt is empty.");
} }
var maxOutputTokens = maxOutputTokensOverride.GetValueOrDefault(settings.MaxOutputTokens);
if (maxOutputTokens <= 0)
{
maxOutputTokens = settings.MaxOutputTokens;
}
var requestBody = JsonSerializer.Serialize(new var requestBody = JsonSerializer.Serialize(new
{ {
model = settings.Model, model = model,
input = completedPrompt, input = completedPrompt,
max_output_tokens = settings.MaxOutputTokens, max_output_tokens = maxOutputTokens,
store = false, store = false,
reasoning = new reasoning = new
{ {
@ -179,7 +201,7 @@ public sealed class StoryIntelligenceClient(
logger.LogInformation( logger.LogInformation(
"Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}", "Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
promptVersion, promptVersion,
settings.Model, model,
stopwatch.ElapsedMilliseconds, stopwatch.ElapsedMilliseconds,
retryCount, retryCount,
null, null,
@ -190,7 +212,7 @@ public sealed class StoryIntelligenceClient(
return new StoryIntelligenceClientResult return new StoryIntelligenceClientResult
{ {
RawResponseText = responseText, RawResponseText = responseText,
Model = settings.Model, Model = model,
Duration = stopwatch.Elapsed, Duration = stopwatch.Elapsed,
RetryCount = retryCount, RetryCount = retryCount,
InputTokens = GetTokenUsage(responseText).InputTokens, InputTokens = GetTokenUsage(responseText).InputTokens,
@ -204,7 +226,7 @@ public sealed class StoryIntelligenceClient(
ex, ex,
"Story Intelligence prompt failed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}", "Story Intelligence prompt failed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
promptVersion, promptVersion,
settings.Model, model,
stopwatch.ElapsedMilliseconds, stopwatch.ElapsedMilliseconds,
null, null,
null, null,
@ -312,6 +334,8 @@ public sealed class StoryIntelligenceDiagnosticsService(
var model = new StoryIntelligenceDiagnosticsViewModel var model = new StoryIntelligenceDiagnosticsViewModel
{ {
Model = clientStatus.Model, Model = clientStatus.Model,
ChapterStructureModel = clientStatus.ChapterStructureModel,
SceneIntelligenceModel = clientStatus.SceneIntelligenceModel,
ApiKeyConfigured = clientStatus.ApiKeyConfigured, ApiKeyConfigured = clientStatus.ApiKeyConfigured,
ClientConstructed = clientStatus.ClientConstructed, ClientConstructed = clientStatus.ClientConstructed,
ConnectionStatus = clientStatus.ConnectionStatus, ConnectionStatus = clientStatus.ConnectionStatus,
@ -379,7 +403,11 @@ public sealed class StoryIntelligenceDryRunService(
var template = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); var template = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
var completedPrompt = builder.BuildPrompt(template, contextJson, form.SceneText); var completedPrompt = builder.BuildPrompt(template, contextJson, form.SceneText);
var result = await client.ExecutePromptAsync(completedPrompt, promptVersion, cancellationToken); var result = await client.ExecutePromptAsync(
completedPrompt,
promptVersion,
cancellationToken,
clientStatus.SceneIntelligenceModel);
var sceneJson = ExtractSceneJson(result.RawResponseText); var sceneJson = ExtractSceneJson(result.RawResponseText);
var parsedScene = DeserializeScene(sceneJson); var parsedScene = DeserializeScene(sceneJson);
var validation = validator.Validate(parsedScene); var validation = validator.Validate(parsedScene);
@ -419,7 +447,7 @@ public sealed class StoryIntelligenceDryRunService(
{ {
Success = false, Success = false,
PromptVersion = promptVersion, PromptVersion = promptVersion,
Model = clientStatus.Model, Model = clientStatus.SceneIntelligenceModel,
SceneContextJson = contextJson, SceneContextJson = contextJson,
ErrorMessage = $"Scene Intelligence JSON could not be deserialised: {ex.Message}", ErrorMessage = $"Scene Intelligence JSON could not be deserialised: {ex.Message}",
Validation = validator.Validate(null) Validation = validator.Validate(null)
@ -429,7 +457,7 @@ public sealed class StoryIntelligenceDryRunService(
ex, ex,
"Admin Story Intelligence dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", "Admin Story Intelligence dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
promptVersion, promptVersion,
clientStatus.Model, clientStatus.SceneIntelligenceModel,
false); false);
} }
catch (Exception ex) when (ex is not OperationCanceledException) catch (Exception ex) when (ex is not OperationCanceledException)
@ -438,7 +466,7 @@ public sealed class StoryIntelligenceDryRunService(
{ {
Success = false, Success = false,
PromptVersion = promptVersion, PromptVersion = promptVersion,
Model = clientStatus.Model, Model = clientStatus.SceneIntelligenceModel,
SceneContextJson = contextJson, SceneContextJson = contextJson,
ErrorMessage = ex.Message ErrorMessage = ex.Message
}; };
@ -447,7 +475,7 @@ public sealed class StoryIntelligenceDryRunService(
ex, ex,
"Admin Story Intelligence dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", "Admin Story Intelligence dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
promptVersion, promptVersion,
clientStatus.Model, clientStatus.SceneIntelligenceModel,
false); false);
} }
@ -530,10 +558,17 @@ public sealed class ChapterStructureDryRunService(
var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs); var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
var completedPrompt = BuildChapterPrompt(template, contextJson, numberedChapterText); var completedPrompt = BuildChapterPrompt(template, contextJson, numberedChapterText);
var result = await client.ExecutePromptAsync(completedPrompt, promptVersion, cancellationToken); var result = await client.ExecutePromptAsync(
completedPrompt,
promptVersion,
cancellationToken,
clientStatus.ChapterStructureModel);
var chapterJson = ExtractOutputText(result.RawResponseText); var chapterJson = ExtractOutputText(result.RawResponseText);
var parsedChapter = DeserializeChapter(chapterJson); var parsedChapter = DeserializeChapter(chapterJson);
var validation = validator.Validate(parsedChapter, paragraphCount); var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphCount);
var chapterForValidation = chapterNormalisation.ChapterStructure;
var validation = validator.Validate(chapterForValidation, paragraphCount);
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, chapterNormalisation);
model.Result = new ChapterStructureDryRunResultViewModel model.Result = new ChapterStructureDryRunResultViewModel
{ {
@ -548,7 +583,7 @@ public sealed class ChapterStructureDryRunService(
OutputTokens = result.OutputTokens, OutputTokens = result.OutputTokens,
RawResponseText = result.RawResponseText, RawResponseText = result.RawResponseText,
ChapterJsonText = chapterJson, ChapterJsonText = chapterJson,
ParsedChapter = parsedChapter, ParsedChapter = chapterForValidation,
Validation = validation Validation = validation
}; };
@ -571,7 +606,7 @@ public sealed class ChapterStructureDryRunService(
{ {
Success = false, Success = false,
PromptVersion = promptVersion, PromptVersion = promptVersion,
Model = clientStatus.Model, Model = clientStatus.ChapterStructureModel,
ChapterContextJson = contextJson, ChapterContextJson = contextJson,
ParagraphCount = paragraphCount, ParagraphCount = paragraphCount,
ErrorMessage = $"Chapter Structure JSON could not be deserialised: {ex.Message}", ErrorMessage = $"Chapter Structure JSON could not be deserialised: {ex.Message}",
@ -582,7 +617,7 @@ public sealed class ChapterStructureDryRunService(
ex, ex,
"Admin Chapter Structure dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", "Admin Chapter Structure dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
promptVersion, promptVersion,
clientStatus.Model, clientStatus.ChapterStructureModel,
false); false);
} }
catch (Exception ex) when (ex is not OperationCanceledException) catch (Exception ex) when (ex is not OperationCanceledException)
@ -591,7 +626,7 @@ public sealed class ChapterStructureDryRunService(
{ {
Success = false, Success = false,
PromptVersion = promptVersion, PromptVersion = promptVersion,
Model = clientStatus.Model, Model = clientStatus.ChapterStructureModel,
ChapterContextJson = contextJson, ChapterContextJson = contextJson,
ParagraphCount = paragraphCount, ParagraphCount = paragraphCount,
ErrorMessage = ex.Message ErrorMessage = ex.Message
@ -601,7 +636,7 @@ public sealed class ChapterStructureDryRunService(
ex, ex,
"Admin Chapter Structure dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", "Admin Chapter Structure dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
promptVersion, promptVersion,
clientStatus.Model, clientStatus.ChapterStructureModel,
false); false);
} }
@ -687,6 +722,8 @@ public sealed class StoryIntelligenceClientConfigurationStatus
{ {
public bool ApiKeyConfigured { get; init; } public bool ApiKeyConfigured { get; init; }
public string Model { get; init; } = string.Empty; public string Model { get; init; } = string.Empty;
public string ChapterStructureModel { get; init; } = string.Empty;
public string SceneIntelligenceModel { get; init; } = string.Empty;
public int MaxOutputTokens { get; init; } public int MaxOutputTokens { get; init; }
public int TimeoutSeconds { get; init; } public int TimeoutSeconds { get; init; }
public bool ClientConstructed { get; init; } public bool ClientConstructed { get; init; }

View File

@ -83,7 +83,9 @@ public sealed class StoryIntelligenceResultPersistenceService(
SourceCharacterCount = form.ChapterText.Length, SourceCharacterCount = form.ChapterText.Length,
SourceParagraphCount = StoryIntelligenceParagraphs.Split(form.ChapterText).Count, SourceParagraphCount = StoryIntelligenceParagraphs.Split(form.ChapterText).Count,
SourceChapterCount = 1, SourceChapterCount = 1,
Model = clientStatus.Model, Model = StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel),
PromptVersionsSummary = promptVersionsSummary PromptVersionsSummary = promptVersionsSummary
}); });
} }
@ -140,7 +142,7 @@ public sealed class StoryIntelligenceResultPersistenceService(
SourceChapterCount = 1, SourceChapterCount = 1,
PromptVersion = promptVersionsSummary, PromptVersion = promptVersionsSummary,
PromptVersionsSummary = promptVersionsSummary, PromptVersionsSummary = promptVersionsSummary,
Model = result.Model, Model = StoryIntelligenceOptions.ParseModelSummary(result.Model, result.Model, result.Model).ChapterStructureModel,
StartedUtc = startedUtc, StartedUtc = startedUtc,
CompletedUtc = completedUtc, CompletedUtc = completedUtc,
FailureStage = DetermineFailureStage(result), FailureStage = DetermineFailureStage(result),
@ -166,7 +168,7 @@ public sealed class StoryIntelligenceResultPersistenceService(
ChapterNumber = form.ChapterNumber, ChapterNumber = form.ChapterNumber,
SourceLabel = CleanSourceLabel(form.SourceLabel), SourceLabel = CleanSourceLabel(form.SourceLabel),
PromptVersion = result.ChapterPromptVersion, PromptVersion = result.ChapterPromptVersion,
Model = result.Model, Model = StoryIntelligenceOptions.ParseModelSummary(result.Model, result.Model, result.Model).SceneIntelligenceModel,
RawResponseJson = result.ChapterRawResponseText, RawResponseJson = result.ChapterRawResponseText,
OutputTextJson = result.ChapterJsonText, OutputTextJson = result.ChapterJsonText,
ParsedJson = SerializeParsed(result.ParsedChapter), ParsedJson = SerializeParsed(result.ParsedChapter),

View File

@ -81,6 +81,8 @@ public sealed class AdminDashboardViewModel
public sealed class StoryIntelligenceDiagnosticsViewModel public sealed class StoryIntelligenceDiagnosticsViewModel
{ {
public string Model { get; set; } = string.Empty; public string Model { get; set; } = string.Empty;
public string ChapterStructureModel { get; set; } = string.Empty;
public string SceneIntelligenceModel { get; set; } = string.Empty;
public int MaxOutputTokens { get; set; } public int MaxOutputTokens { get; set; }
public int TimeoutSeconds { get; set; } public int TimeoutSeconds { get; set; }
public bool ApiKeyConfigured { get; set; } public bool ApiKeyConfigured { get; set; }

View File

@ -48,6 +48,10 @@
<dl class="mt-3 mb-0"> <dl class="mt-3 mb-0">
<dt>Max output tokens</dt> <dt>Max output tokens</dt>
<dd>@Model.MaxOutputTokens.ToString("N0")</dd> <dd>@Model.MaxOutputTokens.ToString("N0")</dd>
<dt>Chapter Structure model</dt>
<dd>@Model.ChapterStructureModel</dd>
<dt>Scene Intelligence model</dt>
<dd>@Model.SceneIntelligenceModel</dd>
<dt>Timeout</dt> <dt>Timeout</dt>
<dd>@Model.TimeoutSeconds seconds</dd> <dd>@Model.TimeoutSeconds seconds</dd>
<dt>Live API status</dt> <dt>Live API status</dt>

View File

@ -45,6 +45,24 @@
<label class="form-label" asp-for="Form.ChapterID">Chapter ID</label> <label class="form-label" asp-for="Form.ChapterID">Chapter ID</label>
<input class="form-control" asp-for="Form.ChapterID" /> <input class="form-control" asp-for="Form.ChapterID" />
</div> </div>
<div class="col-md-6">
<label class="form-label" asp-for="Form.ChapterStructureModel">Chapter Structure model</label>
<select class="form-select" asp-for="Form.ChapterStructureModel">
@foreach (var modelOption in Model.ModelOptions)
{
<option value="@modelOption">@modelOption</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Form.SceneIntelligenceModel">Scene Intelligence model</label>
<select class="form-select" asp-for="Form.SceneIntelligenceModel">
@foreach (var modelOption in Model.ModelOptions)
{
<option value="@modelOption">@modelOption</option>
}
</select>
</div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
@ -103,6 +121,8 @@
<dl class="mt-3 mb-0"> <dl class="mt-3 mb-0">
<dt>Estimated pipeline input tokens</dt> <dt>Estimated pipeline input tokens</dt>
<dd>@Model.Preflight.EstimatedPipelineInputTokens.ToString("N0")</dd> <dd>@Model.Preflight.EstimatedPipelineInputTokens.ToString("N0")</dd>
<dt>Selected models</dt>
<dd>Chapter Structure: @Model.Form.ChapterStructureModel; Scene Intelligence: @Model.Form.SceneIntelligenceModel</dd>
<dt>Estimated input cost</dt> <dt>Estimated input cost</dt>
<dd>@DisplayCost(Model.Preflight.EstimatedInputCostGBP, "GBP") / @DisplayCost(Model.Preflight.EstimatedInputCostUSD, "USD")</dd> <dd>@DisplayCost(Model.Preflight.EstimatedInputCostGBP, "GBP") / @DisplayCost(Model.Preflight.EstimatedInputCostUSD, "USD")</dd>
</dl> </dl>

View File

@ -120,6 +120,10 @@ else
<dd>@Display(run.PromptVersionsSummary)</dd> <dd>@Display(run.PromptVersionsSummary)</dd>
<dt>Model</dt> <dt>Model</dt>
<dd>@run.Model</dd> <dd>@run.Model</dd>
<dt>Chapter Structure model used</dt>
<dd>@ChapterStructureModelUsed(Model)</dd>
<dt>Scene Intelligence model used</dt>
<dd>@SceneIntelligenceModelUsed(Model)</dd>
<dt>Started</dt> <dt>Started</dt>
<dd>@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd> <dd>@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd>
<dt>Completed</dt> <dt>Completed</dt>
@ -148,6 +152,12 @@ else
<button class="btn btn-outline-danger" type="submit">Cancel run</button> <button class="btn btn-outline-danger" type="submit">Cancel run</button>
</form> </form>
} }
else if (CanRerun(run))
{
<div class="mt-3">
<a class="btn btn-outline-primary" asp-action="StoryIntelligenceFullChapterTest" asp-route-sourceRunId="@run.StoryIntelligenceRunID">Run same source again with different models</a>
</div>
}
</section> </section>
<section class="edit-panel"> <section class="edit-panel">
@ -166,6 +176,8 @@ else
<dd>@Display(chapter.ChapterNumber)</dd> <dd>@Display(chapter.ChapterNumber)</dd>
<dt>Prompt version</dt> <dt>Prompt version</dt>
<dd>@chapter.PromptVersion</dd> <dd>@chapter.PromptVersion</dd>
<dt>Model</dt>
<dd>@chapter.Model</dd>
<dt>Validation</dt> <dt>Validation</dt>
<dd>@chapter.ValidationErrorsCount.ToString("N0") error(s), @chapter.ValidationWarningsCount.ToString("N0") warning(s)</dd> <dd>@chapter.ValidationErrorsCount.ToString("N0") error(s), @chapter.ValidationWarningsCount.ToString("N0") warning(s)</dd>
<dt>Duration</dt> <dt>Duration</dt>
@ -174,12 +186,15 @@ else
<dd>@Display(chapter.TotalTokens) total (@Display(chapter.InputTokens) input, @Display(chapter.OutputTokens) output)</dd> <dd>@Display(chapter.TotalTokens) total (@Display(chapter.InputTokens) input, @Display(chapter.OutputTokens) output)</dd>
</dl> </dl>
<label class="form-label" for="chapterParsedJson">Parsed JSON</label>
<dl> <dl>
<dt>Highest returned endParagraph</dt> <dt>Highest returned endParagraph</dt>
<dd>@Display(HighestReturnedEndParagraph(chapter))</dd> <dd>@Display(HighestRawReturnedEndParagraph(chapter))</dd>
<dt>Returned scene boundary ranges</dt> <dt>Returned scene boundary ranges</dt>
<dd>@DisplayBoundaryRanges(chapter)</dd> <dd>@DisplayRawBoundaryRanges(chapter)</dd>
<dt>Normalised scene boundary ranges used</dt>
<dd>@DisplayNormalisedBoundaryRanges(chapter)</dd>
<dt>Chapter structure normalisation</dt>
<dd>@ChapterStructureNormalisation(chapter)</dd>
</dl> </dl>
@if (ReturnedEndParagraphExceedsSubmitted(chapter, run)) @if (ReturnedEndParagraphExceedsSubmitted(chapter, run))
{ {
@ -187,6 +202,7 @@ else
The model returned paragraph numbers beyond the submitted chapter. This usually means the prompt was not using explicit numbered paragraphs. The model returned paragraph numbers beyond the submitted chapter. This usually means the prompt was not using explicit numbered paragraphs.
</div> </div>
} }
<label class="form-label" for="chapterParsedJson">Parsed / normalised JSON used for validation and splitting</label>
<textarea class="form-control font-monospace" id="chapterParsedJson" rows="10" readonly>@chapter.ParsedJson</textarea> <textarea class="form-control font-monospace" id="chapterParsedJson" rows="10" readonly>@chapter.ParsedJson</textarea>
<label class="form-label mt-3" for="chapterOutputJson">Extracted output JSON</label> <label class="form-label mt-3" for="chapterOutputJson">Extracted output JSON</label>
<textarea class="form-control font-monospace" id="chapterOutputJson" rows="10" readonly>@chapter.OutputTextJson</textarea> <textarea class="form-control font-monospace" id="chapterOutputJson" rows="10" readonly>@chapter.OutputTextJson</textarea>
@ -214,6 +230,8 @@ else
<dd>@scene.SourceLabel</dd> <dd>@scene.SourceLabel</dd>
<dt>Prompt version</dt> <dt>Prompt version</dt>
<dd>@scene.PromptVersion</dd> <dd>@scene.PromptVersion</dd>
<dt>Model</dt>
<dd>@scene.Model</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)) @if (IsFailedScene(scene))
@ -223,7 +241,9 @@ else
<dt>Error message</dt> <dt>Error message</dt>
<dd>@SceneErrorMessage(scene)</dd> <dd>@SceneErrorMessage(scene)</dd>
<dt>Retry attempted</dt> <dt>Retry attempted</dt>
<dd>No</dd> <dd>@(SceneRetryAttempted(scene) ? "Yes" : "No")</dd>
<dt>Retry error</dt>
<dd>@Display(SceneRetryError(scene))</dd>
} }
<dt>Duration</dt> <dt>Duration</dt>
<dd>@DisplayDuration(scene.DurationMs)</dd> <dd>@DisplayDuration(scene.DurationMs)</dd>
@ -233,7 +253,7 @@ else
<label class="form-label" for="sceneParsedJson-@scene.SceneResultID">Parsed JSON</label> <label class="form-label" for="sceneParsedJson-@scene.SceneResultID">Parsed JSON</label>
<textarea class="form-control font-monospace" id="sceneParsedJson-@scene.SceneResultID" rows="10" readonly>@scene.ParsedJson</textarea> <textarea class="form-control font-monospace" id="sceneParsedJson-@scene.SceneResultID" rows="10" readonly>@scene.ParsedJson</textarea>
<label class="form-label mt-3" for="sceneOutputJson-@scene.SceneResultID">Extracted output JSON</label> <label class="form-label mt-3" for="sceneOutputJson-@scene.SceneResultID">Extracted raw assistant output</label>
<textarea class="form-control font-monospace" id="sceneOutputJson-@scene.SceneResultID" rows="10" readonly>@scene.OutputTextJson</textarea> <textarea class="form-control font-monospace" id="sceneOutputJson-@scene.SceneResultID" rows="10" readonly>@scene.OutputTextJson</textarea>
<label class="form-label mt-3" for="sceneRawJson-@scene.SceneResultID">Raw OpenAI response JSON</label> <label class="form-label mt-3" for="sceneRawJson-@scene.SceneResultID">Raw OpenAI response JSON</label>
<textarea class="form-control font-monospace" id="sceneRawJson-@scene.SceneResultID" rows="10" readonly>@scene.RawResponseJson</textarea> <textarea class="form-control font-monospace" id="sceneRawJson-@scene.SceneResultID" rows="10" readonly>@scene.RawResponseJson</textarea>
@ -264,6 +284,45 @@ else
private static string DisplayCost(decimal? value, string currency) private static string DisplayCost(decimal? value, string currency)
=> value.HasValue ? $"{value.Value:0.000000} {currency}" : "None"; => value.HasValue ? $"{value.Value:0.000000} {currency}" : "None";
private static string ChapterStructureModelUsed(StoryIntelligenceSavedRunDetailViewModel model)
=> !string.IsNullOrWhiteSpace(model.ChapterResult?.Model)
? model.ChapterResult.Model
: ParseModelPart(model.Run?.Model, "Chapter");
private static string SceneIntelligenceModelUsed(StoryIntelligenceSavedRunDetailViewModel model)
{
var sceneModels = model.SceneResults
.Select(scene => scene.Model)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (sceneModels.Count > 0)
{
return string.Join(", ", sceneModels);
}
return ParseModelPart(model.Run?.Model, "Scene");
}
private static string ParseModelPart(string? summary, string key)
{
if (string.IsNullOrWhiteSpace(summary))
{
return "None";
}
foreach (var segment in summary.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var parts = segment.Split('=', 2, StringSplitOptions.TrimEntries);
if (parts.Length == 2 && string.Equals(parts[0], key, StringComparison.OrdinalIgnoreCase))
{
return parts[1];
}
}
return summary;
}
private static string DisplayName(string? title, int? id, string label) private static string DisplayName(string? title, int? id, string label)
=> !string.IsNullOrWhiteSpace(title) => !string.IsNullOrWhiteSpace(title)
? title ? title
@ -312,9 +371,14 @@ else
} }
private static bool IsFailedScene(StoryIntelligenceSavedSceneResult scene) private static bool IsFailedScene(StoryIntelligenceSavedSceneResult scene)
=> scene.ValidationErrorsCount > 0 => scene.ValidationErrorsCount > 0 && SceneJsonString(scene, "error") is not null;
&& string.IsNullOrWhiteSpace(scene.RawResponseJson)
&& !string.IsNullOrWhiteSpace(scene.ParsedJson); private static bool CanRerun(StoryIntelligenceSavedRun run)
=> !string.IsNullOrWhiteSpace(run.SourceText)
&& (run.Status is StoryIntelligenceRunStatuses.Completed
or StoryIntelligenceRunStatuses.CompletedWithWarnings
or StoryIntelligenceRunStatuses.Failed
or StoryIntelligenceRunStatuses.Cancelled);
private static string SceneFailureStage(StoryIntelligenceSavedSceneResult scene) private static string SceneFailureStage(StoryIntelligenceSavedSceneResult scene)
=> SceneErrorMessage(scene).Contains("boundary", StringComparison.OrdinalIgnoreCase) => SceneErrorMessage(scene).Contains("boundary", StringComparison.OrdinalIgnoreCase)
@ -322,28 +386,62 @@ else
: StoryIntelligenceFailureStages.SceneIntelligence; : StoryIntelligenceFailureStages.SceneIntelligence;
private static string SceneErrorMessage(StoryIntelligenceSavedSceneResult scene) private static string SceneErrorMessage(StoryIntelligenceSavedSceneResult scene)
=> SceneJsonString(scene, "error") ?? "Scene failed. See parsed JSON for details.";
private static bool SceneRetryAttempted(StoryIntelligenceSavedSceneResult scene)
{ {
try try
{ {
using var document = System.Text.Json.JsonDocument.Parse(scene.ParsedJson); 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) if (document.RootElement.TryGetProperty("retryAttempted", out var retry) && retry.ValueKind is System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False)
{ {
return error.GetString() ?? "Scene failed."; return retry.GetBoolean();
} }
} }
catch (System.Text.Json.JsonException) catch (System.Text.Json.JsonException)
{ {
} }
return "Scene failed. See parsed JSON for details."; return false;
} }
private static int? HighestReturnedEndParagraph(StoryIntelligenceSavedChapterResult chapter) private static string? SceneRetryError(StoryIntelligenceSavedSceneResult scene)
=> ReadBoundaryRanges(chapter).Select(range => range.End).DefaultIfEmpty().Max() is var max && max > 0 ? max : null; => SceneJsonString(scene, "retryError");
private static string DisplayBoundaryRanges(StoryIntelligenceSavedChapterResult chapter) private static string? SceneJsonString(StoryIntelligenceSavedSceneResult scene, string propertyName)
{ {
var ranges = ReadBoundaryRanges(chapter).ToList(); if (string.IsNullOrWhiteSpace(scene.ParsedJson))
{
return null;
}
try
{
using var document = System.Text.Json.JsonDocument.Parse(scene.ParsedJson);
if (document.RootElement.TryGetProperty(propertyName, out var value) && value.ValueKind == System.Text.Json.JsonValueKind.String)
{
return value.GetString();
}
}
catch (System.Text.Json.JsonException)
{
}
return null;
}
private static int? HighestRawReturnedEndParagraph(StoryIntelligenceSavedChapterResult chapter)
=> ReadBoundaryRanges(chapter.OutputTextJson).Select(range => range.End).DefaultIfEmpty().Max() is var max && max > 0 ? max : null;
private static string DisplayRawBoundaryRanges(StoryIntelligenceSavedChapterResult chapter)
=> DisplayBoundaryRanges(chapter.OutputTextJson);
private static string DisplayNormalisedBoundaryRanges(StoryIntelligenceSavedChapterResult chapter)
=> DisplayBoundaryRanges(chapter.ParsedJson);
private static string DisplayBoundaryRanges(string json)
{
var ranges = ReadBoundaryRanges(json).ToList();
return ranges.Count == 0 return ranges.Count == 0
? "None" ? "None"
: string.Join(", ", ranges.Select(range => $"{range.Start}-{range.End}")); : string.Join(", ", ranges.Select(range => $"{range.Start}-{range.End}"));
@ -351,21 +449,53 @@ else
private static bool ReturnedEndParagraphExceedsSubmitted(StoryIntelligenceSavedChapterResult chapter, StoryIntelligenceSavedRun run) private static bool ReturnedEndParagraphExceedsSubmitted(StoryIntelligenceSavedChapterResult chapter, StoryIntelligenceSavedRun run)
{ {
var highest = HighestReturnedEndParagraph(chapter); var highest = HighestRawReturnedEndParagraph(chapter);
var submitted = run.SourceParagraphCount; var submitted = run.SourceParagraphCount;
return highest.HasValue && submitted.HasValue && highest.Value > submitted.Value; return highest.HasValue && submitted.HasValue && highest.Value > submitted.Value;
} }
private static IReadOnlyList<(int Start, int End)> ReadBoundaryRanges(StoryIntelligenceSavedChapterResult chapter) private static string ChapterStructureNormalisation(StoryIntelligenceSavedChapterResult chapter)
{ {
if (string.IsNullOrWhiteSpace(chapter.ParsedJson)) if (string.IsNullOrWhiteSpace(chapter.ParsedJson))
{
return "None";
}
try
{
using var document = System.Text.Json.JsonDocument.Parse(chapter.ParsedJson);
if (!document.RootElement.TryGetProperty("normalisation", out var normalisation)
|| normalisation.ValueKind != System.Text.Json.JsonValueKind.Array)
{
return "None";
}
var messages = normalisation
.EnumerateArray()
.Select(issue => issue.TryGetProperty("message", out var message) && message.ValueKind == System.Text.Json.JsonValueKind.String
? message.GetString()
: null)
.Where(message => !string.IsNullOrWhiteSpace(message))
.ToList();
return messages.Count == 0 ? "None" : string.Join(Environment.NewLine, messages);
}
catch (System.Text.Json.JsonException)
{
return "None";
}
}
private static IReadOnlyList<(int Start, int End)> ReadBoundaryRanges(string json)
{
if (string.IsNullOrWhiteSpace(json))
{ {
return []; return [];
} }
try try
{ {
using var document = System.Text.Json.JsonDocument.Parse(chapter.ParsedJson); using var document = System.Text.Json.JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("sceneBoundaries", out var boundaries) if (!document.RootElement.TryGetProperty("sceneBoundaries", out var boundaries)
|| boundaries.ValueKind != System.Text.Json.JsonValueKind.Array) || boundaries.ValueKind != System.Text.Json.JsonValueKind.Array)
{ {

View File

@ -24,6 +24,11 @@
"MaxOutputTokens": 4000, "MaxOutputTokens": 4000,
"TimeoutSeconds": 300 "TimeoutSeconds": 300
}, },
"StoryIntelligence": {
"ChapterStructureModel": "gpt-5-mini",
"SceneIntelligenceModel": "gpt-5-mini",
"SceneIntelligenceMaxOutputTokens": 10000
},
"Site": { "Site": {
"PublicBaseUrl": "https://plotdirector.com" "PublicBaseUrl": "https://plotdirector.com"
}, },