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
You've almost certainly watched this happen. Someone on the finance team has a PDF invoice open on one screen and the ERP open on the other, and they are literally reading a number off the left and typing it into the right. Multiply that by a few hundred documents a week and you have a full-time job that is pure transcription — slow, mind-numbing, and quietly generating typos that surface as reconciliation problems weeks later.
The right way to picture what you're building is a very careful, very fast data-entry clerk who never gets bored — but with one rule the human clerk never had: when it isn't sure, it raises its hand instead of guessing. That single discipline is what separates a document-intelligence agent worth deploying from a demo that looks great until it silently fat-fingers a total into your books.
Why a project like this earns a proper brief — more than a chattier assistant would — is that its value is measurable and boring: hours of manual entry converted to seconds, at a known accuracy, with a known escape hatch. When you write the brief, name the document types, the handful of fields that actually matter, and above all the cost of getting one wrong. That last number is what justifies the human-in-the-loop machinery in every section below. The common mistake is to pitch this as "AI reads your documents" and skip the cost-of-error conversation — then the first wrong invoice that reaches accounting becomes your incident instead of a designed-for, expected outcome.
Compare the two rows — the same document on the left of each, but a different path to the system on the right.
- The top row is today: a document, a human reading a number off it and retyping it (amber), and the ERP that quietly collects the typos this produces.
- The bottom row is what you are building: the same document flows into the agent (purple), which emits validated fields instead of prose.
- The fork on the right is the whole point of the brief — a confident result flows straight into the system (green), while an unsure one is routed to a human (red). The amber arrow is the agent choosing to abstain rather than guess.
In short: the agent removes the retyping but keeps the escape hatch — confident results auto-commit, uncertain ones raise a hand, so a wrong number never silently reaches the books.
"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
Here is the trap that sinks half of these projects. Someone gets excited, points the model at the messiest, most ambiguous scans in the pile — faded thermal receipts, handwritten annotations, a form nobody has a template for — and declares the whole idea unworkable when it stumbles. They spent all their leverage on the 5% of documents that are hard even for a human, and ignored the 80% that are dead simple and being retyped by hand every single day.
Discovery is the deliberate act of resisting that trap. Think of it like triage in an emergency room: you don't treat patients in the order they walked in, you sort by where attention pays off most. The question isn't "what's the hardest document we get?" — it's "where does the time actually go, and where does the agent add the most value per hour saved?" Usually the answer is the boring, high-volume, well-structured documents; the exotic ones are a rounding error you route to a person.
The table below is that triage laid out. Read it as a ranking, not a checklist: the ⭐⭐⭐ rows are where you start, because that's where transcription hours pile up and where a model that generalizes across layouts beats a brittle rules-based OCR pipeline. The ⭐ row — judgment calls on bad scans — isn't a failure to fix; it's designed to go to a human, and admitting that up front is what keeps the project honest.
Read it top-to-bottom, following the arrow down the left edge: each band drops in agent leverage, and its width shrinks to match.
- The wide green band at the top (⭐⭐⭐) is the high-volume, many-layout retyping — the most work being done by hand, and exactly where a model that generalizes beats brittle rules-based OCR. This is where you start.
- The amber middle band (⭐⭐) is catching totals that don't reconcile — real value, but handled by deterministic validation rules rather than raw extraction.
- The thin red band at the bottom (⭐) is judgment calls on bad scans. Its narrowness is the message: it is a small slice, and it is designed to route to a human rather than something to keep grinding on.
In short: spend your effort on the wide top band where hours pile up, and deliberately hand the narrow bottom band to a person — that triage is what keeps the project honest.
| 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
The instinct with anything labelled "agent" is to reach for a loop. You imagine the model reasoning, calling a tool, looking at the result, calling another — a little autonomous worker chipping away at the document. For extraction, that instinct is usually wrong, and following it makes the system slower, costlier, and much harder to test than it needs to be.
The cleaner mental model is an assembly line, not a thinker. A document rides down a conveyor through a fixed sequence of stations — get the text out, hand it to the model to fill a schema, run code to check it — and at the very end a gate decides which of two bins it drops into: straight-through to the system, or over to a human. Each station does one job and passes the work along; nothing loops back. That linearity is a feature: a straight pipeline is something you can reason about, unit-test station by station, and reason about the cost of.
Why it matters that the model call is exactly one box in the middle: everything before it (extracting text) and everything after it (validating, routing) is ordinary deterministic code — no AI, no randomness, fully testable for free. You've quarantined the probabilistic part to a single step and wrapped it in checks. The common mistake is to blur that boundary — letting the model also decide whether to submit, or having it "double-check" its own arithmetic — which hands the safety-critical decisions back to the least predictable component. Keep the fork in code; let the model only fill the form.
Invoice schema, deterministic validation — then a gate splits it: confident-and-valid auto-ingests, everything uncertain routes to a human. Nothing loops back.
Follow the single row left to right, then watch it fork at the end — that fork is the whole safety story of the project.
- The grey/blue stations — document, then extract text (native PDF text, OCR, or a vision read) — are plain deterministic code that turns the raw file into something readable.
- The purple box in the middle, LLM extract → Invoice schema, is the one model call — the only probabilistic step, deliberately quarantined between code on both sides. The amber validate box then checks shape and arithmetic with no AI at all.
- The fork on the right is the payoff: a green arrow sends confident, rule-passing results straight to the ERP/DB, while a red arrow routes anything unsure to a human review queue.
In short: a straight pipeline with the model boxed into a single middle step — everything before and after is testable code, and the end gate never lets an uncertain result auto-commit.
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
Picture the worst thing this system can do. It's not a crash — a crash is loud, someone notices, someone fixes it. The worst thing is far quieter: the agent reads $1,240.00 as $12,400.00, its confidence looks fine, and that wrong number flows straight into accounting, gets paid, and nobody spots it until a reconciliation a month later. A confidently-wrong extraction that no human ever looked at is the failure mode that gets projects like this shut down.
So the safety model here is built around a single inversion of instinct: being right or abstaining beats being confidently wrong. A smoke detector that occasionally chirps at burnt toast is annoying; one that stays silent during a real fire is lethal. You want your agent tuned like the good smoke detector — quick to raise its hand, because a document sent to a human by mistake costs a minute, while a wrong one sent to the ERP costs an incident.
The table maps each concrete risk to the control that catches it, and you'll notice they layer rather than compete. Schema constraints stop malformed shapes at the door; per-field confidence plus validation rules stop silently-wrong values; requiring a source span stops invented numbers; a PII policy stops sensitive data leaking through the review UI. No single control is the answer — the safety comes from stacking cheap deterministic checks so that anything uncertain, malformed, or unsupported is routed to a person before it can be committed, never after.
Read it as a 2×2 grid: the columns split on whether the extraction was actually correct or wrong; the rows split on what the system did with it — auto-ingested it or flagged it for a human.
- Top-left (green) is the happy path: correct and auto-ingested — the straight-through win the whole project is chasing.
- The bottom row (amber, then green) is the safe zone: whenever a result is flagged for review, the worst that happens is a human wastes a minute (bottom-left) — and if it was actually wrong, that minute just caught the error (bottom-right).
- The payoff cell is top-right, red: wrong and auto-ingested — a wrong number that no human ever saw. Every control in the table below exists to move results out of that cell and down into the safe review row.
In short: only one of four cells is a disaster; tuning the agent to raise its hand pushes risk into the cheap cells instead of the catastrophic one.
| 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
If you've ever asked a model to "extract the invoice details" and gotten back a chatty paragraph — or JSON that was almost-but-not-quite the shape your database expected — you've felt why this section exists. Free-text prompting for structured data is a coin flip on every field name, every date format, every "oh, this one used a comma instead of a period." Downstream code that expects total: float and receives "total": "one hundred and ten dollars" doesn't gracefully degrade; it explodes.
A schema flips that relationship. Think of it as a fill-in-the-blank form handed to the model rather than a blank page: instead of "tell me about this invoice," you say "put a number here, one of exactly these three currency codes there, and a value between 0 and 1 in that box." The model isn't composing prose anymore — it's populating slots you defined, and anything that doesn't fit the slots is rejected mechanically before it can travel further.
This is why the schema, not the prompt, is the real specification of the project. The precise point: a well-designed schema makes an entire class of errors unrepresentable — a currency outside the allowed set literally cannot come back, a confidence of 2.0 literally cannot come back. The common mistake is treating the schema as a loose suggestion and doing the real validation afterward in hand-written if statements; that scatters your guarantees and lets shape-errors slip through. Put the constraints in the types, and the model is forced to hand you something already half-checked. The lab below builds exactly that, field by field.
Compare the two rows — same document on the left of each, two very different ways of asking for it.
- The top row is free-text prompting: you ask "tell me about this invoice" and any shape can come back (red), so you're left bolting on hand-written
if-checks afterward (amber) — scattered and easy to miss. - The bottom row is the typed schema: the model must fill defined slots (purple) — a currency must be one of
USD|EUR|GBP, confidence must be 0–1 — and anything that doesn't fit is rejected at that boundary, so what lands in the green box is already a valid, half-checked object. - The dashed grey arrows (top) mark the risky, permissive path; the solid blue arrows (bottom) mark the constrained path where the type system does the guarding for you.
In short: put the constraints in the types and an entire class of malformed outputs becomes unrepresentable — the schema catches them, not you.
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
Coming off other agent projects, you might expect a long list of tools here — search this, call that, look up the other. Document intelligence is refreshingly different, and the surprise is worth sitting with: most of the time the "tool surface" is nearly empty, because the work is one model call bracketed by plain code, not a toolbelt the model reaches into mid-thought.
The useful way to think about a tool is that every one you add is a door you cut into the wall of your system — convenient, but each door is something that can be walked through in a way you didn't intend, and something you now have to guard. So the discipline is to add the fewest doors that do the job. Getting text out of a PDF, running OCR, checking totals in code — none of those need to be "tools" the model decides to call; they're just steps in the pipeline. The only thing that genuinely earns a guarded door is the one action with consequences: writing a record into the real system.
That's why the table sorts capabilities by risk, and why submit_to_system is the one marked gated. The precise point: read-only and deterministic operations (extract, OCR, validate, look up a vendor name) are cheap and safe to expose; the single write is the one that must never fire on a low-confidence or rule-failing result. The common mistake is to reflexively wrap everything in an agent loop because the project is called an "agent" — but a loop only pays for itself when a document genuinely needs multi-step reasoning (cross-referencing pages, chasing a lookup). For the ordinary invoice, one structured call is cheaper, faster, and far easier to test.
submit_to_system writes, so it is the single gated door that must never open on a low-confidence or rule-failing result.
Scan the four green tiles on the left, then the single amber box on the right — they are grouped by whether the capability can change anything.
- The four green tiles are the read-only and deterministic capabilities — reading a PDF or image, OCR, validating in code, matching a vendor name. None of them can commit anything, so they are cheap and safe to expose with no guard.
- The amber, locked box on the right is
submit_to_system— the one capability that writes a record into the real ERP/DB. It is the only action with consequences. - The blue "gate" arrow is the safety condition: the write only fires for a high-confidence, validated result. Everything uncertain is stopped before it reaches that door.
In short: keep read-only steps open and put the single lock on the one write — that, not an agent loop, is where the safety lives.
| 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
On most agent projects, evaluation is the part everyone dreads — you're grading free-flowing prose, arguing about whether an answer was "good enough," standing up an LLM-as-judge and then wondering whether to trust the judge. Extraction is the happy exception, and it's the reason this project is one of the easiest to make genuinely production-grade: the right answer is a fact, not an opinion.
Because the output is structured and there's a ground truth, evaluating it is like marking a multiple-choice test against an answer key rather than grading an essay. Did invoice_number come back INV-2026-0455, yes or no? Exact-match, per field, deterministic — cheap, fast, and unarguable. That's a luxury most of the course's evals don't have, so lean into it: a golden set of ~30 carefully-labelled documents lets you measure per-field accuracy to a precise percentage.
But field accuracy alone would let a dangerous system pass, and this is the point the table circles: the metric that actually protects you is abstention correctness — when the agent flagged needs_review, was it genuinely uncertain, and crucially, did it flag the ones it got wrong? A model that's 98% accurate but silently confident on the 2% it botches is worse than one that's 95% accurate and reliably raises its hand on the rest. So you track two numbers in tension: the straight-through rate (the business win — how many docs skip review) and abstention correctness (the safety floor). Push the first without watching the second and you've optimized your way straight back into the confidently-wrong failure the whole design exists to prevent.
Read left to right along the horizontal axis: moving right means loosening the confidence threshold so more documents auto-ingest instead of going to review.
- The blue rising curve is the straight-through rate — the business win. As you loosen the threshold, more documents skip the human queue, so it climbs.
- The red falling curve is abstention correctness — the safety floor. Loosen too far and the agent stops flagging the ones it gets wrong, so this drops.
- The amber dashed line is the safety floor you must not cross; to its right (the red-tinted zone) you have traded away the ability to catch confidently-wrong extractions.
In short: push the blue metric only as far as the red one allows — the amber line is where more throughput starts costing you real incidents.
| 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
The tempting move is to flip the switch and let the agent auto-ingest everything on day one. Resist it. You have no golden set yet, no proven accuracy, and no earned trust from the finance team whose numbers you're about to touch — turning on full automation now is asking people to bet their books on a system that has never been measured. The first wrong auto-committed invoice would poison the project's reputation before it ever showed its value.
A phased rollout is how you earn autonomy instead of assuming it, and the mechanism is a dial, not a switch. Think of a self-driving feature that starts by only suggesting lane changes with a human's hands on the wheel, then takes over more as the data proves it safe. Here the dial is the confidence threshold: start with it effectively at "review everything," and lower it — widening the band of documents that auto-ingest — only as your evals earn the right.
That's exactly what the rungs below describe. Phase 1 pre-fills fields but sends every document to a human — which is still faster than typing from scratch, and it quietly builds the labelled golden set you need to measure anything. Phase 2 lets the confident, rule-passing extractions flow straight through while the rest go to review. Phase 3 widens coverage to more layouts as accuracy proves out. And the final rung is a line you never cross: a low-confidence or rule-failing extraction is never auto-committed, at any phase, no matter how good the numbers look. The phases move the threshold; they never remove the floor.
Read the three panels left to right — they are the same pipeline at increasing levels of trust, and the blue arrows mark the progression from one phase to the next.
- Phase 1 is all amber: every document goes to human review. Slow, but it builds the labelled golden set and never risks a wrong auto-commit.
- Phase 2 introduces a green band — confident, rule-passing extractions now flow straight through, while the rest stay in the amber review band.
- Phase 3 widens the green band as evals prove accuracy on more layouts; only the long tail still needs a human.
- The red line along the bottom is the floor that is identical in all three panels: a low-confidence or rule-failing document is never auto-committed, no matter how good the numbers get.
In short: the green auto-ingest region grows as you earn trust, but the red floor under it stays put — you widen coverage, you never remove the safety net.
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.
Structured extraction — in Claude or OpenAI
The Pydantic schema, the prompt, and the surrounding plumbing are vendor-neutral — only the structured-output call changes. Claude uses client.messages.parse(output_format=…) → resp.parsed_output; OpenAI uses client.responses.parse(text_format=…) → resp.output_parsed. Toggle the tabs.
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"agent/engine.pydef extract(text):
inv = client.responses.parse(model=MODEL, max_output_tokens=800, instructions=SYSTEM,
text_format=Invoice,
input=f"Invoice document:\n{text}").output_parsed
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.
- The schema-constrained parse call is the key line:
parse(not the plaincreate) makes the model return data that matches your schema, handing you back a realInvoicePython object. With Claude it'sclient.messages.parse(output_format=Invoice, …)→.parsed_output; with OpenAI it'sclient.responses.parse(text_format=Invoice, …)→.output_parsed(toggle the Claude | OpenAI tabs above). Either way theInvoiceshape is what you get back. - The user input is one turn: an f-string that pastes the document's text after the label
"Invoice document:"so the model knows what it's reading (Claude passes it inmessages=[…], OpenAI asinput=…). 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
The safety net is plain arithmetic, not the model — so the tests run offline, with no API key. A test invoice hits the validator, and its needs_review flag decides whether it lands in the pass bin or the flagged bin. The diagram shows that guard.
Read left to right: a hand-written test invoice flows into the validator, and the guard decides which bin it drops into — all without ever calling the model.
- The blue box on the left is a test invoice — a fixture you wrote, standing in for the LLM's output so the test needs no API key.
- The amber box is the validator: it runs the schema check and the totals reconciliation in plain code, setting
needs_review. - The fork is the assertion: a reconciling, valid invoice takes the green path (not flagged), while a bad total or an out-of-enum currency takes the red path (blocked, flagged for review) — exactly the four cases the test table below checks.
In short: because the guard is arithmetic and not the model, you can prove the safety net catches bad invoices deterministically and offline.
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
Evals measure the model's output against known-correct answers, so this step needs a key. A golden set of labelled documents is run through extraction, each result is scored per field and on its needs_review flag, and a threshold gate decides pass or fail — with the bad invoice's flag as a hard-fail. The diagram traces that pipeline.
Read left to right — it is a measurement pipeline that ends in a single pass/fail verdict.
- The golden set (blue) is your ~30 hand-labelled documents whose correct answers you already know; run extract (purple) makes the real model calls — this is why the step needs a key.
- The amber score box compares each result to the labels: per-field exact-match accuracy plus abstention correctness (did it flag the ones it got wrong?).
- The threshold gate forks the run: meet the bar and it goes green (pass); fall short — or let a bad invoice slip through un-flagged, the hard-fail — and it goes red (fail).
In short: a golden set flows through scoring into a gate, and flagging the bad invoice is a non-negotiable hard-fail — accuracy alone can't buy a pass.
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
# OpenAI equivalent:
# r = client.responses.parse(model="gpt-5.5", max_output_tokens=800,
# instructions="Extract invoice fields. Set confidence honestly.",
# text_format=Invoice, input=text)
# inv = r.output_parsed
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?