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:
@@ -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 [];
|
||||
}
|
||||
Reference in New Issue
Block a user