TALDYN
‹ The Playbook Library
SCH-03 · SCHEMATIC

Structured Extraction: Documents In, Validated JSON Out

Extraction is the workhorse of document automation — and the difference between a demo and a system is validation loops, calibrated confidence, and routing. The full pipeline, with code.

JUL 6 · 20265 min readEngineeringDataDesign

Invoices, applications, contracts, intake forms — a huge share of real-world AI value is one shape: unstructured document in, structured record out. Modern models are genuinely good at this. What separates a demo from a production system is everything wrapped around the model call: a schema that encodes your rules, a validation loop that catches bad output before your database does, confidence that means something, and a router that knows when to hand a document to a human.

01The schema is the contract

Design the target schema the way you'd design an API: explicit types, enums for anything with a known value set, and — critically — room for honest uncertainty. A schema that forces a value for every field forces hallucination on every missing field. Make nullable fields genuinely nullable, and require the model to flag what it couldn't find rather than improvise it.

pythonextraction/schema.py — Pydantic schema with honest uncertainty
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field

class LineItem(BaseModel):
    description: str
    quantity: float = Field(gt=0)
    unit_price_cents: int = Field(ge=0)

class InvoiceExtraction(BaseModel):
    vendor_name: str
    invoice_number: str
    invoice_date: date
    due_date: date | None = None          # genuinely optional in the wild
    currency: Literal["USD", "EUR", "GBP"]
    total_cents: int = Field(ge=0)
    line_items: list[LineItem]
    # honest-uncertainty channel — the model reports rather than invents
    missing_fields: list[str] = []
    ambiguities: list[str] = []           # "two dates present; used the later"

    def consistency_errors(self) -> list[str]:
        errs = []
        items_total = sum(
            round(li.quantity * li.unit_price_cents) for li in self.line_items
        )
        if self.line_items and abs(items_total - self.total_cents) > 1:
            errs.append(
                f"line items sum {items_total} != stated total {self.total_cents}"
            )
        if self.due_date and self.due_date < self.invoice_date:
            errs.append("due_date precedes invoice_date")
        return errs

02The validation loop: let the error message do the prompting

The single highest-leverage pattern in extraction: when validation fails, don't just retry — feed the validator's error message back to the model and ask it to correct its own output. Pydantic's errors are precise ("total_cents: input should be a valid integer"), and models are markedly better at fixing a named mistake than at avoiding all mistakes in one shot. Two rounds of this loop resolve the large majority of validation failures; what survives the loop is genuinely hard and belongs with a human.

Note the layering: schema validation (types, enums, ranges) is mechanical; consistency checks (line items sum to total, dates ordered) encode business rules the type system can't see. Run both, feed both back.

pythonextraction/pipeline.py — validate, repair, or escalate
from pydantic import ValidationError

MAX_REPAIRS = 2

def extract_invoice(document_text: str, call_llm) -> tuple[str, object]:
    """Returns (route, result): route is 'auto' | 'review' | 'reject'."""
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},   # schema + rules + examples
        {"role": "user", "content": document_text},
    ]
    last_error = None
    for attempt in range(1 + MAX_REPAIRS):
        raw = call_llm(messages, json_schema=InvoiceExtraction)  # provider seam
        try:
            parsed = InvoiceExtraction.model_validate_json(raw)
        except ValidationError as e:
            last_error = f"Schema errors:\n{e}"
        else:
            issues = parsed.consistency_errors()
            if not issues:
                return route_by_confidence(parsed), parsed
            last_error = "Consistency errors:\n- " + "\n- ".join(issues)
        # feed the exact failure back and let the model repair its output
        messages.append({"role": "assistant", "content": raw})
        messages.append({"role": "user", "content":
            last_error + "\nReturn corrected JSON only. If a value is not in "
            "the document, use null and add the field to missing_fields — "
            "never invent it."})
    return "reject", last_error   # exhausted repairs → human queue with context

def route_by_confidence(parsed: InvoiceExtraction) -> str:
    if parsed.missing_fields or parsed.ambiguities:
        return "review"           # honest uncertainty → human eyes
    return "auto"

03Confidence you can act on

Raw model self-confidence ("rate your certainty 1–10") is poorly calibrated and shouldn't gate anything alone. Production confidence is an ensemble of signals you can measure: did validation pass on the first attempt or need repairs? Did the model populate missing_fields or ambiguities? Do cheap independent checks agree (regex-extract the invoice number and compare)? For high-stakes fields, does a second extraction pass — same document, different prompt phrasing — produce the same value?

Each signal is weak alone; together they partition documents into a clean-pass majority that flows straight through and a flagged minority that earns human review. Tune the thresholds against your golden set until the auto lane's field-level error rate is below the error rate of your manual process — that's the honest bar, since humans mistype too.

04The routing tiers

Every reviewer correction in the review lane is gold: log the (document, model output, human correction) triple. That log is simultaneously your error analysis, your regression eval set, and your evidence for which prompt changes actually help.

  • Auto lane. First-pass validation, no uncertainty flags, checks agree. Writes to the system of record with an extraction-audit row (document hash, model, output, signals) for later reconstruction.
  • Review lane. Validation needed repairs or the model flagged uncertainty. The UI shows the document and extraction side by side with flagged fields highlighted — the reviewer confirms in seconds because attention is pre-aimed.
  • Reject lane. Failed the repair loop, or the document isn't the expected type at all. These route to the human process with the error context attached — and get logged as candidates for prompt or schema fixes.

05Evaluating extraction like an engineer

checklist
  • Golden set of 30–50 real documents with hand-verified extractions, including the ugly scans and the weird vendor formats.
  • Score field-level accuracy, not document-level vibes — "98% of fields, 71% of documents perfect" tells you exactly where to look.
  • Track the auto/review/reject split over time — a drifting split is your earliest warning that upstream documents changed.
  • Re-run the golden set on every prompt, schema, or model change; a change that helps averages can still break your most common vendor.

OPERATOR NOTE — The missing_fields channel is the single cheapest reliability upgrade in extraction: models fabricate most when schemas leave no honest way to say "it isn't there."

TRANSMIT

Put this architecture to work.

A 30-minute strategy call with an operator — we'll map your first deployment path, not send a deck.