fix(insights): tolerate fenced JSON from providers without a JSON mode

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 13:58:28 +02:00
1 parent 0193dbc1ee
commit e6ef9f2a3e
2 files changed
+107 -1

No files matched your search

@@ -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",
);
});
});
+45 -1
View File
@@ -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;
}