mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user