AI EngineeringZero to ProductionHome·About·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

"Stop paying people to retype documents into our systems." Finance keys invoices into the ERP; legal pulls terms out of contracts; ops processes forms. It's slow, expensive, and error-prone. An agent that reads the document and emits clean, validated fields — flagging anything it's unsure about — turns hours into seconds while keeping a human on the risky bits.

1 · Discovery — where does the time go? advanced

Where time 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

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

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

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

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

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

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.

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. client.messages.parse(...) is the key line: parse (not the plain create) makes the model return data that matches your schema. output_format=Invoice tells it which shape to use, and .parsed_output hands you back a real Invoice Python object.
  2. The messages list contains one user turn: an f-string that pastes the document's text after the label "Invoice document:" so the model knows what it's reading.
  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

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

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
    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