Skip to content

Cookbook: Invoices

The job: turn an invoice PDF (or photo of one) into a structured record — number, dates, vendor, customer, line items, tax, totals.

from scriva import Orchestrator, Worker
from scriva.prompts import Prompt
worker = (
Worker.openai("gpt-4o")
.prompt(Prompt.invoice)
.cache(".scriva_cache")
.score(method="rendering")
.retry(times=3)
)
recipe = (
Orchestrator()
.orient()
.deskew()
.split.boxes(detector="invoice") # vendor block + items table + totals
.classify(blank=True, merged=True)
.recognize.by_kind({
"text": worker,
"number": Worker.number(),
"date": Worker.date(),
"blank": Worker.skip(),
})
.reconstruct.kv()
.export.json("acme.json")
)
result = recipe("acme-2026-05.pdf")

recipe.to_dict()["fields"] gives you the typed record. Vendor names, line items, tax/total relationships — every cell that the splitter found has a Recognition with confidence. Use Worker.number() for amounts and Worker.date() for the issue/due dates and they parse to ISO and Decimal for free.

The standard invoice record:

class Invoice(BaseModel):
invoice_number: str
invoice_date: date
due_date: date | None
currency: str # ISO 4217 ("USD", "EUR", "JPY")
subtotal: Decimal | None
tax_amount: Decimal | None
total_amount: Decimal
vendor: Party
customer: Party | None
line_items: list[LineItem]
payment_terms: str | None
notes: str | None

Full type catalogue: Schemas › Invoice.

Subclass and the recipe’s reconstruct/export shape will surface the extra fields automatically:

class MyInvoice(Invoice):
purchase_order: str | None = None
project_code: str | None = None
requisitioner_email: str | None = None
recipe = recipe.replace(
"reconstruct",
reconstruct.kv(schema=MyInvoice),
)

Build a YAML dictionary of canonical vendor names and slot it in as a reconstruct step:

vendors.yaml
vendors:
- canonical: "Acme Industries, Inc."
aliases: ["Acme Ind.", "ACME INDUSTRIES", "Acme Inc"]
- canonical: "MegaCorp PLC"
aliases: ["MEGACORP", "Mega Corp", "MegaCorp Ltd."]
recipe = recipe.insert_after(
"recognize",
reconstruct.dictionary.from_yaml(
"vendors.yaml",
apply_to="vendor.name",
case_sensitive=False,
),
)

Pin a default for invoices that do not print a currency code:

recipe = recipe.replace(
"recognize",
Worker.router({
"number": Worker.number().with_language("en").hint("default currency USD"),
"date": Worker.date(),
"text": worker,
}),
)

Worker.number() recognises symbols and ISO codes when present; the static hint only kicks in if the crop has neither.

A small reconstruct step that flags arithmetic mismatches:

from decimal import Decimal
from scriva import step
@step(phase="reconstruct", name="tax-sanity")
async def tax_sanity(ctx):
inv = ctx.fields
expected = (inv.subtotal or Decimal(0)) + (inv.tax_amount or Decimal(0))
if inv.total_amount and abs(expected - inv.total_amount) > Decimal("0.02"):
inv.notes = (inv.notes or "") + " [WARN: subtotal+tax != total]"
return ctx
recipe = recipe.insert_after("recognize", tax_sanity)
worker = worker.cache(".scriva_cache")

Invoice templates from the same vendor look almost identical month over month. Pair an exact + semantic cache via Cache.layered — 30–60% hit rates on real-world vendor mixes are routine, and invoice runs are where the cache pays for itself fastest.

recipe = recipe.options(max_cost_usd=0.10)
result = recipe("huge.pdf")

Raises BudgetExceeded if a single invoice exceeds your per-doc cap; the partial result is attached to the exception so you can ledger it.

worker = worker.retry(times=4, backoff="expo")

VLM 429s and transient 5xx are retried; auth and validation errors are not. See Reliability.

import scriva
results = scriva.batch(
sources=Path("incoming/").glob("*.pdf"),
recipe=recipe,
max_concurrency=8,
on_error="continue",
resume_file=".scriva_invoices.json",
)

Re-running picks up where it left off; partial results flush every N items (flush_every=10 default).

result = recipe("acme.pdf")
if result.mean_confidence >= 0.92 and not result.low_confidence(0.7):
post_to_erp(result.to_dict()["fields"])
else:
queue_for_human_review(result)

low_confidence(0.7) returns regions below that confidence — empty means the run is all-green and safe to auto-post.

recipe = recipe.review.hitl(
when=lambda r: (r.confidence or 0) < 0.7,
sidecar="review.json",
)

.review.hitl(...) pauses the recipe; the sidecar JSON matches the shape of result.to_dict() so a UI can round-trip edits losslessly. See Orchestrator › Review.

Drop down further — every step is replaceable:

recipe = (
Orchestrator()
.orient()
.deskew()
.split.boxes(detector="invoice")
.classify(blank=True, merged=True)
.recognize(
Worker.escalate(
primary=Worker.openai("gpt-4o-mini")
.cache(".scriva_cache")
.score(method="rendering"),
oracle=Worker.openai("gpt-4o"),
when=lambda r: (r.confidence or 0) < 0.7,
)
)
.reconstruct.kv(schema=MyInvoice)
.export.json("out/{stem}.json")
.export.xlsx("out/{stem}.xlsx")
)

Cheap-first / oracle-second on a per-cell basis; two exports in one run. Every method swaps cleanly into the chain.