Document Intelligence Agent
Turns unstructured documents — contracts, invoices, forms, resumes — into validated structured data, with every field traceable to where it came from and low-confidence fields flagged for a human. One of the highest-ROI agents in business: it replaces hours of manual data entry, and it's mostly Chapter 2 (structured output) done rigorously.
What this project teaches you to design
- Schema-driven extraction that cannot return malformed data.
- A confidence + human-in-the-loop review model for the fields that matter.
- Handling PDFs, scans, and multi-page documents.
- Extraction-specific evals: field accuracy, and never-silently-wrong.
The brief advanced
"Stop paying people to retype documents into our systems." Finance keys invoices into the ERP; legal pulls terms out of contracts; ops processes forms. It's slow, expensive, and error-prone. An agent that reads the document and emits clean, validated fields — flagging anything it's unsure about — turns hours into seconds while keeping a human on the risky bits.
1 · Discovery — where does the time go? advanced
| Where time goes | Agent leverage |
|---|---|
| Reading a doc and typing fields into a system | ⭐⭐⭐ high — the core value |
| Handling many layouts/vendors/formats | ⭐⭐⭐ high — the model generalizes where rules-based OCR breaks |
| Catching totals that don't add up / missing fields | ⭐⭐ medium — validation rules |
| Judgment calls on ambiguous / low-quality scans | ⭐ low — flag for human review |
2 · Architecture advanced
This is the whole document-intelligence pipeline in one row, read left to right. It shows how a raw document becomes clean data — and, crucially, where the agent stops and hands off to a human.
- Document (PDF / scan) — the raw input on the far left. It could be a native-text PDF, a scanned image, or a photo.
- Extract text (PDF / OCR / vision) — first you turn that document into something the model can read: pull the text straight out of a PDF, run OCR on a scan, or let a vision model read the image directly.
- LLM extract → schema (the purple, highlighted box) — the one model call. It reads the text and fills in your
Invoiceschema, so the reply comes back as structured fields, not a paragraph. - validate (schema + rules) (the amber box) — plain code checks the result: does it match the schema, and do the totals add up? No model needed here.
- The two boxes on the right are the fork: a high-confidence, rule-passing result flows straight into your system (green); a low-confidence or rule-failing one is routed to a human review queue (red). The arrows splitting to those two boxes are the safety valve of the whole design.
In short: follow the single path left-to-right, then notice it splits in two at the end. "Confident and correct" goes to the system automatically; anything uncertain goes to a person. That branch is the entire point of the project.
Often not an agent loop at all — frequently a single well-structured call per document (Tier 1). Text comes out of the PDF (native text, OCR, or vision for scans), the LLM extracts into a Pydantic schema, code validates, and confidence routes each result to auto-ingest or a human review queue.
3 · Risk & safety model advanced
| Risk | Control |
|---|---|
| 🔴 A silently-wrong field entered into a system | Per-field confidence + validation rules; anything below threshold or failing a rule → review queue, never auto-committed |
| 🟠 Hallucinated values (a number that isn't in the doc) | Require a source span / provenance for each field where possible; cross-check totals; reject values not found in the text |
| 🟠 Malformed output breaking downstream | Schema-constrained output — it can't return the wrong shape (Ch 2 / Pydantic) |
| 🔴 Sensitive PII in documents | Handle under your data-retention policy; redact where required; access controls on the review UI (Ch 6) |
4 · The extraction schema — the heart of the project advanced
The schema is the spec. Define exactly the fields you need, with types and constraints, and the model must fill it (or you validate and reject).
invoice schema (Pydantic)from pydantic import BaseModel, Field
from typing import Literal, Optional
class LineItem(BaseModel):
description: str
quantity: float
unit_price: float
amount: float
class Invoice(BaseModel):
invoice_number: str
vendor: str
date: str # ISO 8601
currency: Literal["USD","EUR","GBP"]
line_items: list[LineItem]
subtotal: float
tax: float
total: float
confidence: float = Field(ge=0, le=1)
needs_review: bool # the escalation flag
This is the schema — the exact shape of the data you want back from the model. In this project the schema is the specification: you describe every field once, and the model is forced to fill exactly that structure (or you reject it). This is Pydantic, the same tool from Python P4.
class LineItem(BaseModel)describes one row of an invoice — a description, a quantity, a unit price and an amount. Each field has a type (str,float) so a wrong type is rejected automatically.class Invoice(BaseModel)is the whole document.line_items: list[LineItem]means "a list of those rows" — a nested model inside a model.currency: Literal["USD","EUR","GBP"]only allows those three exact strings — anything else fails validation. That's how you stop garbage values at the door.confidence: float = Field(ge=0, le=1)forces the number between 0 and 1 (ge= greater-or-equal,le= less-or-equal).needs_review: boolis the escalation flag — the switch that later sends a document to a human instead of straight into the system.
What the output means: Nothing runs yet — this is a definition. But once it exists, any data that doesn't match (wrong currency, missing field, confidence of 2.0) is rejected before it can reach your database.
Try this: Imagine adding a due_date field. You'd write due_date: str here, and from then on every extracted invoice would be required to include it.
subtotal + tax == total and flag needs_review=True if it doesn't. The type system guarantees shape; the validator guarantees arithmetic sanity — a wrong total that doesn't add up gets caught automatically.messages.parse() pattern applied to documents, plus the nested models + validators from Python P4. If you did Ch 2, you're 70% there.5 · Tool surface (often none — or a few) advanced
| Capability | Does | Risk |
|---|---|---|
| PDF text / document input | Native PDF text or image input to the model | 🟢 read-only |
| OCR (for scans) | Turn a scanned image into text | 🟢 read-only |
validate | Schema + business-rule checks (code) | 🟢 deterministic |
lookup_vendor (optional) | Match extracted vendor to a master list | 🟢 read-only |
submit_to_system | Write the record into the ERP/DB | 🟠 gated — only high-confidence, validated |
Many document projects need no agent loop — a single structured call per document is enough (and cheaper, faster, more testable). Add a loop only if the doc requires multi-step reasoning (e.g. cross-referencing pages or looking things up).
6 · Evaluation expert
| Eval | Measures |
|---|---|
| Field-level accuracy | Per field, exact-match vs a labeled golden set (deterministic — the best kind) |
| Schema validity | 100% of outputs parse into the schema (Ch 2) |
| Business-rule pass | Totals reconcile; required fields present |
| Abstention correctness | When it flags needs_review, was it actually uncertain/wrong? And does it flag the ones it got wrong? (the safety metric) |
| Straight-through rate | % auto-processed without review (the business metric) |
7 · Phased rollout expert
Skills & course map expert
| Skill | Learn it in |
|---|---|
| Schema-constrained extraction | Ch 2 |
| Nested Pydantic models + validators | Python P4 |
| PDF / document / image input | Ch 1 + provider PDF/vision features |
| Confidence routing / review queue | Ch 2 classifier pattern |
| Deterministic field-accuracy evals | Ch 5 |
| PII handling, retention, deploy | Ch 6 |
Build-along plan expert
- Define the schema (Ch 2 + P4): the exact fields you need, with types,
confidence, andneeds_review. - Extract from a text doc: feed a plain-text invoice, get a validated object out with
messages.parse(). - Add PDF/scan input: handle a real PDF (native text, then a scanned image via vision/OCR).
- Add a validator (P4): totals must reconcile; failure sets
needs_review=True. - Evals (Ch 5): label ~30 docs; measure per-field accuracy and abstention correctness; tune the confidence threshold.
- Harden (Ch 6): PII policy, the review-queue UI, and a gated
submit_to_systemfor the confident ones.
Learning objectives
- Model a nested extraction schema with a business-rule validator.
- Route by confidence + validation to auto-ingest or a review queue.
- Extend from text to real PDFs/scans without changing the schema.
- Write deterministic field-accuracy + abstention evals.
What you'll build expert
An extractor that turns an invoice into a validated Invoice object, catches arithmetic errors automatically, and sends anything uncertain to a human — never silently entering a wrong number. Often no agent loop at all: one structured call per document.
Step 1 · The schema + validator (the heart of it) expert
Requires: pip install pydantic
agent/schemas.pyfrom pydantic import BaseModel, Field, model_validator
class Invoice(BaseModel):
invoice_number: str
vendor: str
date: str
currency: Literal["USD", "EUR", "GBP"]
line_items: list[LineItem] = Field(default_factory=list)
subtotal: float
tax: float
total: float
confidence: float = Field(ge=0, le=1)
needs_review: bool = False
@model_validator(mode="after")
def check_totals(self):
if abs((self.subtotal + self.tax) - self.total) > 0.01:
self.needs_review = True # flag, don't reject — a human checks
return self
This is the buildable version of the schema, now with a validator — a small piece of code that runs automatically after the fields are filled in, to check they make arithmetic sense. Together the schema and validator are your safety net, and neither one needs a model call.
- The fields are the same idea as before.
line_items: list[LineItem] = Field(default_factory=list)means "if no line items are found, start with an empty list" instead of crashing.needs_review: bool = Falsedefaults to "don't escalate" unless something trips the flag. @model_validator(mode="after")is a decorator that says "run this function after all the fields are set." The functioncheck_totalsthen gets the finished object asself.if abs((self.subtotal + self.tax) - self.total) > 0.01:checks whether subtotal plus tax actually equals the total, allowing a 1-cent rounding wobble.abs(...)makes the difference positive so the comparison works either way.- If the totals don't reconcile, it sets
self.needs_review = True— it flags the document rather than throwing it away, because a human should look at it. Thenreturn selfhands the (possibly flagged) object back.
What the output means: A valid, reconciling invoice comes out with needs_review=False; one whose numbers don't add up comes out with needs_review=True — automatically, with no AI involved.
Try this: Picture an invoice where subtotal=70, tax=7, total=90. Since 70+7=77, not 90, the difference is 13 > 0.01, so needs_review flips to True. That's the arithmetic guard catching a bad document.
Literal is rejected). The validator guarantees arithmetic sanity — a total that doesn't add up is auto-flagged. Neither needs a model call, so both are testable for free. This is Python P4 validators doing real work.Step 2 · Sample documents expert
samples/invoice_good.txt reconciles (100 + 10 = 110). samples/invoice_bad.txt does not (70 + 7 ≠ 90) — the agent must flag it for review, never auto-ingest it.
Step 3 · Extract + route expert
Uses the client / objects set up earlier in this lesson, and needs ANTHROPIC_API_KEY set. Run the earlier blocks first.
agent/engine.pydef extract(text):
inv = client.messages.parse(model=MODEL, max_tokens=800, system=SYSTEM,
output_format=Invoice,
messages=[{"role":"user","content":f"Invoice document:\n{text}"}]).parsed_output
if inv.confidence < CONFIDENCE_THRESHOLD:
inv.needs_review = True # low confidence also -> review
return inv
def route(inv):
return "REVIEW QUEUE" if inv.needs_review else "AUTO-INGEST"
terminalpython agent/engine.py # needs key
invoice_good.txt: INV-2026-0455 total=110.0 conf=0.96 -> AUTO-INGEST
invoice_bad.txt: INV-777 total=90.0 conf=0.74 -> REVIEW QUEUE
(flagged: subtotal 70.0 + tax 7.0 != total 90.0, or low confidence)
Now the two functions that actually do the work: extract asks the model to fill in the schema from a document's text, and route decides where the result goes. This is the single structured model call the whole project is built around.
client.messages.parse(...)is the key line:parse(not the plaincreate) makes the model return data that matches your schema.output_format=Invoicetells it which shape to use, and.parsed_outputhands you back a realInvoicePython object.- The
messageslist contains one user turn: an f-string that pastes the document's text after the label"Invoice document:"so the model knows what it's reading. if inv.confidence < CONFIDENCE_THRESHOLD:is the second safety check. Even if the arithmetic was fine, a model that's unsure (low confidence) gets itsneeds_reviewflag set too. So a document is escalated if either the totals fail or confidence is low.route(inv)reads that final flag and returns"REVIEW QUEUE"whenneeds_reviewis true, otherwise"AUTO-INGEST". That one line is the fork in the diagram.
What the output means: For the good invoice you'll see total=110.0 conf=0.96 -> AUTO-INGEST; for the bad one, total=90.0 conf=0.74 -> REVIEW QUEUE with a note that the subtotal+tax didn't match the total, or confidence was low.
Try this: Raise CONFIDENCE_THRESHOLD close to 1.0 and re-run — more invoices get sent to review because the bar for auto-ingesting is now stricter.
Step 4 · Extending to real PDFs & scans expert
The schema doesn't change — only how you get the content in. For a native-text PDF or a scanned image, pass a document/image content block instead of text:
real PDF (concept)# base64-encode the PDF and send it as a document block (Ch 1)
messages=[{"role":"user", "content": [
{"type":"document", "source":{"type":"base64",
"media_type":"application/pdf", "data": b64_pdf}},
{"type":"text", "text":"Extract this invoice."},
]}
# same output_format=Invoice, same validator, same routing
This shows how to feed a real PDF (or a scan) instead of plain text — and the big lesson is how little changes. Only the input format is different; the schema, validator and routing all stay exactly the same.
- Instead of a plain string, the message
contentis now a list of blocks. The first block has"type":"document"and carries the PDF itself. "source":{"type":"base64", "media_type":"application/pdf", "data": b64_pdf}— base64 is a way of turning a binary file into plain text so it can travel inside a JSON request.b64_pdfis your PDF encoded that way.- The second block is a normal
"type":"text"instruction —"Extract this invoice."— telling the model what to do with the attached document. - The comment at the end is the whole point:
same output_format=Invoice, same validator, same routing. You swap how the content arrives; every guarantee downstream is untouched.
Try this: For a scanned image instead of a PDF, you'd send an image block the same way. Because the schema is the contract, the input can be text, PDF or image and the rest of your code never notices.
5 · Tests (no key — the validator is the safety net) expert
terminalpython -m pytest tests/ -v
test_reconciling_invoice_is_not_flagged PASSED
test_bad_totals_are_flagged_for_review PASSED
test_rounding_is_tolerated PASSED
test_currency_must_be_valid PASSED
4 passed
This runs the automated tests — and the exciting part is that they need no API key. Because the validator is plain arithmetic, you can prove the safety net works without ever calling the model, which makes the tests fast, free and deterministic.
python -m pytest tests/ -vtells pytest (the standard Python test runner) to run every test in thetests/folder.-vmeans "verbose" — print each test name and its result.- Each line ending in
PASSEDis one check that behaved correctly.test_bad_totals_are_flagged_for_reviewproves the validator catches an invoice whose numbers don't add up — the most important guarantee in the project. test_rounding_is_toleratedconfirms the 0.01 tolerance stops the validator from raising false alarms on cent-level rounding, andtest_currency_must_be_validconfirms theLiteral[...]enum rejects an unknown currency.
What the output means: 4 passed at the bottom means all four checks succeeded. If any failed, pytest would print FAILED with the exact assertion that broke, so you'd know what to fix.
Try this: Temporarily loosen the tolerance in the validator to something huge (say 100) and re-run — test_bad_totals_are_flagged_for_review should now FAIL, showing you the test is genuinely guarding the arithmetic.
| Test | Proves |
|---|---|
| good totals not flagged | clean invoices auto-ingest |
| bad totals flagged | the validator catches arithmetic errors — no model needed |
| rounding tolerated | no false flags on cent-level rounding |
| invalid currency rejected | the schema enforces the enum |
6 · Evals (needs key) expert
terminalpython evals.py
good invoice field accuracy: 3/3
good invoice needs_review: False (expect False)
bad invoice needs_review: True (expect True)
✅ evals passed
Field accuracy is deterministic (exact-match vs labels) — the best kind of eval. The hard-fail: the bad invoice must be flagged, never auto-ingested.
This runs the evals — measurements of how good the extraction actually is against known-correct answers. Tests check the code; evals check the model's output quality. This one needs an API key because it makes real extraction calls.
python evals.pyruns the eval script. It feeds in documents whose correct answers you already know (a "golden set") and compares what the model produced.good invoice field accuracy: 3/3means all three checked fields matched the labels exactly. Because the output is structured, this is a deterministic exact-match score — the cheapest and most trustworthy kind of eval.- The two
needs_reviewlines are the safety eval: the clean invoice must come backFalse(don't escalate) and the broken one must come backTrue(do escalate). Getting the bad one wrong would be the dangerous failure.
What the output means: ✅ evals passed means field accuracy hit its target and the bad invoice was correctly flagged. The flagging check is a hard-fail: a bad invoice that slips through un-flagged fails the eval no matter how good the accuracy number is.
Try this: Add a fourth labeled invoice to the golden set and re-run. Growing the golden set is exactly how you build confidence to let more documents auto-ingest without review.
Troubleshooting expert
| Symptom | Fix |
|---|---|
| Bad invoice not flagged | Confirm the @model_validator runs (mode="after") and the tolerance is 0.01, not too loose |
| Everything goes to review | Confidence threshold too high, or the model is under-confident — inspect inv.confidence |
| Wrong field values | Tighten the prompt ("use only values present"); add few-shot examples for tricky layouts |
| Hallucinated numbers | Instruct "never invent"; consider asking for a source snippet per field; the totals check catches many |
ValidationError on parse | The doc lacked a required field — make optional fields Optional[...] = None if legitimately absent |
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Document intelligence starts by modelling the target as a typed schema, so invalid currencies or out-of-range confidence are rejected before they ever reach your database.
Your task: Define LineItem and Invoice Pydantic models and instantiate a valid invoice.
Requirements:
LineItemcaptures description, quantity, unit price, and amountInvoicehas a currencyLiteral, a 0–1confidence, and aneeds_reviewflag- The schema rejects invalid currencies and out-of-range confidence
- Instantiate a valid invoice to prove the shape
- Runs offline
💡 Hint: A constrained currency Literal and a range-bounded confidence field are what turn 'extract the fields' into a checkable output.
Show solution
The schema is the contract the model must fill. Pydantic makes it enforceable:
from pydantic import BaseModel, Field
from typing import Literal
class LineItem(BaseModel):
description: str
quantity: float
unit_price: float
amount: float
class Invoice(BaseModel):
invoice_number: str
vendor: str
date: str
currency: Literal["USD", "EUR", "GBP"]
line_items: list[LineItem]
subtotal: float
tax: float
total: float
confidence: float = Field(ge=0.0, le=1.0)
needs_review: bool = False
inv = Invoice(invoice_number="A-1", vendor="Acme", date="2026-01-01",
currency="USD", line_items=[LineItem(description="Widget",
quantity=2, unit_price=5.0, amount=10.0)],
subtotal=10.0, tax=0.8, total=10.8, confidence=0.95)
print(inv.total, inv.needs_review) # 10.8 False
A typed schema turns "extract the fields" into a checkable output — invalid currencies or out-of-range confidence are rejected before they reach your database.
Context: Extraction that parses but doesn't add up is worthless. A cross-field validator catches the errors a schema can't — and it's pure code, so it's your cheapest, most reliable quality gate.
Your task: Add a @model_validator(mode="after") that checks totals reconcile and show it rejects a bad invoice.
Requirements:
- Line-item amounts must sum to the subtotal (within a cent)
subtotal + tax == total(within a cent)- The validator raises on a mismatch
- It runs in tests with no API key
- Demonstrate rejection of an invoice whose totals don't reconcile
💡 Hint: Round to cents before comparing to dodge float noise; this validator is the cheapest gate in the whole pipeline.
Show solution
Structural validity is not correctness. A cross-field validator catches the errors a schema can't:
from pydantic import model_validator
class Invoice(Invoice): # extend the Step-1 model
@model_validator(mode="after")
def check_totals(self):
line_sum = round(sum(li.amount for li in self.line_items), 2)
if abs(line_sum - self.subtotal) > 0.01:
raise ValueError(f"line items {line_sum} != subtotal {self.subtotal}")
if abs(self.subtotal + self.tax - self.total) > 0.01:
raise ValueError("subtotal + tax != total")
return self
try:
Invoice(invoice_number="B-2", vendor="X", date="2026-01-02", currency="USD",
line_items=[LineItem(description="a", quantity=1, unit_price=1, amount=1)],
subtotal=1.0, tax=0.1, total=99.0, confidence=0.9)
except ValueError as e:
print("rejected:", e) # rejected: subtotal + tax != total
This validator is pure code — it runs in tests with no API key and is your cheapest, most reliable quality gate.
Context: Extraction is only half the job; routing decides what a human sees. The confidence threshold is the dial between throughput and safety — set it from measured error rates, not a guess.
Your task: Build extract(text) using schema-constrained parsing and route(inv) that sends low-confidence invoices to review.
Requirements:
extractusesmessages.parsewith the Invoice schema (labelled needs-key)- It sets
needs_reviewwhen confidence is below a threshold routereturns REVIEW QUEUE or AUTO-INGEST- A deterministic offline stand-in keeps the routing logic testable
- Demonstrate both a clear invoice and a low-confidence one
💡 Hint: Keep the parse call labelled and swap in a deterministic fake extractor offline so the routing threshold is what your tests exercise.
Show solution
Extraction is only half the job; routing decides what a human sees. The parse() call is labeled needs-key, the routing logic is offline:
CONFIDENCE_THRESHOLD = 0.85
def extract(text):
# --- needs API key: schema-constrained extraction ---
# r = client.messages.parse(model="claude-opus-4-8", max_tokens=800,
# system="Extract invoice fields. Set confidence honestly.",
# output_format=Invoice,
# messages=[{"role":"user","content":text}])
# inv = r.parsed_output
inv = _fake_extract(text) # offline stand-in
if inv.confidence < CONFIDENCE_THRESHOLD:
inv.needs_review = True
return inv
def route(inv):
return "REVIEW QUEUE" if inv.needs_review else "AUTO-INGEST"
def _fake_extract(text):
conf = 0.95 if "TOTAL" in text.upper() else 0.6 # deterministic for tests
return Invoice(invoice_number="A-1", vendor="Acme", date="2026-01-01",
currency="USD", line_items=[LineItem(description="w", quantity=1,
unit_price=10, amount=10)], subtotal=10, tax=0, total=10, confidence=conf)
print(route(extract("clear TOTAL 10"))) # AUTO-INGEST
print(route(extract("blurry scan"))) # REVIEW QUEUE
The threshold is the dial between throughput and safety: raise it and more goes to humans; lower it and more auto-ingests. Set it from measured error rates, not by guessing.
Context: Real inputs are native-text PDFs, scanned images, or both. Sending a scanned image as text yields garbage; sending it as an image block invokes the vision path. Routing the input type is where accuracy on scans is won or lost.
Your task: Route the input type before extraction so text goes as text and scans go as image/document blocks, with a runnable offline dispatcher.
Requirements:
- Plain text is sent as a text content block
- Native PDFs are sent as document blocks
- Scanned images are sent as image content blocks
- An unknown input kind raises
- The dispatcher builds the right block shape offline (no live call)
💡 Hint: Dispatch on an input-kind tag and return the correctly-typed content block; one extract() then works across every format.
Show solution
Picking the right content block per input is where accuracy is won or lost on scans:
def build_content(doc):
kind = doc["kind"]
if kind == "text":
return [{"type": "text", "text": doc["text"]}]
if kind == "pdf":
# --- needs API key: real document block (base64 PDF) ---
return [{"type": "document",
"source": {"type": "base64", "media_type": "application/pdf",
"data": doc["b64"]}}]
if kind == "scan":
return [{"type": "image",
"source": {"type": "base64", "media_type": "image/png",
"data": doc["b64"]}}]
raise ValueError(f"unknown input kind: {kind}")
# offline: verify the dispatcher builds the right block shape
for d in [{"kind":"text","text":"INV 1"},
{"kind":"scan","b64":"iVBOR..."}]:
blocks = build_content(d)
print(d["kind"], "->", blocks[0]["type"])
# text -> text
# scan -> image
Sending a scanned image as text yields garbage; sending it as an image block invokes the vision path. The dispatcher keeps one extract() call working across every input format.
Context: The safety-critical metric here is not accuracy — it's that nothing bad slips through unreviewed. Straight-through rate is the business win; missed_review == 0 is non-negotiable.
Your task: Build an eval over a golden set that reports field accuracy and straight-through rate, and hard-fails if any known-bad invoice was auto-ingested.
Requirements:
- A golden set marking which invoices should be reviewed
- Report field exact-match accuracy
- Report the straight-through (auto-ingest) rate
- Hard-fail if any invoice that should have been reviewed was auto-ingested
- Report both metrics every run
💡 Hint: Count a missed review whenever a should-review invoice wasn't flagged, and assert that count is zero as the safety bar.
Show solution
The safety-critical metric here is not accuracy — it's that nothing bad slips through unreviewed:
GOLD = [
{"text": "clear TOTAL 10", "should_review": False, "total": 10},
{"text": "blurry scan", "should_review": True, "total": 10},
]
def evaluate(gold):
field_hits = auto = missed_review = 0
for g in gold:
inv = extract(g["text"])
field_hits += (abs(inv.total - g["total"]) < 0.01)
if not inv.needs_review:
auto += 1
if g["should_review"]:
missed_review += 1 # a bad invoice auto-ingested = critical
return {"field_acc": field_hits/len(gold),
"straight_through": auto/len(gold),
"missed_review": missed_review}
m = evaluate(GOLD)
print(m)
assert m["missed_review"] == 0, "CRITICAL: bad invoice auto-ingested"
print("eval passed")
Straight-through rate is the business win (fewer humans in the loop); missed_review == 0 is the non-negotiable safety bar. Report both every run.
Context: One big vendor puts tax in a footnote and uses a different date format, breaking generic extraction. A registry of small post-processors keeps the core generic while absorbing the long tail of vendor quirks.
Your task: Add a per-vendor override registry that post-processes the extracted invoice.
Requirements:
- A registry maps a vendor to a post-processing function
- Each override is a small pure function with its own unit test
- The core extractor stays vendor-agnostic
- An override for one vendor can't regress another
- Demonstrate a vendor fixup (e.g. date-format normalization)
💡 Hint: A decorator that registers a fixup by vendor name lets you special-case a customer without touching the core path.
Show solution
A registry of small, testable post-processors keeps the core generic while handling the long tail of vendor quirks:
from datetime import datetime
VENDOR_FIXUPS = {}
def fixup(vendor):
def deco(fn): VENDOR_FIXUPS[vendor] = fn; return fn
return deco
@fixup("Globex")
def _globex(inv):
# Globex writes dates DD/MM/YYYY; normalise to ISO
d = datetime.strptime(inv.date, "%d/%m/%Y")
inv.date = d.strftime("%Y-%m-%d")
return inv
def extract_with_fixups(text, vendor):
inv = extract(text)
fn = VENDOR_FIXUPS.get(vendor)
return fn(inv) if fn else inv
inv = extract("clear TOTAL 10")
inv.date = "31/12/2026"
print(_globex(inv).date) # 2026-12-31
Each override is a pure function with its own unit test, so a fix for one customer can't regress another. The core extractor stays vendor-agnostic; the registry absorbs the messy reality.
✓ Checkpoint — done when…
- The schema + validator flag non-reconciling invoices automatically.
- Extraction routes good→auto-ingest, bad→review.
- Validator tests pass with no key.
- The eval passes, including the "bad invoice must be flagged" hard-fail.
| Dimension | Meets the bar | Above the bar (staff-level) |
|---|---|---|
| Schema validity | Output cannot be malformed — it validates against the nested schema, and a business-rule validator (e.g. line items sum to total) rejects impossible documents. | The validator encodes real domain invariants, and validation failures route to review rather than being coerced into a plausible-but-wrong value. |
| Never silently wrong | Low-confidence or unvalidated fields are flagged, not guessed; the agent abstains on the risky bits instead of emitting a confident wrong number. | Abstention is measured as a first-class metric; a silently-wrong field is scored worse than an abstention, matching the cost of a bad invoice hitting the ERP. |
| Field accuracy | Per-field accuracy is measured deterministically against a gold set — not an overall vibe of good. | Accuracy is broken out by field criticality (amount and dates held to a higher bar than a memo line) and tracked per document type. |
| Confidence routing | Documents route to auto-ingest vs a human review queue by confidence + validation, and the split is tested without an API key. | The confidence threshold is calibrated so the review queue catches the errors it should without drowning humans in easy cases; queue volume is a monitored metric. |
| Input robustness | The pipeline extends from text to real PDFs/scans without changing the schema; degraded scans don't crash it. | OCR/parse confidence propagates into field confidence, so a blurry scan lowers trust in exactly the fields it garbled rather than the whole document. |
| Auditability | Every extracted field is traceable to where in the document it came from, and decisions (auto vs review) are logged. | Provenance is span/page-level and retained for audit, so a disputed value can be shown back to its source region on demand. |
Score each row 0 (missing) / 1 (meets) / 2 (above). A passing extraction build is 9+/12 with never-silently-wrong at 2 — an agent that emits a confident wrong amount with no abstention path is an automatic fail, because a plausible bad number in the ERP is worse than no answer.
Knowledge check check yourself
Why is extraction driven by a Pydantic schema, and what does per-field confidence plus a review queue protect against?
Show answer
Why is this project often a single well-structured call per document rather than an agent loop?