Fix structural JSON numeric repair

This commit is contained in:
Nick Beckley 2026-08-25 20:56:56 +00:00
parent fbb973574c
commit 45aa1c7384
7 changed files with 444 additions and 19 deletions

View File

@ -20,6 +20,11 @@ var tests = new (string Name, Action Test)[]
("Truncated JSON is rejected", RejectsTruncatedJson),
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
("Chapter Structure confidence 0. 8 repairs and deserialises", ChapterStructureWhitespaceDecimalConfidenceRepairs),
("Chapter Structure confidence 7 .5 repairs when unambiguous", ChapterStructureWhitespaceBeforeDecimalRepairs),
("JSON repair leaves decimal-looking prose unchanged", JsonRepairLeavesDecimalProseUnchanged),
("Chapter Structure semantic validation still rejects out-of-range repaired confidence", ChapterStructureOutOfRangeRepairedConfidenceFailsValidation),
("Structural scene detection parser failure uses friendly diagnostics and retry", StructuralSceneDetectionUsesFriendlyParserDiagnosticsAndRetry),
("Chapter Structure boundary normaliser repairs gaps and overlaps", ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps),
("Chapter Structure boundary normaliser handles terminal ghost paragraph", ChapterStructureBoundaryNormaliserHandlesTerminalGhostParagraph),
("Chapter Structure boundary normaliser preserves genuine inverted ranges for validation", ChapterStructureBoundaryNormaliserPreservesGenuineInvertedRanges),
@ -862,6 +867,66 @@ static void ChapterStructureConfidenceRepairs()
Assert(result.Warnings.Any(warning => warning.Contains("$.sceneBoundaries[0].confidence", StringComparison.Ordinal)), "Repair warning did not include JSON path.");
}
static void ChapterStructureWhitespaceDecimalConfidenceRepairs()
{
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(
ChapterStructureJson("""0. 8"""),
JsonOptions());
Assert(result.Success, result.ErrorMessage ?? "Chapter Structure parse failed.");
Assert(result.Parsed?.SceneBoundaries?[0].Confidence == 0.8m, "Confidence was not repaired to 0.8.");
Assert(result.RepairedJson?.Contains("\"confidence\": 0.8", StringComparison.Ordinal) == true, result.RepairedJson ?? "No repaired JSON was returned.");
Assert(result.Warnings.Any(warning => warning.Contains("$.sceneBoundaries[0].confidence", StringComparison.Ordinal)), "Repair warning did not include JSON path.");
}
static void ChapterStructureWhitespaceBeforeDecimalRepairs()
{
var result = StoryIntelligenceJsonRepair.Repair("""{"value":7 .5}""");
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
Assert(result.RepairedJson.Contains("\"value\":7.5", StringComparison.Ordinal), result.RepairedJson);
}
static void JsonRepairLeavesDecimalProseUnchanged()
{
const string prose = "The code printed 0. 8 before Mara copied it.";
var result = StoryIntelligenceJsonRepair.Repair(
$$"""{"summary":"{{prose}}","confidence":0. 8}""");
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
using var document = JsonDocument.Parse(result.RepairedJson);
Assert(document.RootElement.GetProperty("summary").GetString() == prose, "Repair changed decimal-looking prose inside a JSON string.");
Assert(document.RootElement.GetProperty("confidence").GetDecimal() == 0.8m, "Repair did not fix the numeric value.");
}
static void ChapterStructureOutOfRangeRepairedConfidenceFailsValidation()
{
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(
ChapterStructureJson("""7 .5"""),
JsonOptions());
Assert(result.Success, result.ErrorMessage ?? "Chapter Structure parse failed.");
var validation = new ChapterStructureValidator().Validate(result.Parsed, 2);
Assert(!validation.IsValid, "Semantic validation should still reject repaired confidence outside 0.0 to 1.0.");
Assert(validation.Errors.Any(error => error.Message.Contains("Confidence must be from 0.0 to 1.0", StringComparison.Ordinal)), "Out-of-range confidence error was not reported.");
}
static void StructuralSceneDetectionUsesFriendlyParserDiagnosticsAndRetry()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
var dryRun = File.ReadAllText(Path.Combine(root, "Services/ChapterStoryIntelligenceDryRunService.cs"));
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md"));
Assert(analyzer.Contains("StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>", StringComparison.Ordinal), "Onboarding structural analysis should use the canonical AI JSON parser.");
Assert(!analyzer.Contains("JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson", StringComparison.Ordinal), "Onboarding structural analysis should not directly deserialize chapter JSON.");
Assert(dryRun.Contains("StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>", StringComparison.Ordinal), "Chapter dry-run should use the canonical AI JSON parser.");
Assert(analyzer.Contains("BuildJsonRetryPrompt", StringComparison.Ordinal), "Structural analysis should retry once when deterministic repair cannot parse the JSON.");
Assert(analyzer.Contains("PlotDirector could not read the scene structure returned", StringComparison.Ordinal), "User-facing parse failure should avoid raw JsonException details.");
Assert(analyzer.Contains("DiagnosticExcerpt", StringComparison.Ordinal), "Parser failures should log a safe diagnostic excerpt.");
Assert(prompt.Contains("no spaces inside the number", StringComparison.Ordinal), "Prompt should forbid spaces inside numeric JSON values.");
}
static void ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps()
{
var chapter = new ChapterStructureModel
@ -904,6 +969,7 @@ static void ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps()
var boundaries = result.ChapterStructure?.SceneBoundaries;
Assert(boundaries is { Count: 3 }, "Boundary normaliser should preserve the detected scene count.");
boundaries = result.ChapterStructure!.SceneBoundaries!;
Assert(boundaries[0].StartParagraph == 1 && boundaries[0].EndParagraph == 4, "First boundary should remain unchanged.");
Assert(boundaries[1].StartParagraph == 5 && boundaries[1].EndParagraph == 7, "Overlap should be repaired by moving scene 2 start to paragraph 5.");
Assert(boundaries[2].StartParagraph == 8 && boundaries[2].EndParagraph == 10, "Gap should be repaired by moving scene 3 start to paragraph 8.");
@ -943,6 +1009,7 @@ static void ChapterStructureBoundaryNormaliserHandlesTerminalGhostParagraph()
var boundaries = result.ChapterStructure?.SceneBoundaries;
Assert(boundaries is { Count: 1 }, "Terminal 619 of 618 ghost boundary should be discarded.");
boundaries = result.ChapterStructure!.SceneBoundaries!;
Assert(boundaries[0].StartParagraph == 1 && boundaries[0].EndParagraph == 618, "Final valid scene should still end at structural paragraph 618.");
Assert(result.Issues.Any(issue => issue.Message.Contains("terminal off-by-one", StringComparison.OrdinalIgnoreCase)), "Terminal off-by-one repair should be reported.");
}
@ -1001,6 +1068,24 @@ static void SharedParserReportsRawOutput()
Assert(result.RawJson == raw, "Raw malformed output was not retained.");
}
static string ChapterStructureJson(string confidence)
=> $$"""
{
"schemaVersion": "1.0",
"chapterSummary": "A chapter.",
"sceneBoundaries": [
{
"sceneNumber": 1,
"startParagraph": 1,
"endParagraph": 2,
"structuralSummary": "The scene follows a continuous action across two paragraphs while the immediate situation is established.",
"confidence": {{confidence}},
"reason": "Opening scene remains in the same place and time."
}
]
}
""";
static void CharacterFilteringRejectsGenericGroups()
{
Assert(IsGenericGroupReference("four examiners"), "Quantity + generic group should be rejected.");
@ -1689,7 +1774,7 @@ static void StructuralImportAddsSummariesWithoutExtraSceneAiCalls()
Assert(analyzer.Contains("StructuralSummary = Trim(boundary.StructuralSummary", StringComparison.Ordinal), "Boundary analysis should carry summaries into structural scenes.");
Assert(onboarding.Contains("SaveSceneStructuralSummaryAsync", StringComparison.Ordinal), "Core import should persist structural summaries after canonical SceneIDs exist.");
Assert(migration.Contains("SceneStructuralSummary_Save", StringComparison.Ordinal), "Structural summaries should be saved through a stored procedure.");
Assert(analyzer.Split("ExecutePromptAsync", StringSplitOptions.None).Length - 1 == 1, "Structural summaries must be produced by the existing chapter-boundary call, not extra scene calls.");
Assert(!analyzer.Contains("Scene-Prompt", StringComparison.Ordinal), "Structural summaries must be produced by the existing chapter-boundary call, not extra scene calls.");
}
static void CoreImportScanStageDefersScenesAndCharacters()

