Recipe: Invoice processing¶
Invoices arrive as messy PDFs and emails. The system extracts the structured fields (amount, vat, counterparty, due date), validates them against the source text, and routes each invoice to auto-pay or to a human approver — with a full audit trail and durable resume for approvals.
Runnable pieces:
examples/structured_output/ (schema-validated
JSON) and examples/agent_approval/ (every
tool/step can pause for human sign-off).
1. The business problem¶
An accounts team processes hundreds of invoices. Each one needs the same fields extracted correctly — a hallucinated amount is worse than no amount. Small invoices under a threshold should pay automatically; large or unusual ones need a human's sign-off. The whole pipeline is an audit target: who extracted what, who approved it, when.
2. Graph design¶
invoice ──▶ extract (LLM → output_type) ──▶ fallback ──▶ validate (Ask/Validate)
│ (per-field JSON + │ (deterministic │ (re-check against
│ source spans) │ fill) │ the text)
│ │ └──(fail)──▶ re-extract (loop, bounded)
└──────────────────────────┴──────────────▶ gate ──(amount ≤ limit)──▶ pay
└─(large/odd)──▶ approve ──▶ pay
(human Interrupt,
durable resume)
extractproduces a structured result viaoutput_typeand reports a failure (instead of malformed state) when it can't fit the schema — the failure is caught by an__error__edge, not by the business logic.fallbackdeterministically fills fields the model left empty (e.g. a default VAT rate) — seeExtract/Fallback.validatere-checks the extracted values against the source text and can send the invoice back for a boundedre-extractround rather than paying on bad data.
3. State schema¶
class InvoiceState(TypedDict):
invoice_id: str
source_text: str
parsed: dict # {amount, vat, counterparty, due_date, confidence} (output_type)
decision: str # "pay" | "approve"
events: Annotated[list, "append"] # reducer: who did what, when
Every step appends one events entry (extract → validated → paid / approved),
which is exactly what the audit report and the offline tests assert against.
4. Durability & HITL¶
approve is an Interrupt behind a durable checkpointer. A large invoice
pauses with the extracted fields in the prompt; the approver answers later —
from another process, as long as they use the same checkpointer and
checkpoint_id. This is the canonical "a human answers later than the machine
asked" signal (see fraud review for the same shape).
result = await graph.run(
state=state,
checkpointer=cp,
checkpoint_id=f"invoice-{invoice_id}",
resume={"approve": "approved"},
)
5. Testing strategy¶
Offline, no LLM: a canned transport returns a fixed parsed invoice, then the tests walk every route:
- amount ≤ threshold →
pay, events =[extracted, validated, paid]. - amount > threshold → run pauses on
approve; a laterresumepays it. - extraction fails schema →
__error__edge tore-extract, never a payment. validaterejects the first parse → bounded retry, then escalate if still bad.
6. Cost & observability¶
- Watch: extraction
confidence, validation failure rate (how often the model "remembers" the wrong amount), approval lag, per-invoice tokens. - Bound:
output_typeschema +Validate; a bounded re-extract loop so a bad invoice can't spin forever. - Audit: the
eventsreducer is the report — every run's trace replays each decision.
7. Deploy¶
A teff new cli app or a FastAPI endpoint per queue. The durable sessions live
in a file/SQLite checkpointer locally; swap to pg (pg-checkpoint) when
several workers approve invoices concurrently. teff eval scores extraction
accuracy against a labeled dataset before you touch real money.
8. How to adapt¶
- Different document: receipts, PO orders, medical claims — same
extract→validate→approve skeleton, different
output_typeschema. - Different threshold: the gate is a static
branchonamount— one state field, no code change. - Tiered approval: > threshold → manager, > 10× threshold → CFO — chain
two
Interrupts with separate keys. - Receipts with photos: swap
source_textfor an image field; the extract prompt changes, the graph doesn't.