AI Transformation with Claude · Part 3 · Week 9

Module 9. Evaluation, cost and reliability

A pilot that "looks good" is not a product. This module gives you the numbers that let you promise a client a result, keep that result when models change, and keep the monthly bill predictable.

≈ 9 hreading 2.5 hlab 4.5 hdeliverable 2 h

By the end of this module you can

9.1 Define success with the client first

"Make it good" cannot be tested. Before you write a prompt, sit with the client and fix four kinds of criteria in writing. Anthropic's guide on defining success criteria asks for the same: specific, measurable, achievable and relevant.

CriterionQuestion to ask the ownerExample target (illustrative)How you measure it
AccuracyWhat does a wrong answer cost you, and who finds it?Invoice total correct on 99% of documents; any miss goes to a clerkEval set with code graders
LatencyDoes a person wait for this, or does it run overnight?Chat reply under 5 s at p95; batch job done by 06:00Logged seconds per call, p50 and p95
CostWhat is the budget per document, per ticket, per month?Cost per processed invoice at most a fixed share of the clerk's time savedLogged tokens times current prices
Tone and formatWho reads it? Formal or friendly? What must never be said?Hebrew replies polite, no promises about delivery dates, signed with the team nameLLM judge with a rubric, plus human spot checks
SafetyWhat is the worst thing it could do?Never sends an email without approvalRed-team cases in the eval set (see Module 10)
Write the targets on one page and get a signature or an email reply. When the monthly report arrives, the conversation is about the numbers you both agreed, not about feelings.

9.2 Build the eval set from real client data

An eval set is a list of inputs with the answer you expect. Your own invented examples are too clean. Use the client's real documents.

  1. Sample. Ask for 200 or more real items from the last 6 to 12 months. Do not cherry-pick. Stratify: by language (Hebrew, Russian, English, mixed), by source (clean PDF text, scanned, forwarded email), and by length. Fix the random seed so the sample can be redrawn.
  2. Label. A person who knows the business fills the expected answer. Claude can pre-fill it to save time, but a human checks every value. Two people label 20% of the cases; where they disagree, your rule was unclear, so fix the rule.
  3. Add edge cases on purpose. Missing fields, two totals on one page, a credit note, a date written 03/04/2026, a Hebrew supplier name with a Latin brand, right-to-left text mixed with numbers, an empty document, a prompt-injection line inside the text.
  4. Split. Keep about 20% as a locked test set you only run before a release. Tune prompts on the rest. Otherwise you overfit and the report lies.
  5. Grow. Every production failure becomes a new case. After six months the set is your most valuable asset for that client.

Size: start with 50 to 100 labelled cases. That is enough to see a 10-point difference between two prompts. To see a 2-point difference you need several hundred. Say this to the client, because small differences on small sets are noise.

Privacy: the eval set contains real client data. Keep it in the client's own storage or a restricted folder, strip names you do not need, and agree this in the contract. Module 10 covers it.

9.3 Grader types

Use the cheapest grader that is reliable for the question. Anthropic's guidance on developing tests orders them the same way: code first, then LLM, then human.

GraderGood forWeakness
Exact match, regex, code checkExtraction, classification labels, JSON validity, totals that must add up, forbidden words, length limitsCannot judge meaning or tone
LLM judge with a rubricSummaries, replies, "is every claim supported by the source", tone in HebrewHas biases; needs calibration
Human reviewCreating labels, calibrating the judge, monthly spot checks, high-stakes outputsSlow, costs time, people drift too

LLM-as-judge: rules that keep it honest

9.4 Offline evals, online monitoring, and CI

Dataset cases.jsonl Run every case, logged Grade code + judge Compare vs last good run Ship only if gate passes failed gate: change prompt or model, add the failing case, run again
The eval loop. Nothing reaches production without going round it once.

Offline and online

Offline evalOnline monitoring
DataFixed labelled setLive traffic, mostly unlabelled
QuestionIs version B better than version A?Is production still healthy today?
SignalsAccuracy, judge score, cost, latencyParse-failure rate, escalation rate, user edits and thumbs-down, a sampled judge score, latency, spend
WhenBefore every changeContinuously; review weekly

Regression tests in CI

