File size: 4,420 Bytes
7fd62bd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
/**
 * src/utils/jsonCleaner.ts
 *
 * LLM responses almost never contain bare JSON. They typically:
 *   - Wrap JSON in ```json … ``` code fences
 *   - Prepend sentences like "Here is the JSON:"
 *   - Mix prose before/after the JSON block
 *   - Contain nested braces inside string literals
 *
 * This module extracts the first valid JSON object `{}` or array `[]`
 * from any arbitrary LLM response string using a character-level scan
 * that correctly handles strings, escapes, and nesting.
 */

// ─── Public API ───────────────────────────────────────────────────────────────

/**
 * Extracts and parses the first JSON object or array from an LLM response.
 *
 * @param raw - The full LLM response text
 * @returns The parsed value
 * @throws Error if no valid JSON can be extracted
 */
export function extractJson<T = unknown>(raw: string): T {
  if (!raw || typeof raw !== 'string') {
    throw new Error('extractJson: input must be a non-empty string');
  }

  // ── Strategy 1: Strip a markdown code fence if present ──────────────────
  const fenceMatch = raw.match(
    /```(?:json|yaml|js|javascript|typescript|dart)?\s*([\s\S]*?)```/i
  );
  if (fenceMatch) {
    const inner = fenceMatch[1].trim();
    try {
      return JSON.parse(inner) as T;
    } catch {
      // Fenced block wasn't valid JSON β€” fall through
    }
  }

  // ── Strategy 2: Find first `{` or `[` and matching close bracket ────────
  const objStart = raw.indexOf('{');
  const arrStart = raw.indexOf('[');

  // Determine which comes first
  let start: number;
  let openChar: '{' | '[';
  let closeChar: '}' | ']';

  if (objStart === -1 && arrStart === -1) {
    throw new Error(
      `extractJson: no JSON object or array found in response.\n` +
      `Response preview: ${raw.slice(0, 200)}`
    );
  }

  if (arrStart === -1 || (objStart !== -1 && objStart < arrStart)) {
    start = objStart;
    openChar = '{';
    closeChar = '}';
  } else {
    start = arrStart;
    openChar = '[';
    closeChar = ']';
  }

  // Character-level scan: track depth, skip string contents
  let depth = 0;
  let inString = false;
  let escape = false;
  let end = -1;

  for (let i = start; i < raw.length; i++) {
    const ch = raw[i];

    if (escape) {
      escape = false;
      continue;
    }

    if (inString) {
      if (ch === '\\') escape = true;
      else if (ch === '"') inString = false;
      continue;
    }

    // Not in a string
    if (ch === '"') { inString = true; continue; }
    if (ch === openChar)  { depth++;  continue; }
    if (ch === closeChar) {
      depth--;
      if (depth === 0) { end = i; break; }
    }
  }

  if (end === -1) {
    throw new Error(
      `extractJson: unbalanced brackets in LLM response.\n` +
      `Response preview: ${raw.slice(start, start + 300)}`
    );
  }

  const candidate = raw.slice(start, end + 1);

  try {
    return JSON.parse(candidate) as T;
  } catch (parseErr) {
    // Last resort: attempt to fix common LLM JSON mistakes
    const repaired = attemptRepair(candidate);
    if (repaired !== null) return repaired as T;

    throw new Error(
      `extractJson: JSON.parse failed.\n` +
      `Error: ${String(parseErr)}\n` +
      `Candidate (first 300 chars): ${candidate.slice(0, 300)}`
    );
  }
}

/**
 * Non-throwing variant of extractJson.
 * Returns `null` if extraction or parsing fails.
 */
export function tryExtractJson<T = unknown>(raw: string): T | null {
  try {
    return extractJson<T>(raw);
  } catch {
    return null;
  }
}

// ─── Private Helpers ──────────────────────────────────────────────────────────

/**
 * Attempts common repairs to malformed LLM JSON:
 * - Trailing commas before `}` or `]`
 * - Single-quoted strings
 * - Unquoted property keys (limited cases)
 */
function attemptRepair(json: string): unknown | null {
  let repaired = json;
  // Remove trailing commas in objects/arrays
  repaired = repaired.replace(/,\s*([}\]])/g, '$1');
  // Replace single-quoted strings with double-quoted
  repaired = repaired.replace(/'/g, '"');

  try {
    return JSON.parse(repaired);
  } catch {
    return null;
  }
}