AI EngineeringZero to ProductionHome·About·What’s new·Contact
Project 4 · Design Chapter

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.

🎯 Intermediate📈 high ROI🧾 finance / legal / opsstructured-output-first

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.

before · a person retypes every field document PDF / scan human eyes reads & retypes ERP / DB typos leak in after · agent extracts, but raises its hand when unsure document PDF / scan the agent validated fields confident → ERP / DB unsure → human review The brief in one picture: keep the win, remove the typing, keep a human on the risky bits. The agent replaces the retyping, but unlike the tireless clerk it never guesses — anything it is unsure of is routed to a person instead of committed.
🗺️ How to read this diagram

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.

sort by where an hour of attention pays off most retype fields · many layouts ⭐⭐⭐ high volume · the core value totals don't add up ⭐⭐ validation rules ambiguous scans ⭐ flag for human agent leverage ↓ start at the wide top band; route the thin bottom band to a person the exotic 5% is a rounding error — don't spend your leverage there Leverage is a pyramid, not a to-do list. The widest band — high-volume, well-structured documents being retyped by hand — is where the agent earns its keep; the narrow bottom band of ambiguous scans is designed to go to a human, not fixed.
🗺️ How to read this diagram

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 goesAgent 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
Problem statement"Staff spend hours transcribing invoices/contracts into our systems, with typos and inconsistency. If an agent extracted the fields as validated structured data — with a confidence per field and a review queue for the uncertain ones — we'd process documents in seconds and only touch the hard cases by hand, never silently entering a wrong number."

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.

a straight pipeline — the one model call is quarantined in the middle document PDF / scan extract text PDF/OCR/vision LLM extract → Invoice schema validate schema + rules confident → ERP / DB unsure → human review deterministic code — no AI ↑ the only probabilistic step An assembly line, not a thinker. A document flows through fixed stations — extract text, one LLM call to fill the Invoice schema, deterministic validation — then a gate splits it: confident-and-valid auto-ingests, everything uncertain routes to a human. Nothing loops back.
🗺️ How to read this diagram

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.

DocumentPDF / scan Extract textPDF / OCR / vision LLM extract→ schema validateschema + rules high-conf → system low-conf → review
🗺️ How to read this diagram

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 Invoice schema, 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.

the only cell that hurts you is wrong AND auto-committed result was CORRECT result was WRONG auto-ingested flagged → review ✓ straight-through the business win ✗ silent disaster wrong number enters the ERP, nobody looks — a real incident · over-cautious a human spends a minute ✓ caught in time the error was caught in time tune toward the bottom row: abstaining is cheap, confidently-wrong is not Being right or abstaining beats being confidently wrong. Three of the four outcomes are fine; only wrong AND auto-ingested is a real incident — so every control exists to push uncertain results down into the flag-for-review row before they can commit.
🗺️ How to read this diagram

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.

RiskControl
🔴 A silently-wrong field entered into a systemPer-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 downstreamSchema-constrained output — it can't return the wrong shape (Ch 2 / Pydantic)
🔴 Sensitive PII in documentsHandle under your data-retention policy; redact where required; access controls on the review UI (Ch 6)
The golden rule of extraction agentsBeing right or abstaining beats being confidently wrong. A wrong invoice total that flows into accounting unnoticed is far worse than one flagged for a human. Confidence + validation + a review queue is the whole safety model.

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.

the schema is a gate — bad shapes never make it to your database the document extracted text the document extracted text free-text prompt "tell me about it" any shape comes back hand-written checks scattered, easy to miss typed schema fill the exact slots currency: USD|EUR|GBP half-checked object wrong shapes rejected here The schema is the spec, not the prompt. A free-text ask (top row) lets any shape come back and pushes validation into scattered hand-written checks; a typed schema (bottom row) forces the model to populate defined slots and rejects a wrong currency or out-of-range confidence before it can travel further.
🗺️ How to read this diagram

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
▶ How this works

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.

  1. 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.
  2. class Invoice(BaseModel) is the whole document. line_items: list[LineItem] means "a list of those rows" — a nested model inside a model.
  3. 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.
  4. confidence: float = Field(ge=0, le=1) forces the number between 0 and 1 (ge = greater-or-equal, le = less-or-equal). needs_review: bool is 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.

Add a validator for the business ruleUse a Pydantic validator (Python P4) to check 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.

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.

add the fewest doors — only the one write needs a lock safe to expose — no consequences PDF / image read-only OCR read-only validate deterministic lookup_vendor read-only 🔒 submit_to_system the one WRITE — into the ERP opens only when confident gate Every tool is a door; add the fewest that do the job. Extract, OCR, validate and vendor-lookup are read-only or deterministic — safe to expose freely (green). Only submit_to_system writes, so it is the single gated door that must never open on a low-confidence or rule-failing result.
🗺️ How to read this diagram

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.