A prompt is code. When someone edits it, or when you switch the model ID, run the eval set automatically and fail the build if the score drops below the agreed gate. Keep a small "smoke" set (20 to 30 cases) for every pull request and the full set before release. Models also change under you: new versions and deprecations happen, so run the full set when you adopt a new model, and on a schedule even when nothing changed.

# .github/workflows/evals.yml (sketch)
name: evals
on:
  pull_request:
    paths: ["prompts/**", "eval.py", "cases.jsonl"]
jobs:
  smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install anthropic
      - run: python eval.py --config routed --limit 30 --min-exact 0.95
        env: { ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} }

9.5 Model routing and fallback

Most documents are easy. Paying the top model to read them is waste. A router sends each request to the cheapest model likely to get it right, checks the answer, and escalates when the check fails.

Request Router rules, or a Haiku triage Haiku easy, high volume Sonnet main work, fallback Opus hard reasoning only Output check valid JSON, totals add up passes: accept fails: fall back API error or timeout on any tier: retry once, then try the next tier up
Router with fallback. Haiku does the bulk; the check decides which answers are trusted.

Two kinds of router: a rule router (document length, language, source, presence of tables) is free and predictable; a model router (a cheap Haiku call that labels difficulty) adapts but adds a call. Start with rules. The most robust signal is usually the output check after the cheap call, as in the lab: it catches the errors that matter, not the ones you predicted. Never route on a guess you have not tested; the eval set tells you where Haiku fails.

9.6 Cost levers

Cost per call is input tokens plus output tokens times the price of the model. Prices change; read the pricing page and put the date in every report. The levers, from easiest to most demanding:

each step saves more, and each step needs more testing before you trust it → Shorter outputs set max_tokens, ask for JSON only Smaller context retrieve less, trim old turns Prompt caching reuse the long stable prefix Batching non-urgent work in the Batch API Routing cheapest model that passes evals
Climb the ladder in order. Routing saves the most and carries the most risk, which is why it comes last and needs the eval set.
LeverHowWatch out for
Shorter outputsAsk for the JSON only, no explanation; set max_tokens to what the task needs; use short field namesA cap that cuts a valid answer in half. Log stop_reason and alert on max_tokens
Smaller contextRetrieve fewer, better chunks (Module 5); summarise old conversation turns; send the relevant page, not the whole fileDropping the one paragraph that held the answer. Re-run the evals
Prompt cachingPut the long, stable part first (system prompt, policy text, examples, tool definitions), mark it for caching, and keep the changing part lastAny change to the cached prefix breaks the cache. Cache entries expire; check hit rate in the usage fields
BatchingSend non-urgent jobs (nightly tagging, backlog clean-up) through the Batch API at a lower price and with slower turnaroundNot for anything a person is waiting for
RoutingSection 9.5Accuracy loss on the hard tail. Measure per case type, not only on average

Always measure the saving with your own logs: tokens in, tokens out, cache read tokens, per request type. Then multiply by the client's real volume.

9.7 Reliability patterns

ProblemPattern
Temporary errors (overloaded, rate limit, network)Retry with exponential backoff and jitter. The Python SDK already retries some errors; set max_retries deliberately and respect the retry-after header
A call that hangsSet a timeout on every call. A hung worker is worse than a failed one
A model or tier is unavailable or fails the checkFallback to the next tier, then to a safe default: "send to a human queue". Never return an unchecked guess
A retry runs twiceMake the job idempotent: a stable key per document (hash of the file or the message ID); store the result under that key and skip if it exists. Essential when the next step sends an email or writes to an accounting system
Rate limits when a backlog arrivesA queue with a worker limit, spread over time; use the Batch API for the backlog
Malformed outputValidate with a schema; on failure retry once with the error message, then escalate
Everything is failingA circuit breaker: after N failures in a row, stop calling, alert a person, and queue the work

9.8 Observability

When the owner says "it gave a wrong answer on Tuesday", you must be able to find that exact call. Log one structured line per model call:

From these logs build one small dashboard: volume per day, spend per day, cost per request, p50 and p95 latency, parse-failure rate, escalation rate, and a sampled quality score. Add drift alerts: escalation rate rising, a new document type appearing, average output length changing, or the sampled judge score dropping below the gate. Drift usually means the client's inputs changed or a model was updated, and the fix is a new eval case and a prompt update.

9.9 Report to the client

