Skip to the solution
HardAI Engineering17 min read

How do you validate and repair structured LLM output at runtime?

480 views

The problem

Question presented to candidate: "We extract invoices with an LLM and write them straight into our billing database. Even with strict JSON output we occasionally store a total that does not add up or a customer id that does not exist. Design the runtime layer that validates the model's output and repairs it, and tell me when you would stop trying."

What a strong answer should cover:

  • Layers of validation in order: stop reason (refusal, max_tokens truncation), parse, schema (types, enums, required keys), semantic rules (cross-field arithmetic, ranges, dates) and referential checks (does this id exist, does this user own it)
  • A repair ladder from cheap to expensive: deterministic local fixes that never invent values (strip code fences, trailing commas), then a re-ask that sends the previous output plus precise, path-named errors, then a deterministic fallback (review queue, safe default, partial result flagged)
  • Bounding: one or two re-asks at most, because a value the model cannot know (a missing id) will only be re-invented; never accept on timeout and never auto-close truncated JSON
  • Treating the output as untrusted input: validation is also a security boundary, since injected content can steer field values
  • Metrics: first-pass validity, repair rate, fallback rate and errors per field, used both to fix the schema or prompt and as an early drift alarm after a model or prompt change

Clarifying questions expected:

  • Does the provider support strict schema output for this model, so shape errors are already gone and the layer can focus on meaning?
  • What is the cost of a wrong record compared with a delayed one (that sets how quickly we fall back to a human)?
  • Can the model be given the lookup it needs (for example a customer-search tool) instead of being asked to recall ids?

Code / implementation expected: Yes โ€” a validate-then-repair loop with a retry bound and a fallback, using Zod, Pydantic or hand-written checks.

  • validation
  • structured-output
  • repair-loop
  • reliability

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

17 min read ยท 12 sections

Read the code

Validate, repair locally, re-ask with errors, fall back: an invoice pipeline
// Validate -> cheap local repair -> bounded re-ask -> fallback, with metrics.
// The "model" is a stub with scripted replies per document (no network).
const CUSTOMERS = new Set(["C-1001", "C-1002", "C-1003"]);
const CURRENCIES = ["EUR", "USD", "GBP"];
const MAX_REASKS = 2;

const STUB = {
  "doc-1": ['{"invoice_id":"INV-0001","customer_id":"C-1001","currency":"EUR","line_items":[{"sku":"A1","qty":2,"unit_cents":500}],"total_cents":1000}'],
  "doc-2": ['Here is the JSON:\n```json\n{"invoice_id":"INV-0002","customer_id":"C-1002","currency":"USD","line_items":[{"sku":"B7","qty":1,"unit_cents":1999},],"total_cents":1999}\n```'],
  "doc-3": ['{"invoice_id":"INV-0003","customer_id":"C-1003","currency":"GBP","line_items":[{"sku":"C2","qty":3,"unit_cents":250},{"sku":"C9","qty":1,"unit_cents":400}],"total_cents":1250}',
            '{"invoice_id":"INV-0003","customer_id":"C-1003","currency":"GBP","line_items":[{"sku":"C2","qty":3,"unit_cents":250},{"sku":"C9","qty":1,"unit_cents":400}],"total_cents":1150}'],
  "doc-4": ['{"invoice_id":"INV-0004","customer_id":"C-1002","currency":"euro","line_items":[{"sku":"D4","qty":"2","unit_cents":300}],"total_cents":600}',
            '{"invoice_id":"INV-0004","customer_id":"C-1002","currency":"EUR","line_items":[{"sku":"D4","qty":2,"unit_cents":300}],"total_cents":600}'],
  "doc-5": ['{"invoice_id":"INV-0005","customer_id":"C-9999","currency":"USD","line_items":[{"sku":"E1","qty":1,"unit_cents":100}],"total_cents":100}',
            '{"invoice_id":"INV-0005","customer_id":"C-9999","currency":"USD","line_items":[{"sku":"E1","qty":1,"unit_cents":100}],"total_cents":100}',
            '{"invoice_id":"INV-0005","customer_id":"C-0005","currency":"USD","line_items":[{"sku":"E1","qty":1,"unit_cents":100}],"total_cents":100}'],
};
const calls = {};
function callModel(doc, prompt) { calls[doc] = (calls[doc] || 0) + 1; return STUB[doc][Math.min(calls[doc] - 1, STUB[doc].length - 1)]; }

