From e6ef9f2a3e3cae77ee62a7074a09358a36656dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:56:26 +0200 Subject: [PATCH] fix(insights): tolerate fenced JSON from providers without a JSON mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic and the local provider do not send a JSON mode, so a compliant model still wraps its { "summary": … } reply in a code fence or prefixes a sentence, which broke JSON.parse and surfaced the raw fenced string as the assessment. Add a shared stripJsonFences helper (drop the fence, narrow to the first { … last }) and apply it as a parse fallback in parseSummaryFromContent. No-op on already-clean JSON. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../insights/__tests__/status-shared.test.ts | 62 +++++++++++++++++++ src/lib/insights/status-shared.ts | 46 +++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/lib/insights/__tests__/status-shared.test.ts diff --git a/src/lib/insights/__tests__/status-shared.test.ts b/src/lib/insights/__tests__/status-shared.test.ts new file mode 100644 index 000000000..576a7df97 --- /dev/null +++ b/src/lib/insights/__tests__/status-shared.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + parseSummaryFromContent, + stripJsonFences, +} from "@/lib/insights/status-shared"; + +/** + * The Anthropic + local providers have no native JSON mode, so a compliant + * model still routinely wraps its `{ "summary": … }` reply in a ```json + * fence or prefixes it with a sentence. These guards pin the fence-strip + * fallback so such replies parse instead of surfacing the raw fenced string + * as the user-facing assessment. + */ + +describe("stripJsonFences", () => { + it("strips a ```json fence", () => { + const out = stripJsonFences('```json\n{"summary":"ok"}\n```'); + expect(out).toBe('{"summary":"ok"}'); + }); + + it("strips a bare ``` fence", () => { + const out = stripJsonFences('```\n{"summary":"ok"}\n```'); + expect(out).toBe('{"summary":"ok"}'); + }); + + it("narrows to the first { … last } when prose surrounds the object", () => { + const out = stripJsonFences('Here is the result: {"summary":"ok"} done.'); + expect(out).toBe('{"summary":"ok"}'); + }); + + it("is a no-op on already-clean JSON", () => { + expect(stripJsonFences('{"summary":"ok"}')).toBe('{"summary":"ok"}'); + }); + + it("returns the trimmed input when there is no object body", () => { + expect(stripJsonFences(" plain prose ")).toBe("plain prose"); + }); +}); + +describe("parseSummaryFromContent", () => { + it("parses a clean JSON envelope", () => { + expect(parseSummaryFromContent('{"summary":"hello"}')).toBe("hello"); + }); + + it("parses a ```json-fenced envelope (no native JSON mode)", () => { + expect( + parseSummaryFromContent('```json\n{"summary":"hello"}\n```'), + ).toBe("hello"); + }); + + it("parses a sentence-prefixed envelope", () => { + expect( + parseSummaryFromContent('Sure:\n{"summary":"hello"} — done'), + ).toBe("hello"); + }); + + it("falls back to the raw content for bare prose", () => { + expect(parseSummaryFromContent("just prose, no json")).toBe( + "just prose, no json", + ); + }); +}); diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index 5c0c488ad..f10bbdd25 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -86,17 +86,61 @@ export function summarizeSeries( }; } +/** + * Strip a Markdown code fence and isolate the JSON object body. + * + * The Anthropic and local providers have no native JSON mode (only the + * OpenAI-family clients send `response_format: json_object`), so a + * compliant model still routinely wraps its `{ "summary": … }` reply in a + * ```json … ``` fence or prefixes it with a sentence. `JSON.parse` then + * throws and the caller would surface the raw fenced string as the + * user-facing assessment. This helper removes a leading/trailing fence and + * narrows to the first `{` … last `}` span so the parse sees clean JSON. + * + * It is a no-op on already-clean JSON and on genuinely fence-free prose + * with no braces — in both cases the original (trimmed) string is returned, + * so the bare-prose fallback below still works. + */ +export function stripJsonFences(content: string): string { + let text = content.trim(); + // Drop a leading ```json / ``` fence and a trailing ``` fence if present. + const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i); + if (fenced) { + text = fenced[1].trim(); + } + // Narrow to the first `{` … last `}` so a leading/trailing sentence the + // model added around the object does not break the parse. + const first = text.indexOf("{"); + const last = text.lastIndexOf("}"); + if (first !== -1 && last !== -1 && last > first) { + return text.slice(first, last + 1); + } + return text; +} + /** * Pull the `summary` string out of a model completion. The status * prompts return a `{ "summary": "…" }` envelope; a model that ignores * the contract and returns bare prose falls back to the raw content. + * + * Tries the content as-is first (the common, fence-free case), then a + * fence-stripped retry for providers without a native JSON mode. */ export function parseSummaryFromContent(content: string): string { try { const parsed = JSON.parse(content) as { summary?: string }; if (typeof parsed.summary === "string") return parsed.summary; } catch { - // not JSON — fall through to the raw content + // not directly parseable — try a fence-stripped retry below + } + try { + const stripped = stripJsonFences(content); + if (stripped !== content) { + const parsed = JSON.parse(stripped) as { summary?: string }; + if (typeof parsed.summary === "string") return parsed.summary; + } + } catch { + // still not JSON — fall through to the raw content } return content; }