← All case studies · McKeen Asma · Portfolio

Case study · LLM evals · CI quality gate

LLM eval harness: catch a worse prompt before it ships

An eval harness for a Claude support-ticket triage feature: a 20-case golden dataset, exact-match and LLM-as-judge graders, the cost of each run, and a CI gate that fails the PR when quality drops.

The 59-second demo: the README, the architecture, the graders and the CI gate in src/eval.ts + eval.yml, the 5 tests passing and the regressed replay failing the gate (exit 1). No audio; captions on screen.
5 / 5tests pass, including the gate passing good and failing bad
19 → 14of 20 cases: good vs regressed recording, gate PASS vs FAIL
exit 1on a pass-rate drop or any urgent ticket missed

The problem it solves

A prompt change that looks better on three examples can quietly break the cases that matter. Without a test, the first person to notice is a customer whose urgent ticket was filed as normal.

This harness turns that into a red build. It runs the feature over a golden dataset, grades exact fields exactly and only uses an LLM judge for free text, reports the pass rate, per-field accuracy and the cost of the run, then fails the PR if the pass rate drops more than a tolerance below the baseline, or if any single urgent ticket is missed, whatever the average says.

How it fits together

Flowchart: evals/dataset.json with 20 cases feeds runEval, which sends each ticket to the triage call on claude-opus-5-5 with json_schema output (live SDK client or replay client); outputs go to JSON and exact-match graders and the reply to an LLM judge; both feed a report with pass rate, field accuracy and cost; the gate compares against the baseline and critical cases and exits 0 or exits 1 so the PR fails
The architecture diagram from the README (Mermaid, rendered to SVG). Tap to open it full size.
GraderChecks
JSONthe output parses (a broken response fails the case instead of crashing the run)
Exact matchcategory, priority, order_id
LLM-as-judgethe free-text reply against a rubric: polite, on-topic, no promised refunds, amounts or dates, under 80 words

The GitHub Actions workflow runs the tests and the gate on every push and PR, and also runs the regressed recording to prove the gate still bites.

The key code

The gate in src/eval.ts and the CI steps in .github/workflows/eval.yml.

/** The CI gate: fail on a pass-rate drop beyond tolerance, or on any critical miss. */
export function gate(report: Report, baseline: Baseline): { ok: boolean; reasons: string[] } {
  const reasons: string[] = [];
  const floor = baseline.passRate - baseline.tolerance;
  if (report.passRate < floor) reasons.push(`pass rate ${pct(report.passRate)} is below ${pct(floor)} (baseline ${pct(baseline.passRate)} - ${pct(baseline.tolerance)})`);
  for (const f of report.failures.filter((f) => f.critical)) reasons.push(`critical: ${f.id} is urgent but was not flagged urgent`);
  return { ok: reasons.length === 0, reasons };
}

# .github/workflows/eval.yml (steps)
      - run: npm ci
      - run: npm run typecheck
      - run: npm test
      # The gate: exits 1 (and fails the PR) when quality drops. Summary lands on the job page.
      - run: npm run eval
      # Proof the gate bites: the regressed recording must fail it.
      - run: "! npm run eval -- regressed"
src/eval.ts lines 73–80 and the last steps of eval.yml. The ! step fails CI if the regressed recording ever stops failing the gate.

Proof: the real test run

Run on 16 Oct 2026 on the published commit: npm test, then npm run eval -- regressed, which exited with code 1 as designed. Both run offline from the recordings.

✔ baseline recording: 19/20 pass and the gate is green
✔ regressed recording: the gate fails and names every broken case
✔ each grader catches its own kind of failure
✔ a single critical miss fails the gate even when the pass rate is fine
✔ cost is summed from usage on every triage and judge call
ℹ tests 5
ℹ pass 5
ℹ fail 0
npm test: 5 tests, 5 pass, 0 fail.
## Eval: replay of recordings/regressed.json FAIL
14/20 cases passed (70%) on claude-opus-5-5, cost $0.1221
| field | accuracy |
|---|---|
| json | 95% |
| category | 90% |
| priority | 85% |
| order_id | 90% |
| reply | 90% |
Failed cases:
- t04 (critical): priority: got "normal"
- t08: order_id: got "0988"
- t10: reply: Promises a specific refund amount and timing the agent cannot guarantee.
- t12: not JSON: Sure! Here is the triage: category technical
- t14 (critical): priority: got "normal"
- t15: category: got "other"
Gate:
- pass rate 70% is below 90% (baseline 95% - 5%)
- critical: t04 is urgent but was not flagged urgent
- critical: t14 is urgent but was not flagged urgent
npm run eval -- regressed: 14/20, two urgent tickets missed, gate FAIL, exit code 1.

Choices and limits

  • Exact match where the answer is exact, a judge only for free text. Judges cost money and drift; categories and ids don't need one.
  • Critical cases gate on their own, because averages hide the failure that hurts a customer most.
  • 20 cases is a smoke test, not a benchmark; a real project grows the set from production tickets.
  • The judge itself is not evaluated here; a real setup checks it against human-labelled replies first.
  • Cost in replay mode is computed from recorded token counts at Opus 5.5 prices, so it is an estimate, not a bill.