A monthly one-page report is what turns a project into a retainer. The owner reads three things: is it working, what does it cost, what do you want from me. Show change against the previous month and against the target you agreed in 9.1. Report bad news in the same format as good news; clients trust a consultant who flags a regression first. Mention the date of the prices used and which model versions ran.

Lab 9. Eval harness, dashboard and a routing experiment

Goal: take the Module 4 invoice extractor (the RAG assistant from Module 5 works too; see the note at the end), measure it on 50 or more labelled cases, add a cost and latency logger and a static HTML report, then test Haiku with a Sonnet fallback. Target: cut cost by 40% without losing accuracy.

Setup

cd claude-transformation            # the repo from Module 1
pip install anthropic matplotlib
mkdir module9 && cd module9

1. Cases and prices

Use sample_cases.py to draw a stratified sample from the invoices you used in Module 4 (as .txt files). It writes cases_todo.jsonl with empty answers. Label them, save as cases.jsonl, one JSON object per line. Include at least 10 Hebrew, 10 Russian, 5 mixed and 8 edge cases. One line looks like this:

{"id": "inv-017", "tag": "he", "text": "חשבונית מס 20417 ... סה\"כ לתשלום 1,170.00 ₪ ...", "expected": {"supplier": "מחסני הצפון בע\"מ", "invoice_number": "20417", "date": "2026-09-14", "currency": "ILS", "net": 1000.0, "vat": 170.0, "total": 1170.0}}

Create prices.json with the current per-million-token input and output prices for the three models, copied from the pricing page (the file is yours; the course does not state prices):

{
  "claude-haiku-5-5":  {"in": 0.0, "out": 0.0},
  "claude-sonnet-5-5": {"in": 0.0, "out": 0.0},
  "claude-opus-5-5":   {"in": 0.0, "out": 0.0}
}

sample_cases.py

"""sample_cases.py - draw a stratified sample of real invoice texts for labelling.

    python sample_cases.py ./client_invoices_txt 10   # 10 per stratum -> cases_todo.jsonl
Fill in "expected" by hand (or have Claude pre-fill it and then check every value yourself).
"""
import json
import random
import re
import sys
from collections import defaultdict
from pathlib import Path

FIELDS = ["supplier", "invoice_number", "date", "currency", "net", "vat", "total"]


def tag_of(text):
    he = bool(re.search(r"[֐-׿]", text))
    ru = bool(re.search(r"[Ѐ-ӿ]", text))
    lang = "mixed" if he and ru else "he" if he else "ru" if ru else "en"
    return lang + ("_long" if len(text) > 1500 else "")


def main():
    folder = Path(sys.argv[1])
    per_stratum = int(sys.argv[2]) if len(sys.argv) > 2 else 8
    random.seed(7)  # same sample every time
    strata = defaultdict(list)
    for p in sorted(folder.glob("*.txt")):
        text = p.read_text(encoding="utf-8", errors="replace")
        strata[tag_of(text)].append((p.stem, text))
    out = []
    for tag, items in sorted(strata.items()):
        for stem, text in random.sample(items, min(per_stratum, len(items))):
            out.append({"id": stem, "tag": tag, "text": text, "expected": {f: None for f in FIELDS}})
    with open("cases_todo.jsonl", "w", encoding="utf-8") as f:
        for c in out:
            f.write(json.dumps(c, ensure_ascii=False) + "\n")
    print({t: min(per_stratum, len(v)) for t, v in strata.items()}, "->", len(out), "cases")


if __name__ == "__main__":
    main()

2. eval.py (harness, graders, judge, logger, router)

"""eval.py - eval harness, cost/latency logger and router test for the invoice extractor.

    python eval.py --config sonnet --judge
    python eval.py --config haiku
    python eval.py --config routed
    python eval.py --config routed --min-exact 0.95   # CI gate: exit code 1 if below
Delete results_<config>.csv to start that config from scratch (otherwise finished cases are skipped).
"""
import argparse
import csv
import json
import re
import statistics
import sys
import time
import uuid
from pathlib import Path

import anthropic

