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
+47 -19
View File
@@ -1,12 +1,13 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { parseLlmJson, flattenValue } from "../lib/llm-parse";
interface ItemResponse {
name: string;
rarity: string;
type: string;
description: string;
mechanical: string;
mechanical: string | Record<string, unknown>;
lore: string;
}
@@ -18,6 +19,7 @@ export function ItemForge() {
const [itemType, setItemType] = useState("Wondrous Item");
const [prompt, setPrompt] = useState("");
const [item, setItem] = useState<ItemResponse | null>(null);
const [rawResponse, setRawResponse] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -25,6 +27,7 @@ export function ItemForge() {
setLoading(true);
setError("");
setItem(null);
setRawResponse(null);
try {
const result = await invoke<string>("generate", {
req: {
@@ -33,23 +36,29 @@ Rarity: ${rarity}
Type: ${itemType}
${prompt ? `Additional details: ${prompt}` : ""}
Provide the response as a JSON object with exactly these keys:
- "name": the item's evocative name
- "rarity": "${rarity}"
- "type": "${itemType}"
- "description": a vivid 2-3 sentence physical description
- "mechanical": the item's game mechanics (what it does, charges, etc.)
- "lore": a 1-2 sentence piece of lore or history about the item`,
system: "You are a creative D&D item designer. Always respond with valid JSON only.",
IMPORTANT: Return ONLY a raw JSON object, with NO markdown fences, NO code blocks, NO extra text.
The response must start with { and end with }.
The JSON must have exactly these keys:
- "name": the item's evocative name (string)
- "rarity": "${rarity}" (string)
- "type": "${itemType}" (string)
- "description": a vivid 2-3 sentence physical description (string)
- "mechanical": the item's game mechanics as a single descriptive paragraph (string, NOT an object)
- "lore": a 1-2 sentence piece of lore or history (string)`,
system: "You are a creative D&D item designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.85,
max_tokens: 500,
},
});
const jsonMatch = result.match(/\{[\s\S]*\}/);
if (jsonMatch) {
setItem(JSON.parse(jsonMatch[0]) as ItemResponse);
setRawResponse(result);
const parsed = parseLlmJson<ItemResponse>(result);
if (parsed) {
setItem(parsed);
} else {
setError("LLM did not return valid JSON.\n" + result);
setError("Could not parse LLM response as JSON. Raw response shown below.");
}
} catch (e) {
setError(String(e));
@@ -66,6 +75,9 @@ Provide the response as a JSON object with exactly these keys:
Artifact: "#e6cc80",
};
const mechanicalText = item ? flattenValue(item.mechanical) : "";
const hasParseError = !item && rawResponse;
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-2">
@@ -123,32 +135,48 @@ Provide the response as a JSON object with exactly these keys:
<div className="flex items-start justify-between">
<div>
<h3 className="font-heading text-lg font-bold" style={{ color: rarityColor[item.rarity] || "var(--color-gold-bright)" }}>
{item.name}
{item.name || "Unknown Item"}
</h3>
<div className="flex gap-2 text-xs text-[var(--color-text-dim)]">
<span>{item.rarity}</span>
<span>{item.rarity || rarity}</span>
<span></span>
<span>{item.type}</span>
<span>{item.type || itemType}</span>
</div>
</div>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium uppercase tracking-wider">Description</span>
<p className="text-[var(--color-text-primary)] text-sm mt-0.5 leading-relaxed">{item.description}</p>
<p className="text-[var(--color-text-primary)] text-sm mt-0.5 leading-relaxed">
{flattenValue(item.description)}
</p>
</div>
<div className="rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-glass)] p-3">
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Mechanics</span>
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{item.mechanical}</p>
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{mechanicalText}</p>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium uppercase tracking-wider">Lore</span>
<p className="text-[var(--color-text-secondary)] text-sm mt-0.5 italic">{item.lore}</p>
<p className="text-[var(--color-text-secondary)] text-sm mt-0.5 italic">
{flattenValue(item.lore)}
</p>
</div>
</div>
)}
{/* Show raw response if JSON parsing failed */}
{hasParseError && (
<details className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)]">
<summary className="px-3 py-2 text-xs text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)]">
Raw LLM response (click to expand)
</summary>
<pre className="px-3 pb-3 text-xs text-[var(--color-text-secondary)] overflow-x-auto whitespace-pre-wrap">
{rawResponse}
</pre>
</details>
)}
</div>
);
}