// Stage 1: safe, deterministic text repairs only (no guessing values).
function localRepair(text) {
  const fixes = [];
  let t = text;
  const a = t.indexOf("{"), b = t.lastIndexOf("}");
  if (a > 0 || (b !== -1 && b < t.length - 1)) { t = t.slice(a, b + 1); fixes.push("stripped prose/fences"); }
  const noTrailing = t.replace(/,\s*([}\]])/g, "$1");
  if (noTrailing !== t) { t = noTrailing; fixes.push("removed trailing comma"); }
  return { t, fixes };
}
const isInt = (v) => typeof v === "number" && Number.isInteger(v);
// Stage 2: schema. Stage 3: semantic + referential rules. Errors name the path.
function validate(o) {
  const e = [];
  if (typeof o.invoice_id !== "string" || !/^INV-\d{4}$/.test(o.invoice_id)) e.push(`invoice_id: expected pattern INV-0000, got ${JSON.stringify(o.invoice_id)}`);
  if (typeof o.customer_id !== "string") e.push(`customer_id: expected string`);
  if (!CURRENCIES.includes(o.currency)) e.push(`currency: expected one of ${CURRENCIES.join("|")}, got ${JSON.stringify(o.currency)}`);
  if (!Array.isArray(o.line_items) || o.line_items.length === 0) e.push("line_items: expected a non-empty array");
  else o.line_items.forEach((li, i) => {
    if (!isInt(li.qty) || li.qty < 1) e.push(`line_items[${i}].qty: expected integer >= 1, got ${JSON.stringify(li.qty)}`);
    if (!isInt(li.unit_cents) || li.unit_cents < 0) e.push(`line_items[${i}].unit_cents: expected integer >= 0, got ${JSON.stringify(li.unit_cents)}`);
  });
  if (!isInt(o.total_cents)) e.push("total_cents: expected integer");
  if (e.length) return { kind: "schema", errors: e };
  const sum = o.line_items.reduce((s, li) => s + li.qty * li.unit_cents, 0);
  if (sum !== o.total_cents) e.push(`total_cents: ${o.total_cents} but line items sum to ${sum}`);
  if (!CUSTOMERS.has(o.customer_id)) e.push(`customer_id: ${o.customer_id} does not exist`);
  return e.length ? { kind: "semantic", errors: e } : { kind: "ok", errors: [] };
}

const stats = { firstPass: 0, localOnly: 0, afterReask: 0, fallback: 0, modelCalls: 0 };
const fieldErrors = {};
function handle(doc) {
  console.log(`\n${doc}`);
  let prompt = `Extract the invoice in ${doc} as JSON.`;
  for (let attempt = 0; attempt <= MAX_REASKS; attempt++) {
    const raw = callModel(doc, prompt);
    stats.modelCalls++;
    const { t, fixes } = localRepair(raw);
    let obj = null;
    try { obj = JSON.parse(t); } catch { /* handled below */ }
    const v = obj ? validate(obj) : { kind: "parse", errors: ["output is not valid JSON"] };
    for (const err of v.errors) { const f = err.split(":")[0].replace(/\[\d+\]/, "[]"); fieldErrors[f] = (fieldErrors[f] || 0) + 1; }
    const tag = fixes.length ? ` (local repair: ${fixes.join(", ")})` : "";
    if (v.kind === "ok") {
      console.log(`  attempt ${attempt + 1}: valid${tag}`);
      if (attempt > 0) stats.afterReask++; else if (fixes.length) stats.localOnly++; else stats.firstPass++;
      return;
    }
    console.log(`  attempt ${attempt + 1}: ${v.kind} errors${tag}`);
    for (const err of v.errors) console.log(`    - ${err}`);
    if (attempt === MAX_REASKS) break;
    prompt = `Your previous output had these problems:\n${v.errors.map((x) => `- ${x}`).join("\n")}\nReturn the corrected JSON only.`;
    if (doc === "doc-3") console.log(`  re-ask prompt sent:\n    ${prompt.split("\n").join("\n    ")}`);
  }
  stats.fallback++;
  console.log(`  -> gave up after ${MAX_REASKS} re-asks: routed to the human review queue, not saved`);
}