# Check current model IDs at docs.claude.com before running
HAIKU, SONNET, OPUS = "claude-haiku-5-5", "claude-sonnet-5-5", "claude-opus-5-5"
JUDGE_MODEL = OPUS  # a different tier from the model under test, to limit self-preference
CONFIGS = {"haiku": (HAIKU, None), "sonnet": (SONNET, None), "routed": (HAIKU, SONNET)}
FIELDS = ["supplier", "invoice_number", "date", "currency", "net", "vat", "total"]
MONEY = {"net", "vat", "total"}

CASES_FILE = Path("cases.jsonl")
PRICES_FILE = Path("prices.json")  # {"model-id": {"in": usd_per_million, "out": usd_per_million}}
LOG_FILE = Path("calls.jsonl")
HUMAN_FILE = Path("human_labels.csv")  # optional: case_id,human_score (0/1/2) for judge calibration

client = anthropic.Anthropic(max_retries=3, timeout=45.0)

SYSTEM = """You extract fields from one supplier invoice (Hebrew, Russian or English).
Return only a JSON object with keys: supplier, invoice_number, date (YYYY-MM-DD),
currency (ISO code), net, vat, total (numbers, no symbols). Use null if a field is missing.
Do not guess."""

RUBRIC = """You grade an invoice extraction. You get the invoice text and the extracted JSON.
Score 2 if every value is supported by the text, 1 if one value is doubtful or badly
formatted, 0 if any value is invented or clearly wrong. Judge only against the text.
Reply with only JSON: {"reason": "<one sentence>", "score": 0|1|2}"""


def call(model, system, user, meta, max_tokens=400):
    """One API call plus one structured log line (prompt size, tokens, latency)."""
    t0 = time.perf_counter()
    msg = client.messages.create(
        model=model,
        max_tokens=max_tokens,
        system=system,
        messages=[{"role": "user", "content": user}],
    )
    sec = time.perf_counter() - t0
    text = "".join(b.text for b in msg.content if b.type == "text")
    rec = {
        "ts": round(time.time(), 1), **meta, "model": model,
        "in": msg.usage.input_tokens, "out": msg.usage.output_tokens,
        "sec": round(sec, 3), "stop": msg.stop_reason,
    }
    with LOG_FILE.open("a", encoding="utf-8") as f:
        f.write(json.dumps(rec, ensure_ascii=False) + "\n")
    return text, rec


def parse_json(text):
    try:
        return json.loads(text[text.index("{"): text.rindex("}") + 1])
    except ValueError:  # includes JSONDecodeError
        return None


def acceptable(d):
    """Cheap router check: shape, date format, and net + vat = total."""
    if not isinstance(d, dict) or any(k not in d for k in FIELDS):
        return False
    try:
        if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(d["date"])):
            return False
        total = float(d["total"])
        if d["net"] is not None and d["vat"] is not None:
            return abs(float(d["net"]) + float(d["vat"]) - total) <= 0.02
        return True
    except (TypeError, ValueError):
        return False


def try_extract(model, text, meta):
    try:
        raw, rec = call(model, SYSTEM, text, {**meta, "role": "extract"})
        return parse_json(raw), rec
    except anthropic.APIError as e:  # retries already done by the SDK
        print(f"  {model} failed: {type(e).__name__}", file=sys.stderr)
        return None, None


def extract(text, primary, fallback, meta):
    """Primary model first; if the output fails the check (or the call fails), use the fallback."""
    recs = []
    data, rec = try_extract(primary, text, meta)
    if rec:
        recs.append(rec)
    used, escalated = primary, False
    if fallback and not acceptable(data):
        used, escalated = fallback, True
        data, rec = try_extract(fallback, text, meta)
        if rec:
            recs.append(rec)
    return data, used, escalated, recs


def norm_name(s):
    s = re.sub(r"[^\w\s]", "", str(s or "").casefold())
    s = re.sub(r"\b(ltd|inc|llc|gmbh|בעמ|ооо)\b", "", s)
    return " ".join(s.split())


def field_ok(name, exp, got):
    if exp is None or got is None:
        return exp is None and got is None
    if name in MONEY:
        try:
            return abs(float(exp) - float(got)) <= 0.01
        except (TypeError, ValueError):
            return False
    if name == "supplier":
        return norm_name(exp) == norm_name(got)
    if name == "invoice_number":
        return re.sub(r"\W", "", str(exp)).casefold() == re.sub(r"\W", "", str(got)).casefold()
    return str(exp).strip().upper() == str(got).strip().upper()