View File

@ -21,7 +21,9 @@ Your only task is to identify likely scene boundaries in one chapter and provide
3. Prefer slightly larger scenes over excessive fragmentation.
4. Do not split merely because there is a paragraph break.
5. Do not try to create perfect scenes.
6. Return JSON only.
6. Return strict JSON only.
7. Do not wrap JSON in markdown fences.
8. Do not include explanatory prose before or after the JSON object.
## Boundary Guidance
@ -123,6 +125,9 @@ Each `sceneBoundaries` item must contain exactly:
- `reason`
Use confidence values from `0.0` to `1.0`.
Confidence values must be standard JSON numbers with no spaces inside the number.
Correct examples: `0.8`, `0.75`, `1.0`, `0.0`.
Incorrect examples: `0. 8`, `7 .5`, `0,8`, `"0.8"`.
Use `structuralSummary` for a factual, neutral summary of what happens in that scene. It should normally be 25 to 40 words, avoid interpretation, and only use information present in the supplied text.

View File

@ -58,7 +58,13 @@ public sealed class ChapterStoryIntelligenceDryRunService(
cancellationToken,
clientStatus.ChapterStructureModel);
var chapterJson = ExtractOutputText(chapterResult.RawResponseText);
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
var chapterParse = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(chapterJson, JsonOptions);
if (!chapterParse.Success)
{
throw new JsonException(chapterParse.ErrorMessage ?? "Chapter Structure JSON could not be parsed.");
}
var parsedChapter = chapterParse.Parsed;
var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count);
var chapterForProcessing = chapterNormalisation.ChapterStructure;
var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);

