Hands-on · ~30 min

Evaluation Harness

A small gold set, a CLI that scores extraction against it, a Markdown report. Run before every prompt or model change.

What we're building

  • A directory data/gold/ with paired PDF + JSON ground-truth.
  • A scoring function that compares extraction output to gold values.
  • A CLI that runs the gold set through extraction and emits a Markdown report.

This is a learning-grade harness; real production eval needs more rigor (chapter 08 of the Doc AI deep dive). But the shape is the same.

Gold set

For each PDF in the gold set, an adjacent .gold.json file with the right answers.

data/gold/acme_msa_2024.gold.json
{
  "document_id": "doc_demo_a3b4c5d6e7f8",
  "doc_class": "master_service_agreement",
  "fields": {
    "parties": ["Acme Corp", "Customer Inc"],
    "effective_date": "2024-03-15",
    "term_length_months": 12,
    "renewal_terms": {
      "auto_renew": true,
      "term_length_months": 12,
      "notice_period_days": 60
    },
    "governing_law": "Delaware"
  }
}

Place 5–10 PDFs in data/gold/ with matching JSON. Construct the JSON by hand based on the source documents — that's the labeling step.

Scoring

Field-level match with tolerances per type.

src/ci/eval/scoring.py
from __future__ import annotations
from datetime import date
from typing import Any


def normalize_str(v: Any) -> str:
    return str(v).strip().lower() if v else ""


def match_string(extracted: Any, gold: Any) -> bool:
    return normalize_str(extracted) == normalize_str(gold)


def match_date(extracted: Any, gold: Any, tolerance_days: int = 1) -> bool:
    try:
        if not extracted or not gold: return False
        e = date.fromisoformat(extracted)
        g = date.fromisoformat(gold)
        return abs((e - g).days) <= tolerance_days
    except (ValueError, TypeError):
        return False


def match_int(extracted: Any, gold: Any, tolerance: int = 0) -> bool:
    try:
        return abs(int(extracted) - int(gold)) <= tolerance
    except (ValueError, TypeError):
        return False


def match_renewal_terms(extracted: dict | None, gold: dict | None) -> bool:
    if extracted is None and gold is None: return True
    if extracted is None or gold is None: return False
    return (extracted.get("auto_renew") == gold.get("auto_renew")
            and match_int(extracted.get("term_length_months"), gold.get("term_length_months"))
            and match_int(extracted.get("notice_period_days"), gold.get("notice_period_days"), tolerance=5))


def match_parties(extracted: list, gold: list) -> bool:
    if not extracted or not gold: return False
    e = sorted(normalize_str(x) for x in extracted)
    g = sorted(normalize_str(x) for x in gold)
    return e == g


def score_extraction(extracted: dict, gold: dict) -> dict:
    """Return per-field correctness + overall accuracy."""
    results = {
        "parties": match_parties(extracted.get("parties", []), gold.get("parties", [])),
        "effective_date": match_date(
            extracted.get("effective_date", {}).get("value"),
            gold.get("effective_date"),
        ),
        "term_length_months": match_int(
            extracted.get("term_length_months", {}).get("value"),
            gold.get("term_length_months"),
        ),
        "renewal_terms": match_renewal_terms(
            extracted.get("renewal_terms", {}).get("value"),
            gold.get("renewal_terms"),
        ),
        "governing_law": match_string(
            extracted.get("governing_law", {}).get("value"),
            gold.get("governing_law"),
        ),
    }
    total = len(results)
    correct = sum(results.values())
    return {
        "per_field": results,
        "correct": correct,
        "total": total,
        "accuracy": correct / total,
    }

Eval runner

Walk the gold directory, run extraction on each PDF, score, accumulate.

src/ci/eval/runner.py
from __future__ import annotations
import json
from pathlib import Path
from collections import defaultdict

from ci.config import settings
from ci.extraction.ocr import extract_text
from ci.extraction.llm import extract_msa
from ci.eval.scoring import score_extraction


def run_eval(gold_dir: Path) -> dict:
    """Run extraction over the gold set; return aggregate report."""
    pdfs = sorted(gold_dir.glob("*.pdf"))
    if not pdfs:
        raise RuntimeError(f"No PDFs in {gold_dir}")

    per_file = []
    field_correct: dict[str, int] = defaultdict(int)
    field_total: dict[str, int] = defaultdict(int)

    for pdf in pdfs:
        gold_path = pdf.with_suffix(".gold.json")
        if not gold_path.exists():
            print(f"Skip {pdf.name}: no gold file")
            continue
        gold = json.loads(gold_path.read_text())

        file_bytes = pdf.read_bytes()
        text, _ = extract_text(file_bytes)
        extraction = extract_msa(text)
        scored = score_extraction(extraction.model_dump(mode="json"), gold["fields"])

        per_file.append({"file": pdf.name, **scored})
        for field, was_correct in scored["per_field"].items():
            field_total[field] += 1
            if was_correct: field_correct[field] += 1

    overall_correct = sum(f["correct"] for f in per_file)
    overall_total = sum(f["total"] for f in per_file)
    return {
        "model": settings.extraction_model,
        "n_documents": len(per_file),
        "overall_accuracy": overall_correct / overall_total if overall_total else 0.0,
        "per_field_accuracy": {
            f: field_correct[f] / field_total[f] for f in field_total
        },
        "per_file": per_file,
    }