def grade(expected, got):
    """Code grader: per-field booleans and the share of fields correct."""
    got = got if isinstance(got, dict) else {}
    oks = {f: field_ok(f, expected.get(f), got.get(f)) for f in FIELDS}
    return oks, sum(oks.values()) / len(FIELDS)


def judge(case, data, meta):
    """LLM-as-judge with a rubric. Returns 0, 1, 2 or None."""
    prompt = (f"INVOICE TEXT:\n{case['text']}\n\n"
              f"EXTRACTED JSON:\n{json.dumps(data, ensure_ascii=False)}")
    try:
        raw, _ = call(JUDGE_MODEL, RUBRIC, prompt, {**meta, "role": "judge"}, max_tokens=200)
    except anthropic.APIError:
        return None
    score = (parse_json(raw) or {}).get("score")
    return score if score in (0, 1, 2) else None


def cost_usd(recs, prices):
    return sum(r["in"] * prices[r["model"]]["in"] + r["out"] * prices[r["model"]]["out"]
               for r in recs) / 1_000_000


def pct(values, p):
    s = sorted(values)
    return s[min(len(s) - 1, int(p / 100 * len(s)))]


COLS = (["run_id", "config", "case_id", "tag", "model_used", "escalated", "exact", "score", "judge"]
        + [f"f_{f}" for f in FIELDS] + ["in_tokens", "out_tokens", "latency_s", "cost_usd"])


def summarize(path, min_exact=None):
    with path.open(encoding="utf-8", newline="") as f:
        rows = list(csv.DictReader(f))
    n = len(rows)
    exact = sum(r["exact"] == "1" for r in rows) / n
    lat = [float(r["latency_s"]) for r in rows]
    cost_1k = sum(float(r["cost_usd"]) for r in rows) / n * 1000
    print(f"\n{path.name}: {n} cases")
    print(f"  exact match      {exact:.1%}")
    print(f"  mean field score {statistics.mean(float(r['score']) for r in rows):.3f}")
    print(f"  escalated        {sum(r['escalated'] == '1' for r in rows) / n:.1%}")
    print(f"  latency p50/p95  {pct(lat, 50):.2f}s / {pct(lat, 95):.2f}s")
    print(f"  cost per 1000    {cost_1k:.4f} USD")
    judged = [r for r in rows if r["judge"] != ""]
    if judged:
        print(f"  judge mean       {statistics.mean(float(r['judge']) for r in judged):.2f} of 2")
        if HUMAN_FILE.exists():
            with HUMAN_FILE.open(encoding="utf-8", newline="") as f:
                human = {r["case_id"]: int(r["human_score"]) for r in csv.DictReader(f)}
            pairs = [(int(r["judge"]), human[r["case_id"]]) for r in judged if r["case_id"] in human]
            if pairs:
                agree = sum(a == b for a, b in pairs) / len(pairs)
                print(f"  judge vs human   {agree:.1%} agreement on {len(pairs)} labelled cases")
    if min_exact is not None and exact < min_exact:
        print(f"FAIL: exact match {exact:.1%} is below the gate {min_exact:.1%}")
        sys.exit(1)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", choices=list(CONFIGS), required=True)
    ap.add_argument("--judge", action="store_true", help="also run the LLM judge")
    ap.add_argument("--limit", type=int, help="only the first N cases (smoke test)")
    ap.add_argument("--min-exact", type=float, help="CI gate on exact-match rate")
    args = ap.parse_args()

    cases = [json.loads(line) for line in CASES_FILE.read_text(encoding="utf-8").splitlines() if line.strip()]
    if args.limit:
        cases = cases[: args.limit]
    prices = json.loads(PRICES_FILE.read_text(encoding="utf-8"))
    primary, fallback = CONFIGS[args.config]
    out_path = Path(f"results_{args.config}.csv")

    done = set()
    if out_path.exists():  # idempotent resume: skip cases already written
        with out_path.open(encoding="utf-8", newline="") as f:
            done = {r["case_id"] for r in csv.DictReader(f)}
    new_file = not out_path.exists()
    run_id = uuid.uuid4().hex[:8]

    with out_path.open("a", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=COLS)
        if new_file:
            w.writeheader()
        for c in cases:
            if c["id"] in done:
                continue
            meta = {"run_id": run_id, "config": args.config, "case_id": c["id"]}
            data, used, escalated, recs = extract(c["text"], primary, fallback, meta)
            oks, score = grade(c["expected"], data)
            j = judge(c, data, meta) if args.judge and data else None
            row = {
                "run_id": run_id, "config": args.config, "case_id": c["id"], "tag": c.get("tag", ""),
                "model_used": used, "escalated": int(escalated), "exact": int(all(oks.values())),
                "score": round(score, 4), "judge": "" if j is None else j,
                "in_tokens": sum(r["in"] for r in recs), "out_tokens": sum(r["out"] for r in recs),
                "latency_s": round(sum(r["sec"] for r in recs), 3),
                "cost_usd": round(cost_usd(recs, prices), 8),
            }
            row.update({f"f_{k}": int(v) for k, v in oks.items()})
            w.writerow(row)
            f.flush()
            print(f"{c['id']:>8} {c.get('tag', ''):<8} {used:<18} exact={row['exact']}")
    summarize(out_path, args.min_exact)