View File

@ -46,19 +46,41 @@ public sealed class ManuscriptStructureAnalysisService(
var parseResult = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(chapterJson, JsonOptions);
if (!parseResult.Success)
{
LogStructuralJsonParseFailure(request, parseResult, repairSucceeded: false, retryAttempted: false);
var retryPrompt = BuildJsonRetryPrompt(
prompt,
parseResult.ErrorMessage ?? parseResult.InitialErrorMessage ?? "Chapter Structure JSON could not be parsed.",
chapterJson);
var retryResult = await client.ExecutePromptAsync(retryPrompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel);
var retryJson = ExtractOutputText(retryResult.RawResponseText);
var retryParseResult = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(retryJson, JsonOptions);
if (!retryParseResult.Success)
{
LogStructuralJsonParseFailure(request, retryParseResult, repairSucceeded: false, retryAttempted: true);
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
parseResult.ErrorMessage ?? "Chapter Structure JSON could not be parsed.",
parseResult.RawJson);
BuildUserJsonParseFailureMessage(request),
retryParseResult.RawJson);
}
var retryWarnings = new List<string> { "Chapter Structure JSON parse failed once and succeeded after one retry." };
retryWarnings.AddRange(retryParseResult.Warnings);
parseResult = retryParseResult with { Warnings = retryWarnings };
}
if (parseResult.Warnings.Count > 0)
{
logger.LogWarning(
"Repaired structural scene-boundary JSON. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Warnings={Warnings}",
"Repaired structural scene-boundary JSON. ProjectID={ProjectID} BookID={BookID} PreviewID={PreviewID} TemporaryChapterKey={TemporaryChapterKey} SourceLabel={SourceLabel} ParserFailure={ParserFailure} DiagnosticExcerpt={DiagnosticExcerpt} RepairSucceeded={RepairSucceeded} RetryAttempted={RetryAttempted} Warnings={Warnings}",
request.ProjectID,
request.BookID,
request.PreviewID,
request.TemporaryChapterKey,
request.SourceLabel,
parseResult.InitialErrorMessage,
BuildJsonDiagnosticExcerpt(parseResult.RawJson, parseResult.InitialErrorMessage),
parseResult.RepairedJson is not null,
parseResult.Warnings.Any(warning => warning.Contains("retry", StringComparison.OrdinalIgnoreCase)),
string.Join("; ", parseResult.Warnings));
}
@ -152,6 +174,26 @@ public sealed class ManuscriptStructureAnalysisService(
}
}
private void LogStructuralJsonParseFailure(
ManuscriptStructureAnalysisRequest request,
StoryIntelligenceAiJsonParseResult<ChapterStructureModel> parseResult,
bool repairSucceeded,
bool retryAttempted)
{
var parserFailure = parseResult.InitialErrorMessage ?? parseResult.ErrorMessage;
logger.LogWarning(
"Structural scene-boundary JSON parse failed. ProjectID={ProjectID} BookID={BookID} PreviewID={PreviewID} TemporaryChapterKey={TemporaryChapterKey} SourceLabel={SourceLabel} ParserFailure={ParserFailure} DiagnosticExcerpt={DiagnosticExcerpt} RepairSucceeded={RepairSucceeded} RetryAttempted={RetryAttempted}",
request.ProjectID,
request.BookID,
request.PreviewID,
request.TemporaryChapterKey,
request.SourceLabel,
parserFailure,
BuildJsonDiagnosticExcerpt(parseResult.RawJson, parserFailure),
repairSucceeded,
retryAttempted);
}
private static string BuildChapterContextJson(ManuscriptStructureAnalysisRequest request)
=> JsonSerializer.Serialize(new
{
@ -174,6 +216,27 @@ public sealed class ManuscriptStructureAnalysisService(
.Replace("{{CHAPTER_TEXT}}", chapterText ?? string.Empty, StringComparison.Ordinal);
}
private static string BuildJsonRetryPrompt(string originalPrompt, 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 Chapter Structure object as valid JSON only. Do not include markdown. Do not truncate. Numeric confidence values must be valid JSON numbers such as 0.8 or 0.0, with no spaces inside numbers.",
Environment.NewLine,
"Parse error:",
Environment.NewLine,
parseError,
Environment.NewLine,
"Malformed JSON returned previously:",
Environment.NewLine,
"```json",
Environment.NewLine,
malformedJson,
Environment.NewLine,
"```");
private static string ExtractOutputText(string rawResponseText)
{
var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(rawResponseText, JsonOptions)
@ -223,6 +286,90 @@ public sealed class ManuscriptStructureAnalysisService(
return first;
}
private static string BuildUserJsonParseFailureMessage(ManuscriptStructureAnalysisRequest request)
{
var label = ExtractChapterLabel(request.SourceLabel);
return string.IsNullOrWhiteSpace(label)
? "PlotDirector could not read the scene structure returned by the analysis. The analysis has been stopped and can be retried."
: $"PlotDirector could not read the scene structure returned for \"{label}\". The analysis has been stopped and can be retried.";
}
private static string ExtractChapterLabel(string sourceLabel)
{
if (string.IsNullOrWhiteSpace(sourceLabel))
{
return string.Empty;
}
var separator = sourceLabel.LastIndexOf(':');
return separator >= 0 && separator + 1 < sourceLabel.Length
? sourceLabel[(separator + 1)..].Trim()
: sourceLabel.Trim();
}
private static string BuildJsonDiagnosticExcerpt(string rawJson, string? parseError)
{
if (string.IsNullOrWhiteSpace(rawJson))
{
return "No raw JSON was available.";
}
if (!TryReadJsonErrorLocation(parseError, out var lineNumber, out var bytePosition))
{
return TrimDiagnostic(rawJson.ReplaceLineEndings(" "), 180);
}
var lines = rawJson.ReplaceLineEndings("\n").Split('\n');
if (lineNumber < 0 || lineNumber >= lines.Length)
{
return TrimDiagnostic(rawJson.ReplaceLineEndings(" "), 180);
}
var line = lines[lineNumber].Trim();
if (line.Length <= 180)
{
return line;
}
var start = Math.Max(0, Math.Min(bytePosition, line.Length) - 70);
var length = Math.Min(160, line.Length - start);
return TrimDiagnostic(line.Substring(start, length), 180);
}
private static bool TryReadJsonErrorLocation(string? parseError, out int lineNumber, out int bytePosition)
{
lineNumber = -1;
bytePosition = -1;
if (string.IsNullOrWhiteSpace(parseError))
{
return false;
}
const string lineMarker = "LineNumber: ";
const string byteMarker = "BytePositionInLine: ";
var lineStart = parseError.IndexOf(lineMarker, StringComparison.Ordinal);
var byteStart = parseError.IndexOf(byteMarker, StringComparison.Ordinal);
if (lineStart < 0 || byteStart < 0)
{
return false;
}
lineStart += lineMarker.Length;
byteStart += byteMarker.Length;
var lineEnd = parseError.IndexOfAny([' ', '|', '.'], lineStart);
var byteEnd = parseError.IndexOfAny([' ', '|', '.'], byteStart);
var lineValue = lineEnd > lineStart ? parseError[lineStart..lineEnd] : parseError[lineStart..];
var byteValue = byteEnd > byteStart ? parseError[byteStart..byteEnd] : parseError[byteStart..];
return int.TryParse(lineValue, out lineNumber)
&& int.TryParse(byteValue, out bytePosition);
}
private static string TrimDiagnostic(string value, int maxLength)
{
var clean = value.Trim();
return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd();
}
private static string BuildBoundaryDiagnostic(
ChapterStructureModel? chapter,
IReadOnlyList<ManuscriptScanParagraphMap> paragraphMap,
@ -280,6 +427,7 @@ public sealed class ManuscriptStructureAnalysisRequest
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public Guid PreviewID { get; init; }
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string SourceLabel { get; init; } = string.Empty;

View File

@ -268,6 +268,7 @@ public sealed class OnboardingService(
{
ProjectID = preview.ProjectID,
BookID = preview.BookID,
PreviewID = preview.PreviewID,
TemporaryChapterKey = chapterDecision.TemporaryChapterKey,
ChapterNumber = chapterDecision.ChapterNumber,
SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}",

View File

@ -16,7 +16,8 @@ public static partial class StoryIntelligenceJsonRepair
}
warnings.AddRange(extraction.Warnings);
var numericRepaired = RepairTrailingDecimalNumbers(extraction.Json, warnings);
var numericWhitespaceRepaired = RepairWhitespaceInDecimalNumbers(extraction.Json, warnings);
var numericRepaired = RepairTrailingDecimalNumbers(numericWhitespaceRepaired, warnings);
var slashRepaired = RepairInvalidBackslashesInStrings(numericRepaired, warnings);
if (!IsValidJson(slashRepaired, out var error))
@ -133,6 +134,162 @@ public static partial class StoryIntelligenceJsonRepair
return repaired;
}
private static string RepairWhitespaceInDecimalNumbers(string value, List<string> warnings)
{
var builder = new StringBuilder(value.Length);
var inString = false;
var escaped = false;
var changed = false;
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
if (inString)
{
builder.Append(ch);
if (escaped)
{
escaped = false;
}
else if (ch == '\\')
{
escaped = true;
}
else if (ch == '"')
{
inString = false;
}
continue;
}
if (ch == '"')
{
inString = true;
builder.Append(ch);
continue;
}
if ((ch == '-' || char.IsDigit(ch))
&& IsJsonNumberValueStart(value, i)
&& TryReadWhitespaceDecimalNumber(value, i, out var repairedNumber, out var endIndex))
{
builder.Append(repairedNumber);
i = endIndex - 1;
changed = true;
continue;
}
builder.Append(ch);
}
if (changed)
{
warnings.Add("Normalised whitespace inside decimal JSON number.");
}
return builder.ToString();
}
private static bool TryReadWhitespaceDecimalNumber(string value, int startIndex, out string repairedNumber, out int endIndex)
{
repairedNumber = string.Empty;
endIndex = startIndex;
var index = startIndex;
var builder = new StringBuilder();
var removedWhitespace = false;
if (value[index] == '-')
{
builder.Append(value[index]);
index++;
if (index >= value.Length || !char.IsDigit(value[index]))
{
return false;
}
}
var integerStart = index;
while (index < value.Length && char.IsDigit(value[index]))
{
builder.Append(value[index]);
index++;
}
if (index == integerStart)
{
return false;
}
var beforeDecimalWhitespaceStart = index;
while (index < value.Length && char.IsWhiteSpace(value[index]))
{
index++;
}
if (index >= value.Length || value[index] != '.')
{
return false;
}
removedWhitespace = index > beforeDecimalWhitespaceStart;
builder.Append('.');
index++;
var afterDecimalWhitespaceStart = index;
while (index < value.Length && char.IsWhiteSpace(value[index]))
{
index++;
}
removedWhitespace = removedWhitespace || index > afterDecimalWhitespaceStart;
var fractionStart = index;
while (index < value.Length && char.IsDigit(value[index]))
{
builder.Append(value[index]);
index++;
}
if (!removedWhitespace || index == fractionStart || !IsJsonValueEnd(value, index))
{
return false;
}
repairedNumber = builder.ToString();
endIndex = index;
return true;
}
private static bool IsJsonNumberValueStart(string value, int startIndex)
{
for (var i = startIndex - 1; i >= 0; i--)
{
if (char.IsWhiteSpace(value[i]))
{
continue;
}
return value[i] is ':' or '[' or ',';
}
return false;
}
private static bool IsJsonValueEnd(string value, int index)
{
for (var i = index; i < value.Length; i++)
{
if (char.IsWhiteSpace(value[i]))
{
continue;
}
return value[i] is ',' or '}' or ']';
}
return true;
}
private static string RepairInvalidBackslashesInStrings(string value, List<string> warnings)
{
var builder = new StringBuilder(value.Length);
@ -240,7 +397,11 @@ public static class StoryIntelligenceAiJsonParser
var repair = StoryIntelligenceJsonRepair.Repair(rawAssistantOutput);
if (!repair.Success)
{
return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repair.ErrorMessage ?? strictEx.Message);
return StoryIntelligenceAiJsonParseResult<T>.Failed(
extraction.Json,
repair.ErrorMessage ?? strictEx.Message,
strictEx.Message,
strictEx.Path);
}
try
@ -251,11 +412,13 @@ public static class StoryIntelligenceAiJsonParser
extraction.Json,
repair.RepairedJson,
parsed,
warnings);
warnings,
strictEx.Message,
strictEx.Path);
}
catch (JsonException repairedEx)
{
return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repairedEx.Message);
return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repairedEx.Message, strictEx.Message, strictEx.Path);
}
}
}
@ -265,7 +428,8 @@ public static class StoryIntelligenceAiJsonParser
var warnings = new List<string>();
foreach (var warning in repairWarnings)
{
if (warning.Contains("trailing decimal", StringComparison.OrdinalIgnoreCase))
if (warning.Contains("trailing decimal", StringComparison.OrdinalIgnoreCase)
|| warning.Contains("whitespace inside decimal", StringComparison.OrdinalIgnoreCase))
{
warnings.Add(string.IsNullOrWhiteSpace(strictErrorPath)
? "Repaired malformed AI JSON numeric value."
@ -310,15 +474,23 @@ public sealed record StoryIntelligenceAiJsonParseResult<T>(
string? RepairedJson,
T? Parsed,
IReadOnlyList<string> Warnings,
string? ErrorMessage)
string? ErrorMessage,
string? InitialErrorMessage = null,
string? InitialErrorPath = null)
{
public static StoryIntelligenceAiJsonParseResult<T> Ok(
string rawJson,
string? repairedJson,
T? parsed,
IReadOnlyList<string> warnings)
=> new(true, rawJson, repairedJson, parsed, warnings, null);
IReadOnlyList<string> warnings,
string? initialErrorMessage = null,
string? initialErrorPath = null)
=> new(true, rawJson, repairedJson, parsed, warnings, null, initialErrorMessage, initialErrorPath);
public static StoryIntelligenceAiJsonParseResult<T> Failed(string rawJson, string errorMessage)
=> new(false, rawJson, null, default, [], errorMessage);
public static StoryIntelligenceAiJsonParseResult<T> Failed(
string rawJson,
string errorMessage,
string? initialErrorMessage = null,
string? initialErrorPath = null)
=> new(false, rawJson, null, default, [], errorMessage, initialErrorMessage, initialErrorPath);
}

View File

@ -711,7 +711,15 @@ public sealed class ChapterStructureDryRunService(
}
private static ChapterStructureModel? DeserializeChapter(string chapterJson)
=> JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
{
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(chapterJson, JsonOptions);
if (!result.Success)
{
throw new JsonException(result.ErrorMessage ?? "Chapter Structure JSON could not be parsed.");
}
return result.Parsed;
}
private static string FormatDuration(TimeSpan duration)
=> duration.TotalSeconds < 1