profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
json-repair.ts 3,160 bytes
1 /**
2 * Defensive parsing of model-produced JSON.
3 *
4 * Models wrap JSON in ```json fences, prepend "Here is the JSON:", emit trailing
5 * commas, or use smart quotes. We strip the obvious noise, extract the outermost
6 * JSON value, and light-touch repair common syntactic slips before validating.
7 * If parsing still fails, the caller retries once with a dedicated repair prompt
8 * (see `structured.ts`).
9 */
10 import type { z } from 'zod';
11
12 /** Strip Markdown code fences and surrounding prose from a JSON-ish string. */
13 export function stripCodeFences(raw: string): string {
14 let s = raw.trim();
15 // ```json … ``` or ``` … ```
16 const fence = s.match(/^```(?:json|jsonc|json5)?\s*\n?([\s\S]*?)\n?```$/i);
17 if (fence?.[1] !== undefined) s = fence[1].trim();
18 return s;
19 }
20
21 /**
22 * Extract the first balanced JSON object or array from arbitrary text.
23 * Returns null if no plausible JSON value is found. Correctly ignores braces
24 * that appear inside string literals.
25 */
26 export function extractJsonValue(raw: string): string | null {
27 const s = stripCodeFences(raw);
28 const start = s.search(/[{[]/);
29 if (start === -1) return null;
30
31 const open = s[start]!;
32 const close = open === '{' ? '}' : ']';
33 let depth = 0;
34 let inString = false;
35 let escaped = false;
36
37 for (let i = start; i < s.length; i++) {
38 const ch = s[i]!;
39 if (inString) {
40 if (escaped) escaped = false;
41 else if (ch === '\\') escaped = true;
42 else if (ch === '"') inString = false;
43 continue;
44 }
45 if (ch === '"') inString = true;
46 else if (ch === open) depth++;
47 else if (ch === close) {
48 depth--;
49 if (depth === 0) return s.slice(start, i + 1);
50 }
51 }
52 return null;
53 }
54
55 /** Light syntactic repairs that are safe and common. */
56 export function lightRepair(json: string): string {
57 return json
58 // smart quotes -> straight quotes
59 .replace(/[“”]/g, '"')
60 .replace(/[‘’]/g, "'")
61 // trailing commas before } or ]
62 .replace(/,\s*([}\]])/g, '$1');
63 }
64
65 export type ParseResult<T> =
66 | { ok: true; value: T }
67 | { ok: false; error: string; extracted: string | null };
68
69 /**
70 * Parse + validate a model response against a Zod schema, applying the full
71 * defensive pipeline. Never throws.
72 */
73 export function parseStructured<T>(raw: string, schema: z.ZodType<T>): ParseResult<T> {
74 const extracted = extractJsonValue(raw);
75 if (extracted === null) {
76 return { ok: false, error: 'No JSON object or array found in response', extracted: null };
77 }
78
79 for (const candidate of [extracted, lightRepair(extracted)]) {
80 try {
81 const parsed = JSON.parse(candidate);
82 const result = schema.safeParse(parsed);
83 if (result.success) return { ok: true, value: result.data };
84 // Parsed as JSON but failed schema - report the schema error.
85 return {
86 ok: false,
87 error: result.error.issues
88 .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
89 .join('; '),
90 extracted,
91 };
92 } catch {
93 // Try the next candidate (light-repaired form).
94 }
95 }
96 return { ok: false, error: 'Response was not valid JSON after repair', extracted };
97 }
98