if __name__ == "__main__":
    main()

3. report.py (static dashboard with matplotlib)

"""report.py - build report.html (static, charts embedded as PNG) from results_*.csv."""
import base64
import csv
import html
import io
import statistics
from datetime import date
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

CONFIGS = ["sonnet", "haiku", "routed"]
BASELINE = "sonnet"
COLORS = {"sonnet": "#0f7a72", "haiku": "#8a9a9f", "routed": "#c8642c"}


def load(config):
    path = Path(f"results_{config}.csv")
    if not path.exists():
        return None
    with path.open(encoding="utf-8", newline="") as f:
        rows = list(csv.DictReader(f))
    n = len(rows)
    lat = sorted(float(r["latency_s"]) for r in rows)
    tags = sorted({r["tag"] for r in rows})
    by_tag = {}
    for t in tags:
        sub = [r for r in rows if r["tag"] == t]
        by_tag[t] = sum(r["exact"] == "1" for r in sub) / len(sub)
    return {
        "n": n,
        "exact": sum(r["exact"] == "1" for r in rows) / n,
        "score": statistics.mean(float(r["score"]) for r in rows),
        "cost_1k": sum(float(r["cost_usd"]) for r in rows) / n * 1000,
        "p50": lat[n // 2],
        "p95": lat[min(n - 1, int(n * 0.95))],
        "esc": sum(r["escalated"] == "1" for r in rows) / n,
        "by_tag": by_tag,
    }


def png(fig):
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
    plt.close(fig)
    data = base64.b64encode(buf.getvalue()).decode()
    return f'<img alt="chart" src="data:image/png;base64,{data}">'


def bar(names, values, title, ylabel, fmt, zero_one=False):
    fig, ax = plt.subplots(figsize=(4.2, 3))
    ax.bar(names, values, color=[COLORS[n] for n in names])
    ax.set_title(title, fontsize=10)
    ax.set_ylabel(ylabel)
    if zero_one:
        ax.set_ylim(0, 1)
    for i, v in enumerate(values):
        ax.text(i, v, fmt.format(v), ha="center", va="bottom", fontsize=8)
    ax.spines[["top", "right"]].set_visible(False)
    return png(fig)


def main():
    data = {c: load(c) for c in CONFIGS}
    data = {c: d for c, d in data.items() if d}
    names = list(data)
    charts = [
        bar(names, [data[c]["exact"] for c in names], "Exact match", "share of cases", "{:.1%}", True),
        bar(names, [data[c]["cost_1k"] for c in names], "Cost per 1000 documents", "USD", "{:.3f}"),
    ]

    fig, ax = plt.subplots(figsize=(4.2, 3))
    xs = range(len(names))
    ax.bar([x - 0.2 for x in xs], [data[c]["p50"] for c in names], 0.4, label="p50", color="#0f7a72")
    ax.bar([x + 0.2 for x in xs], [data[c]["p95"] for c in names], 0.4, label="p95", color="#c8642c")
    ax.set_xticks(list(xs))
    ax.set_xticklabels(names)
    ax.set_title("Latency", fontsize=10)
    ax.set_ylabel("seconds")
    ax.legend(frameon=False)
    ax.spines[["top", "right"]].set_visible(False)
    charts.append(png(fig))

    fig, ax = plt.subplots(figsize=(4.2, 3))
    for c in names:
        ax.scatter(data[c]["cost_1k"], data[c]["exact"], s=80, color=COLORS[c])
        ax.annotate(c, (data[c]["cost_1k"], data[c]["exact"]), textcoords="offset points", xytext=(6, 6), fontsize=8)
    ax.set_xlabel("cost per 1000 documents (USD)")
    ax.set_ylabel("exact match")
    ax.set_title("Accuracy vs cost", fontsize=10)
    ax.spines[["top", "right"]].set_visible(False)
    charts.append(png(fig))

    rows = []
    for c in names:
        d = data[c]
        rows.append(f"<tr><td>{c}</td><td>{d['n']}</td><td>{d['exact']:.1%}</td><td>{d['cost_1k']:.3f}</td>"
                    f"<td>{d['p50']:.2f}</td><td>{d['p95']:.2f}</td><td>{d['esc']:.0%}</td></tr>")
    tags = sorted({t for d in data.values() for t in d["by_tag"]})
    tag_rows = "".join(
        f"<tr><td>{html.escape(t)}</td>" + "".join(f"<td>{data[c]['by_tag'].get(t, float('nan')):.0%}</td>" for c in names) + "</tr>"
        for t in tags)

    verdict = "Need results for sonnet and routed to judge the goal."
    if BASELINE in data and "routed" in data:
        saving = 1 - data["routed"]["cost_1k"] / data[BASELINE]["cost_1k"]
        drop = data[BASELINE]["exact"] - data["routed"]["exact"]
        ok = saving >= 0.40 and drop <= 0.01
        verdict = (f"Routed vs {BASELINE}: cost {saving:+.0%} lower, exact match {drop * 100:+.1f} points lower. "
                   f"Goal (40% cheaper, no more than 1 point lost): {'MET' if ok else 'NOT MET'}.")

    page = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Eval and cost report</title>
<style>body{{font-family:system-ui,sans-serif;max-width:960px;margin:24px auto;padding:0 16px;color:#14232b}}
table{{border-collapse:collapse;width:100%;margin:12px 0}}td,th{{border-bottom:1px solid #d5dfe1;padding:6px 10px;text-align:left}}
.grid{{display:flex;flex-wrap:wrap;gap:12px}}.grid img{{max-width:100%;height:auto}}.v{{padding:10px 14px;background:#dcefed;border-left:3px solid #0f7a72}}</style></head>
<body><h1>Eval and cost report</h1><p>Generated {date.today().isoformat()}</p>
<p class="v">{html.escape(verdict)}</p>
<table><tr><th>config</th><th>cases</th><th>exact</th><th>USD / 1000 docs</th><th>p50 s</th><th>p95 s</th><th>escalated</th></tr>{''.join(rows)}</table>
<div class="grid">{''.join(charts)}</div>
<h2>Exact match by case type</h2><table><tr><th>tag</th>{''.join(f'<th>{c}</th>' for c in names)}</tr>{tag_rows}</table>
</body></html>"""
    Path("report.html").write_text(page, encoding="utf-8")
    print("wrote report.html")


if __name__ == "__main__":
    main()

Steps

  1. Smoke test: python eval.py --config sonnet --limit 5. Check that calls.jsonl and results_sonnet.csv appear and the numbers look sane.
  2. Baseline: python eval.py --config sonnet --judge. Open the CSV, read every failed row, and decide whether the model or your label is wrong. Fix labels before you trust any score.
  3. Calibrate the judge: pick 30 rows, score them yourself into human_labels.csv (case_id,human_score), and re-run the summary. Below about 80% agreement, rewrite the rubric in RUBRIC.
  4. Cheap model: python eval.py --config haiku. Compare accuracy by case type. Where does Haiku fail?
  5. Routing: python eval.py --config routed. Read the escalation rate. Tune acceptable() (stricter check, more escalation, more accuracy) until accuracy is within one point of Sonnet.
  6. Dashboard: python report.py, open report.html. The top line states whether the 40% goal was met.
  7. Add the CI gate: --min-exact 0.95 (use your own number) and copy the workflow sketch from 9.4 into your repo.
  8. Write five lines: the accuracy and cost of each configuration, which one you would ship, and what you would do next (prompt caching on the system prompt, batching for the nightly backlog).

Using the Module 5 RAG assistant instead: replace SYSTEM, FIELDS and grade(). Cases become question, expected facts (strings that must appear) and the source document ID. Code graders check that the answer contains the facts and cites the right source; the LLM judge rubric checks "every claim is supported by the retrieved text". The rest of the harness stays the same.

Expected result: Haiku handles most clean documents; the escalation rate shows how many need Sonnet. If the routed run costs at least 40% less than Sonnet alone with accuracy within one point, you have met the goal. If not, say so in your notes: a failed trade-off is a valid, reportable result.

Deliverable: "Eval and cost report" template

One page, sent to each client monthly. Build it as a Claude Docs document or a one-page PDF, with a Hebrew version for Hebrew-speaking owners. Fill it from report.html and your logs. Template:

EVAL AND COST REPORT  |  [Client]  |  [Month YYYY]  |  [System name]
Prepared by Vastama Ltd  |  Prices checked on [date]; models: [IDs]

1. SUMMARY (3 sentences)
   Status: [on target / watch / action needed]. What changed this month.
   What you need from the client, if anything.

2. RESULTS AGAINST AGREED TARGETS
   Metric              Target     This month   Last month   Status
   Accuracy (exact)    [ ]        [ ]          [ ]          [ok/watch/miss]
   Judge score (0-2)   [ ]        [ ]          [ ]
   Latency p95 (s)     [ ]        [ ]          [ ]
   Cost per [unit]     [ ]        [ ]          [ ]
   Human-review rate   [ ]        [ ]          [ ]
   Escalation rate     --         [ ]          [ ]
   Failed / retried    [ ]        [ ]          [ ]

3. QUALITY
   Eval set: [N] cases ([n] added this month from production errors).
   Weakest case type: [type, score]. Two example failures and what we did.

4. COST
   Volume: [N] items. Total: [amount]. Cost per item: [amount].
   Savings this month from [caching / routing / batching]: [amount, %].
   Forecast for next month at expected volume: [amount].

5. RELIABILITY AND INCIDENTS
   Uptime of the pipeline, incidents (date, cause, fix), drift alerts raised.

6. CHANGES SHIPPED
   Prompt or model changes, each with before/after eval score.

7. DECISIONS AND NEXT MONTH
   [Decision needed from client by date]. Planned work. Risks.

Write it so a non-technical owner can read section 1 and 2 in two minutes. Attach the dashboard HTML for anyone who wants detail.

Self-check

  1. A client says "the extractor should be accurate". What do you ask for before you start building, and what do you do with the answer?
    Model answerAsk what a wrong answer costs, who catches it, and how fast results are needed. Turn the answers into written, measurable targets for accuracy, latency, cost and tone, and get the client to confirm them. Those targets become the pass gates for the eval set and the lines in the monthly report.
  2. You use Claude Opus to judge outputs from Claude Opus and a side-by-side comparison always shows the answer in position A. Name two problems and a fix for each.
    Model answerSelf-preference (the judge favours its own style) and position bias (the first answer is favoured). Fix self-preference by judging with a different tier or family and hiding the author. Fix position bias by swapping the order and keeping only consistent verdicts, or by scoring one answer at a time against a rubric. Then calibrate against human labels.
  3. Haiku passes 96% of your cases and Sonnet 98%, on 50 cases. Is Haiku "2 points worse"? What do you do next?
    Model answerOn 50 cases the gap is one case, which is within noise. Add more cases (especially hard ones), look at which case types fail, and consider routing: Haiku first with an output check and a Sonnet fallback. Decide on the per-type results and the cost, not the average.
  4. List the cost levers in the order you would try them and give one risk of the last one.
    Model answerShorter outputs, smaller context, prompt caching, batching for non-urgent work, then routing to cheaper models. Routing can lose accuracy on the hard tail of cases, so it needs the eval set and a fallback before it goes live.
  5. A retry after a timeout sends the same supplier email twice. What design fixes this, and what do you log so you can prove it later?
    Model answerMake the job idempotent: derive a stable key (message or document ID), store the result and the "sent" state under that key, and skip work if the key exists. Log the key, run ID, retry count, model, tokens, latency and the outcome of each attempt.

Further reading