CapabilityDoesRisk
PDF text / document inputNative PDF text or image input to the model🟢 read-only
OCR (for scans)Turn a scanned image into text🟢 read-only
validateSchema + business-rule checks (code)🟢 deterministic
lookup_vendor (optional)Match extracted vendor to a master list🟢 read-only
submit_to_systemWrite 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.

two metrics pull opposite ways as you loosen the threshold loosen the confidence threshold → straight-through rate ↑ the business win abstention correctness ↓ the safety floor safety floor — don't cross confidently- wrong zone Straight-through rate and abstention correctness pull against each other. Loosening the confidence threshold auto-ingests more documents (blue, the business win) but eventually drops the safety floor (red) — past the amber line you are optimizing straight into the confidently-wrong failure the design exists to prevent.
🗺️ How to read this diagram

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.

EvalMeasures
Field-level accuracyPer field, exact-match vs a labeled golden set (deterministic — the best kind)
Schema validity100% of outputs parse into the schema (Ch 2)
Business-rule passTotals reconcile; required fields present
Abstention correctnessWhen 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)
Extraction is the most testable agentBecause outputs are structured and there's a ground truth, evals are largely deterministic (exact-match per field) — cheap, fast, unambiguous. Build a golden set of ~30 labeled docs and you can measure accuracy precisely, per field.

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.

the auto-ingest band widens across phases — the floor never moves Phase 1 everything → human review Phase 2 confident →auto rest → review Phase 3 more layouts→ auto tail → review the floor (never crossed): a low-confidence or rule-failing doc is never auto-committed Phases move the threshold; they never remove the floor. The green auto-ingest band grows from nothing (Phase 1, everything reviewed) to most documents (Phase 3) as evals earn trust — but the red floor is fixed: uncertain or rule-failing documents always route to a human.
🗺️ How to read this diagram

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.

Phase 1 · Extract + review all — every document goes to a human who verifies the pre-filled fields. Faster than typing; builds the golden set. (Ch 2)
Phase 2 · Straight-through the confident ones — high-confidence, rule-passing extractions auto-ingest; the rest go to review. (Ch 5 sets the threshold)
Phase 3 · Widen coverage — more doc types & layouts as evals prove accuracy; humans handle the long tail. (Ch 6)
Never — auto-commit a low-confidence or rule-failing extraction. Uncertainty always routes to a human.

Skills & course map expert

SkillLearn it in
Schema-constrained extractionCh 2
Nested Pydantic models + validatorsPython P4
PDF / document / image inputCh 1 + provider PDF/vision features
Confidence routing / review queueCh 2 classifier pattern
Deterministic field-accuracy evalsCh 5
PII handling, retention, deployCh 6

Build-along plan expert

  1. Define the schema (Ch 2 + P4): the exact fields you need, with types, confidence, and needs_review.
  2. Extract from a text doc: feed a plain-text invoice, get a validated object out with messages.parse().
  3. Add PDF/scan input: handle a real PDF (native text, then a scanned image via vision/OCR).
  4. Add a validator (P4): totals must reconcile; failure sets needs_review=True.
  5. Evals (Ch 5): label ~30 docs; measure per-field accuracy and abstention correctness; tune the confidence threshold.
  6. Harden (Ch 6): PII policy, the review-queue UI, and a gated submit_to_system for the confident ones.
The most achievable "wow" projectBecause it's structured-output-first with deterministic evals, this is arguably the easiest to get genuinely production-grade. Want full build labs + runnable code (with sample invoices/contracts)? Ask and I'll build them like the DevOps capstone.
🛠️ Hands-on build — everything below is on this pageThe rest of this page is the complete, self-contained build: set up from an empty folder, paste in every file, run it (with a mock, so no API key is needed), and pass the tests. Follow it top to bottom — no other page required.

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.

Finished code includedAll in llm-course-starter/doc-intel/. Design rationale in the design chapter.

Step 1 · The schema + validator (the heart of it) expert

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1

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
▶ How this works

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.

  1. 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 = False defaults to "don't escalate" unless something trips the flag.
  2. @model_validator(mode="after") is a decorator that says "run this function after all the fields are set." The function check_totals then gets the finished object as self.
  3. 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.
  4. 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. Then return self hands 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.

Two layers of guaranteeThe schema guarantees shape (a currency outside the 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

Step 2

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

Step 3

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"
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)
▶ How this works

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.

  1. The schema-constrained parse call is the key line: parse (not the plain create) makes the model return data that matches your schema, handing you back a real Invoice Python object. With Claude it's client.messages.parse(output_format=Invoice, …) → .parsed_output; with OpenAI it's client.responses.parse(text_format=Invoice, …) → .output_parsed (toggle the Claude | OpenAI tabs above). Either way the Invoice shape is what you get back.
  2. 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 in messages=[…], OpenAI as input=…).
  3. if inv.confidence < CONFIDENCE_THRESHOLD: is the second safety check. Even if the arithmetic was fine, a model that's unsure (low confidence) gets its needs_review flag set too. So a document is escalated if either the totals fail or confidence is low.
  4. route(inv) reads that final flag and returns "REVIEW QUEUE" when needs_review is 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