Markdown report

Render the eval results as a copy-pasteable Markdown report.

src/ci/eval/report.py
def render_report(eval_result: dict) -> str:
    lines = []
    lines.append(f"# Extraction Eval Report")
    lines.append(f"")
    lines.append(f"- **Model**: {eval_result['model']}")
    lines.append(f"- **Documents**: {eval_result['n_documents']}")
    lines.append(f"- **Overall accuracy**: {eval_result['overall_accuracy']:.1%}")
    lines.append(f"")
    lines.append(f"## Per-field accuracy")
    for f, acc in sorted(eval_result['per_field_accuracy'].items()):
        marker = "✅" if acc >= 0.9 else "⚠️" if acc >= 0.7 else "❌"
        lines.append(f"- {marker} **{f}**: {acc:.1%}")
    lines.append(f"")
    lines.append(f"## Per-document results")
    lines.append(f"| File | Correct/Total | Accuracy |")
    lines.append(f"|------|--------------|----------|")
    for f in eval_result['per_file']:
        lines.append(f"| {f['file']} | {f['correct']}/{f['total']} | {f['accuracy']:.0%} |")
    return "\n".join(lines)

Wire to the CLI:

src/ci/cli.py — eval command
from pathlib import Path
from ci.eval.runner import run_eval
from ci.eval.report import render_report


@app.command("eval")
def eval_(gold_dir: Path = Path(settings.gold_dir)) -> None:
    """Run eval against the gold set."""
    console.print(f"Running eval against {gold_dir}...")
    result = run_eval(gold_dir)
    report = render_report(result)
    console.print(report)
    Path("eval_report.md").write_text(report)
    console.print(f"\nReport saved to eval_report.md")

(The function is named eval_ because eval is a Python builtin. Typer would turn that name into eval-, so the decorator sets the command name to eval explicitly.)

Verify

ci eval

You should see something like:

# Extraction Eval Report

- **Model**: claude-sonnet-5-5
- **Documents**: 7
- **Overall accuracy**: 88.6%

## Per-field accuracy
- ✅ **effective_date**: 100.0%
- ⚠️ **governing_law**: 85.7%
- ✅ **parties**: 100.0%
- ⚠️ **renewal_terms**: 71.4%
- ⚠️ **term_length_months**: 85.7%

## Per-document results
| File | Correct/Total | Accuracy |
|------|--------------|----------|
| acme_msa_2024.pdf | 5/5 | 100% |
| ...

The pattern: run eval, see where accuracy dips, adjust the prompt or schema, re-run. The harness is the scoreboard.

Use the harness

  • Before changing the prompt — record baseline accuracy.
  • After every prompt change — compare to baseline.
  • When upgrading model — run with old and new, compare.
  • In CI — fail the build if accuracy regresses below a threshold.

✓ Check yourself

Answer from memory before opening each one.

  1. A run over 7 documents reports per-field accuracy of 100%, 100%, 71.4%, 85.7% and 85.7%. What overall accuracy should the report print, and what does a different number mean?

    Show answer

    88.6%, which is 31 of 35 field checks. Every document has the same five fields, so overall accuracy is the mean of the per-field values. A different number means the report mixes in skipped documents or counts fields differently, so read the runner before you trust it. See Markdown report.

  2. You tune the prompt until all 7 gold documents score 100%. What does that number tell you, and what do you do next?

    Show answer

    It tells you the prompt fits those 7 files, not that it generalises. You have also read the answers while tuning. Keep a second set you never tune against, and note that one document moves a field's accuracy by 14 points. See Gold set.

  3. The date matcher accepts a one-day error and the notice-period matcher accepts five days. Why is that a risk, and who decides the tolerance?

    Show answer

    A date that is off by one day is wrong, and it can hide time-zone or off-by-one slips in the prompt. A notice period off by five days can miss a contractual deadline. Tolerances encode business harm, so the people who act on the field should set them, and the report should show exact-match rates too. See Scoring.

  4. A gold document states no governing law, so its gold value is null, and the model correctly returns null. Which of the string, date and integer matchers score that as correct?

    Show answer

    Only the string matcher, because both sides normalise to an empty string. The date matcher returns false when either side is empty, and the integer matcher fails on int(None) and returns false. A correct abstention is scored as an error on dates and numbers. Make null-equals-null explicit in every matcher. See Scoring.

  5. A new prompt moves overall accuracy from 88.6% to 91.4%. A colleague wants to ship it. What do you ask for first?

    Show answer

    That gain is one more correct field out of 35, within the run-to-run noise of a model call. Ask which fields and documents changed, repeat both prompts several times, and compare document by document. Ship only if the gains repeat and no field got worse. See Verify.

Takeaway

  • The harness is a scoreboard: it makes a prompt or model change comparable to the last one, and it only works if you run it before every change.
  • Gold labels are written by hand from the source documents, and that labelling effort is the real cost of an eval.
  • With about 7 documents, one file moves a field by 14 points, so small differences between runs are noise until they repeat.
  • A score tuned against the same set it reports on is optimistic, so keep a set you do not tune against.
  • Match tolerances are policy: loosening a date or a notice period hides errors that cost money downstream.
  • A matcher must treat a correct null as a correct answer, or the score punishes the model for not guessing.

You can now measure the pipeline, so the next step exposes its output to other systems. → API Exposure

Further reading