for (const doc of Object.keys(STUB)) handle(doc);
const n = Object.keys(STUB).length;
console.log("\nsummary");
console.log(`  documents ${n} | valid first pass ${stats.firstPass} | fixed locally ${stats.localOnly} | fixed by re-ask ${stats.afterReask} | fallback ${stats.fallback}`);
console.log(`  model calls ${stats.modelCalls} for ${n} documents (${(stats.modelCalls / n).toFixed(1)} per document)`);
console.log(`  repair rate (anything other than clean first pass): ${((n - stats.firstPass) / n * 100).toFixed(0)}%`);
console.log(`  errors by field: ${Object.entries(fieldErrors).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).map(([k, c]) => `${k}=${c}`).join(", ")}`);
Truncated output: why auto-closing brackets is not a repair
// Truncated output (stop_reason "max_tokens"): why "close the brackets" is not a repair.
const FULL = {
  total_cents: 1150,
  line_items: [
    { sku: "C2", qty: 3, unit_cents: 250 },
    { sku: "C9", qty: 1, unit_cents: 400 },
  ],
  tags: ["urgent", "eu", "paper"],
};
const text = JSON.stringify(FULL);

// Naive "repair": cut after the last complete } or ] and append the missing closers.
function autoClose(t) {
  let inStr = false, esc = false, lastClose = -1;
  for (let i = 0; i < t.length; i++) {
    const c = t[i];
    if (esc) { esc = false; continue; }
    if (c === "\\") { esc = true; continue; }
    if (c === '"') inStr = !inStr;
    else if (!inStr && (c === "}" || c === "]")) lastClose = i;
  }
  let cut = t.slice(0, lastClose + 1);
  const stack = [];
  inStr = false;
  for (const c of cut) {
    if (c === '"') inStr = !inStr;
    else if (!inStr && (c === "{" || c === "[")) stack.push(c === "{" ? "}" : "]");
    else if (!inStr && (c === "}" || c === "]")) stack.pop();
  }
  return cut + stack.reverse().join("");
}
function check(o) {
  const errs = [];
  for (const k of ["total_cents", "line_items"]) if (!(k in o)) errs.push(`missing ${k}`); // tags is optional
  if (Array.isArray(o.line_items) && Number.isInteger(o.total_cents)) {
    const sum = o.line_items.reduce((s, li) => s + li.qty * li.unit_cents, 0);
    if (sum !== o.total_cents) errs.push(`total ${o.total_cents} != sum ${sum}`);
  }
  return errs;
}

console.log(`full output is ${text.length} characters`);
for (const cutAt of [75, 125]) {
  const truncated = text.slice(0, cutAt);
  console.log(`\nstop_reason=max_tokens after ${cutAt} chars: ...${truncated.slice(-24)}`);
  let parsed = true;
  try { JSON.parse(truncated); } catch { parsed = false; }
  console.log(`  JSON.parse(raw)      : ${parsed ? "ok" : "fails"}`);
  const fixed = autoClose(truncated);
  const o = JSON.parse(fixed);
  console.log(`  auto-closed          : ${fixed}`);
  const errs = check(o);
  console.log(`  validator            : ${errs.length ? errs.join("; ") : "PASSES"}`);
  console.log(`  items kept           : ${o.line_items ? o.line_items.length : 0} of 2, tags kept ${o.tags ? o.tags.length : 0} of 3`);
  console.log(`  stop_reason check    : reject and retry with a larger max_tokens`);
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 76 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

pgvector vs Pinecone vs Qdrant: how do you choose a vector DB in 2025?

Continue