---
name: llm-json-parsing-resilience
description: Handle malformed JSON from LLM responses — normalize field types, fix casing, strip markdown, provide defaults. Prevents "Invalid AI response structure" type errors.
version: 1.0.0
author: Hermes Agent
metadata:
  hermes:
    tags: [llm, json, parsing, normalization, structured-output, error-handling]
    related_skills: [systematic-debugging, writing-plans]
---

# LLM JSON Parsing Resilience

## When to Use

Apply this whenever parsing JSON or structured data from an LLM response — especially when:
- The LLM occasionally returns `tags` as a string (`"tag1, tag2"`) instead of an array (`["tag1", "tag2"]`)
- Enum values have wrong casing (`"Другое"` instead of `"другое"`)
- The LLM wraps JSON in markdown code fences (` ```json ... ``` `)
- Fields are missing or have unexpected types
- You're seeing "Invalid AI response structure" or similar parsing errors

## Root Cause

LLMs, especially smaller/cheaper models (Llama, Groq-hosted models), don't always follow JSON schema exactly:

```
Prompt: tags MUST be an array, e.g. ["тег1", "тег2", "тег3"]
LLM responds: tags: "тег1, тег2, тег3"  ← STRING, not array!
```

This is NOT a prompt issue — it's an LLM reliability issue. Fix the parser, not the prompt.

## The Pattern: Normalize Before Validate

### 1. Strip Markdown Fences

```typescript
const raw = text.trim()
  .replace(/^```(?:json)?\s*/i, "")
  .replace(/\s*```\s*$/, "");
```

### 2. Parse — Then Normalize Field Types

```typescript
const parsed = JSON.parse(raw);

// Normalize tags: string → array
if (typeof parsed.tags === "string") {
  parsed.tags = (parsed.tags as string)
    .split(/[,\n]+/)  // comma or newline separated
    .map(t => t.trim())
    .filter(Boolean);
}

// Normalize category: fix casing
if (typeof parsed.category === "string") {
  parsed.category = (parsed.category as string).toLowerCase();
}

// Normalize optional numeric/boolean fields
if (typeof parsed.count === "string") {
  parsed.count = parseInt(parsed.count, 10);
}
if (typeof parsed.isActive === "string") {
  parsed.isActive = parsed.isActive === "true" || parsed.isActive === "yes";
}
```

### 3. Then Validate Schema

```typescript
if (!parsed.title || !parsed.content || !parsed.category || !Array.isArray(parsed.tags)) {
  throw new Error("Invalid AI response structure");
}
```

## Common Normalizations

| Expected Type | LLM Returns | Fix |
|---|---|---|
| `string[]` (array) | `"a, b, c"` (string) | `.split(/[,\n]+/)` → trim → filter |
| Lowercase enum | `"Другое"` | `.toLowerCase()` |
| `number` | `"42"` (string) | `parseInt()` or `Number()` |
| `boolean` | `"true"` (string) | Compare string value |
| `null`/missing | `undefined` | `?? default` operator |

## Pitfalls

### Regex Escaping in Compiled Code

When the normalization regex is constructed in a template literal that gets compiled:
- **WRONG:** `split(/[,\\n]+/)` → matches comma, backslash, OR `n`
- **CORRECT:** `split(/[,\n]+/)` → matches comma OR newline

Check compiled output (dist/) to verify the regex byte sequence:
```
Correct: 2f 5b 2c 5c 6e 5d 2b 2f  ← one backslash (0x5c) before n
Wrong:   2f 5b 2c 5c 5c 6e 5d 2b 2f  ← two backslashes (0x5c 0x5c) before n
```

In TypeScript source, use: `/[, (backslash) n]+/` — the `\n` in a regex literal is the newline escape.

In a JavaScript string that generates source code, use `\\n` to produce `\n` in the output file.

### Don't Only Validate — Normalize First

The validation check (`!Array.isArray(parsed.tags)`) rejects the response entirely if tags is a string.
**Always normalize before validating** — otherwise the user gets "Something went wrong" with no recovery.

### Idempotency

Normalization should be idempotent — running it twice produces the same result as running it once.
(Optional: check `if (typeof parsed.tags === "string")` before normalizing.)

## Template: Resilient Parser Function

```typescript
interface ProcessedPost {
  title: string;
  content: string;
  category: string;
  tags: string[];
}

function parseAIResponse(raw: string): ProcessedPost {
  // 1. Strip markdown fences
  const clean = raw.trim()
    .replace(/^```(?:json)?\s*/i, "")
    .replace(/\s*```\s*$/, "");
  
  // 2. Parse
  const parsed = JSON.parse(clean) as ProcessedPost;
  
  // 3. Normalize before validate
  if (typeof parsed.tags === "string") {
    parsed.tags = (parsed.tags as string)
      .split(/[,\n]+/)
      .map(t => t.trim())
      .filter(Boolean);
  }
  if (typeof parsed.category === "string") {
    parsed.category = parsed.category.toLowerCase();
  }
  
  // 4. Validate
  if (!parsed.title || !parsed.content || !parsed.category || !Array.isArray(parsed.tags)) {
    throw new Error("Invalid AI response structure");
  }
  
  return parsed;
}
```

## Verification

After deploying the fix, send a test prompt that previously failed to confirm:
- The response is now parsed successfully
- Tags appear as an array of strings
- Category matches the expected enum key
- No "Invalid AI response structure" error in logs
