Fix AI price check truncation and JSON parse errors

Root cause: MaxTokens=4096 was too low — 25 items at ~250 tokens each hit the
limit mid-array (logged error showed Path: $[17]).

- MaxTokens: 4096 → 8192
- BatchSize: 25 → 15 items (keeps each response well under the limit)
- StripJsonFences → ExtractJsonArray: now also handles prose before/after the
  JSON array, and recovers truncated responses by finding the last complete
  object and closing the array — so partial batches return whatever Claude
  finished rather than nothing
- GET action: added try-catch around ResultsJson deserialization so a bad DB
  row shows a friendly "re-run" warning instead of a raw error page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 19:45:53 -04:00
parent c9324ee0b0
commit 2c4c1a6846
2 changed files with 45 additions and 9 deletions
@@ -20,7 +20,7 @@ public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService
private readonly ILogger<AiCatalogPriceCheckService> _logger;
private const string Model = "claude-sonnet-4-6";
private const int BatchSize = 25;
private const int BatchSize = 15; // 25 items at ~250 tokens each exceeds 4096 output tokens
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
@@ -37,18 +37,44 @@ public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService
}
/// <summary>
/// Strips optional ```json ... ``` fences that Claude sometimes adds despite instructions.
/// Extracts a JSON array from Claude's response, handling three common failure modes:
/// (1) ```json ... ``` fences wrapping the array,
/// (2) prose text before or after the JSON array,
/// (3) truncated responses where the closing ] is missing — in that case we close any
/// open string and append ]} to produce a parseable (though incomplete) array so
/// we recover whatever items Claude did finish.
/// </summary>
private static string StripJsonFences(string text)
private static string ExtractJsonArray(string text)
{
var trimmed = text.Trim();
// Strip code fences
if (trimmed.StartsWith("```"))
{
var firstNewline = trimmed.IndexOf('\n');
if (firstNewline >= 0) trimmed = trimmed[(firstNewline + 1)..];
if (trimmed.EndsWith("```")) trimmed = trimmed[..^3];
trimmed = trimmed.Trim();
}
return trimmed.Trim();
// Find the outermost [ ... ] even when Claude adds prose around it
var arrayStart = trimmed.IndexOf('[');
if (arrayStart < 0) return "[]";
trimmed = trimmed[arrayStart..];
var arrayEnd = trimmed.LastIndexOf(']');
if (arrayEnd >= 0)
return trimmed[..(arrayEnd + 1)];
// No closing bracket — response was truncated. Patch it so we can recover
// whatever complete objects Claude did return.
// Strategy: find the last complete }, and close the array after it.
var lastComplete = trimmed.LastIndexOf("},");
if (lastComplete < 0) lastComplete = trimmed.LastIndexOf('}');
if (lastComplete >= 0)
return trimmed[..(lastComplete + 1)] + "]";
return "[]";
}
private static async Task<MessageResponse> SendAsync(AnthropicClient client, MessageParameters parameters)
@@ -98,7 +124,7 @@ public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService
var parameters = new MessageParameters
{
Model = Model,
MaxTokens = 4096,
MaxTokens = 8192,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
@@ -110,7 +136,7 @@ public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService
{
var response = await SendAsync(client, parameters);
var raw = response.Content.OfType<TextContent>().FirstOrDefault()?.Text ?? "[]";
var json = StripJsonFences(raw);
var json = ExtractJsonArray(raw);
var claudeItems = JsonSerializer.Deserialize<List<ClaudePriceCheckItem>>(json, JsonOpts) ?? new();