From 2c4c1a6846d57e52a5fdd34bd2c89076489c8bc1 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 25 Apr 2026 19:45:53 -0400 Subject: [PATCH] Fix AI price check truncation and JSON parse errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Services/AiCatalogPriceCheckService.cs | 38 ++++++++++++++++--- .../Controllers/CatalogItemsController.cs | 16 ++++++-- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs b/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs index da28b2e..da7b471 100644 --- a/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs +++ b/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs @@ -20,7 +20,7 @@ public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService private readonly ILogger _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 } /// - /// 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. /// - 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 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 { @@ -110,7 +136,7 @@ public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService { var response = await SendAsync(client, parameters); var raw = response.Content.OfType().FirstOrDefault()?.Text ?? "[]"; - var json = StripJsonFences(raw); + var json = ExtractJsonArray(raw); var claudeItems = JsonSerializer.Deserialize>(json, JsonOpts) ?? new(); diff --git a/src/PowderCoating.Web/Controllers/CatalogItemsController.cs b/src/PowderCoating.Web/Controllers/CatalogItemsController.cs index e4a6aa4..5eb3ee6 100644 --- a/src/PowderCoating.Web/Controllers/CatalogItemsController.cs +++ b/src/PowderCoating.Web/Controllers/CatalogItemsController.cs @@ -944,9 +944,19 @@ namespace PowderCoating.Web.Controllers CatalogPriceCheckReportDto? dto = null; if (report != null) { - var results = JsonSerializer.Deserialize>( - report.ResultsJson, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new(); + List results; + try + { + results = JsonSerializer.Deserialize>( + report.ResultsJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize stored price check report {ReportId}", report.Id); + TempData["Warning"] = "The previous report could not be loaded. Please re-run the price check."; + results = new(); + } dto = new CatalogPriceCheckReportDto {