fix: robust LLM JSON parsing across all AI components

- Created shared lib/llm-parse.ts with extractJson(), parseLlmJson(),
  flattenValue(), and ensureArray() utilities
- Strips markdown code fences (\`\`\`json ... \`\`\`) before parsing
- Handles nested objects where strings were expected (flattens them)
- Handles arrays that come back as strings or objects
- Updated all AI prompts to explicitly request raw JSON with no fences
- All 5 AI components now use parseLlmJson() instead of raw regex
- ItemForge shows raw LLM response in expandable <details> when parsing fails
- Added 'IMPORTANT: Return ONLY raw JSON' instruction to all prompts
- Full production build passing
This commit is contained in:
itsamejms
2026-06-28 23:03:10 +01:00
parent 9b0483a2ab
commit b23ab61841
6 changed files with 192 additions and 63 deletions
+91
View File
@@ -0,0 +1,91 @@
/**
* Parse LLM output into JSON, handling common LLM quirks:
* 1. Markdown code fences (```json ... ``` or ``` ... ```)
* 2. Leading/trailing text around the JSON
* 3. Nested objects where strings were expected (flatten them)
*/
/** Strip markdown code fences and extract the JSON object */
export function extractJson(raw: string): string | null {
// Remove markdown code fences: ```json ... ``` or ``` ... ```
let cleaned = raw.trim();
// Try to extract from code fence
const fenceMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
if (fenceMatch) {
cleaned = fenceMatch[1].trim();
}
// Find the outermost { ... } brace pair
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start !== -1 && end > start) {
return cleaned.substring(start, end + 1);
}
return null;
}
/** Parse JSON from LLM output, stripping fences and handling errors */
export function parseLlmJson<T>(raw: string): T | null {
const jsonStr = extractJson(raw);
if (!jsonStr) return null;
try {
return JSON.parse(jsonStr) as T;
} catch (e) {
console.error("JSON parse error:", e, "Input:", jsonStr);
return null;
}
}
/**
* Flatten nested objects into display-friendly strings.
* If a value is an object, stringify it nicely.
* If a value is an array of objects, join them.
*/
export function flattenValue(val: unknown): string {
if (val === null || val === undefined) return "";
if (typeof val === "string") return val;
if (typeof val === "number") return String(val);
if (typeof val === "boolean") return String(val);
if (Array.isArray(val)) {
return val.map(flattenValue).join("\n");
}
if (typeof val === "object") {
// Pretty-format the object as key: value pairs
return Object.entries(val as Record<string, unknown>)
.map(([k, v]) => {
const formatted = typeof v === "string" ? v : JSON.stringify(v);
return `**${formatKey(k)}**: ${formatted}`;
})
.join("\n");
}
return String(val);
}
/** Convert camelCase or snake_case keys to Title Case */
function formatKey(key: string): string {
return key
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Safely get an array from a field that might be a string or array */
export function ensureArray(val: unknown): string[] {
if (Array.isArray(val)) {
return val.map((v) => (typeof v === "string" ? v : JSON.stringify(v)));
}
if (typeof val === "string") {
// Try to parse as JSON array
try {
const parsed = JSON.parse(val);
if (Array.isArray(parsed)) return parsed.map((v: unknown) => String(v));
} catch {
// Not JSON, return as single-element array
}
return [val];
}
return [];
}