Step 4

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
▶ How this works

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.

  1. Instead of a plain string, the message content is now a list of blocks. The first block has "type":"document" and carries the PDF itself.
  2. "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_pdf is your PDF encoded that way.
  3. The second block is a normal "type":"text" instruction — "Extract this invoice." — telling the model what to do with the attached document.
  4. 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.

Vision handles scansFor scanned/photographed docs, send an image block — the model reads it directly (or run OCR first). The whole point of the schema-first design: the input format is swappable, the guarantees stay.

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.

no model call — pure code proves the safety net test invoice a fixture, no LLM validator schema + totals check needs_review? ✓ not flagged totals reconcile ✗ flagged → review bad totals or currency The validator is the seam you can test without a model. A test invoice (a fixture, not an LLM call) runs through the schema + totals check; a reconciling one passes green, a bad total or invalid currency is blocked and flagged red — deterministic, offline, no key needed.
🗺️ How to read this diagram

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.

Step 5
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
▶ How this works

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.

  1. python -m pytest tests/ -v tells pytest (the standard Python test runner) to run every test in the tests/ folder. -v means "verbose" — print each test name and its result.
  2. Each line ending in PASSED is one check that behaved correctly. test_bad_totals_are_flagged_for_review proves the validator catches an invoice whose numbers don't add up — the most important guarantee in the project.
  3. test_rounding_is_tolerated confirms the 0.01 tolerance stops the validator from raising false alarms on cent-level rounding, and test_currency_must_be_valid confirms the Literal[...] 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 cases
TestProves
good totals not flaggedclean invoices auto-ingest
bad totals flaggedthe validator catches arithmetic errors — no model needed
rounding toleratedno false flags on cent-level rounding
invalid currency rejectedthe 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.

golden set → run → score → threshold gate golden set ~30 labelled run extract real LLM calls score field exact-match + abstention gate ≥ threshold ✅ pass ❌ fail bad invoice mis-flagged → hard fail Evals are a golden set run through a pass/fail gate. Labelled docs are extracted, scored per field (exact-match) and on abstention, then a threshold gate passes or fails the run — and a bad invoice that isn't flagged is a hard-fail no matter how high the accuracy.
🗺️ How to read this diagram

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.

Step 6
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.

▶ How this works

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.

  1. python evals.py runs the eval script. It feeds in documents whose correct answers you already know (a "golden set") and compares what the model produced.
  2. good invoice field accuracy: 3/3 means 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.
  3. The two needs_review lines are the safety eval: the clean invoice must come back False (don't escalate) and the broken one must come back True (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

⚠️ Common issues
SymptomFix
Bad invoice not flaggedConfirm the @model_validator runs (mode="after") and the tolerance is 0.01, not too loose
Everything goes to reviewConfidence threshold too high, or the model is under-confident — inspect inv.confidence
Wrong field valuesTighten the prompt ("use only values present"); add few-shot examples for tricky layouts
Hallucinated numbersInstruct "never invent"; consider asking for a source snippet per field; the totals check catches many
ValidationError on parseThe 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.

Exercise 1 · Define the extraction schema with confidence + review flagBeginner

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:

  • LineItem captures description, quantity, unit price, and amount
  • Invoice has a currency Literal, a 0–1 confidence, and a needs_review flag
  • 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.

Exercise 2 · Business-rule validator: totals must reconcileIntermediate

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.

Exercise 3 · Extract-and-route with a confidence thresholdAdvanced

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:

  • extract uses messages.parse with the Invoice schema (labelled needs-key)
  • It sets needs_review when confidence is below a threshold
  • route returns 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.

Exercise 4 · Handle PDFs and scans — text vs document blocksExpert

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.

Exercise 5 · Golden-set eval with a hard-fail on missed reviewProfessional

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.

Exercise 6 · Vendor-specific overrides without forking the pipelineIndustry scenario

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.
📋 Master rubric — grade your extraction agent
DimensionMeets the barAbove the bar (staff-level)
Schema validityOutput 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 wrongLow-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 accuracyPer-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 routingDocuments 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 robustnessThe 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.
AuditabilityEvery 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

✓ Knowledge check

Why is extraction driven by a Pydantic schema, and what does per-field confidence plus a review queue protect against?

Show answer
The schema is the spec: schema-constrained output can't return a malformed shape (Ch 2/Pydantic). Per-field confidence plus validation rules route anything below threshold or failing a rule to a human review queue — so a silently-wrong field is never auto-committed. Being right or abstaining beats being confidently wrong.
✓ Knowledge check

Why is this project often a single well-structured call per document rather than an agent loop?

Show answer
For most documents extraction is Tier 1: pull text (native/OCR/vision), have the LLM fill the schema in one call, then validate with code and route by confidence. There's no multi-step reasoning or tool use to justify an agentic loop — rigor comes from the schema and validation, not iteration.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in