Cookbook: Bank statements
Cookbook: bank statements
Section titled “Cookbook: bank statements”Bank statements are the worst case of “table that wraps across many pages with a different header on each page.” The recipe handles the stitching; this page is mostly about telling you which knobs to turn when your bank’s format goes off-script.
The recipe
Section titled “The recipe”from scriva import Orchestrator, Workerfrom scriva.prompts import Prompt
worker = ( Worker.openai("gpt-4o") .prompt(Prompt.bank_statement) .cache(".scriva_cache") .score(method="rendering"))
recipe = ( Orchestrator() .orient() .deskew() .split.multi_page_table() # detects header / footer per page, stitches rows .classify(blank=True, merged=True) .recognize.by_kind({ "text": worker, "number": Worker.number(), "date": Worker.date(), "blank": Worker.skip(), }) .reconstruct.table( columns=["posted_date", "description", "amount", "balance"], reconcile="opening + Σ == closing", # annotates notes on mismatch ) .export.json("chase.json"))
result = recipe("chase-2026-05.pdf")stmt = result.to_dict()["fields"]print(stmt["account_number_last4"], stmt["statement_period_start"], stmt["statement_period_end"])print(stmt["opening_balance"], "->", stmt["closing_balance"])for tx in stmt["transactions"]: print(tx["posted_date"], tx["amount"], tx["description"]).split.multi_page_table() detects column splits per page and
stitches rows by column alignment. .reconstruct.table(reconcile=...)
checks opening_balance + Σ transactions == closing_balance and
annotates stmt.notes on failure.
What you get back
Section titled “What you get back”class BankStatement(BaseModel): institution: str # "Chase", "Wells Fargo", "Mitsubishi UFJ", … account_number_last4: str account_type: Literal["checking", "savings", "credit", "loan", "investment"] currency: str statement_period_start: date statement_period_end: date opening_balance: Decimal closing_balance: Decimal transactions: list[Transaction] fees: list[Fee] | None notes: str | None
class Transaction(BaseModel): posted_date: date value_date: date | None amount: Decimal # signed: positive = credit, negative = debit running_balance: Decimal | None description: str category: str | None counterparty: str | None reference: str | None page_index: int row_index: intcategory and counterparty are derived from description by the
reconstruct step. To disable:
recipe = recipe.replace( "reconstruct", reconstruct.table(columns=[...], derive_categories=False),)Customising
Section titled “Customising”Cross-page table stitching
Section titled “Cross-page table stitching”The default stitcher matches columns by horizontal alignment and
treats mid-row continuation glyphs (…, →) as joins. For statements
with unusual column orders (e.g. amount on the left), give it a hint:
recipe = recipe.replace( "split", split.multi_page_table( column_order=["posted_date", "description", "amount", "balance"], header_height_px=80, # skip at the top of each page footer_height_px=60, # …and bottom ),)See Detectors › multi_page_table.
Per-bank prompts
Section titled “Per-bank prompts”The default Worker uses a generic prompt. For banks with idiosyncratic layouts (e.g. Japanese postal-savings books), pin one:
from scriva.prompts import Prompt
Prompt.register( "bank-jp-yucho", """通帳画像から取引行を抽出してください。列: 年月日 / 摘要 / お支払金額 / お預り金額 / 差引残高""", locale="ja",)
recipe = recipe.replace( "recognize", Worker.openai("gpt-4o").prompt(Prompt.from_registered("bank-jp-yucho")),)Reconciliation gate
Section titled “Reconciliation gate”The default behaviour annotates stmt.notes on a mismatch. To raise
instead:
from decimal import Decimal
recipe = recipe.replace( "reconstruct", reconstruct.table( columns=[...], reconcile="opening + Σ == closing", on_mismatch="raise", tolerance=Decimal("0.01"), ),)on_mismatch="re-ocr" re-runs the low-confidence rows through a
second-pass Worker (the same shape as
Orchestrator › Confidence-driven re-OCR).
Foreign-currency surcharges
Section titled “Foreign-currency surcharges”Credit-card statements with FX transactions print three fields per foreign charge: the local amount, the converted amount, and the exchange rate. The default reconstructs them as separate rows; to collapse them, add a small step:
from scriva import step
@step(phase="reconstruct", name="merge-fx-rows")async def merge_fx_rows(ctx): stmt = ctx.fields merged = [] i = 0 while i < len(stmt.transactions): tx = stmt.transactions[i] if i + 2 < len(stmt.transactions) and "FX" in (tx.description or ""): local, conv, rate = stmt.transactions[i:i+3] tx = tx.copy(update={ "amount": conv.amount, "description": f"{tx.description} (rate {rate.amount})", }) i += 3 else: i += 1 merged.append(tx) stmt.transactions = merged return ctx
recipe = recipe.insert_after("reconstruct", merge_fx_rows)Production notes
Section titled “Production notes”Cost scales with page count
Section titled “Cost scales with page count”A 30-page statement with 10 transactions per page is a more expensive run than a 5-page invoice. Two levers:
# Cache aggressively — headers/footers repeat per page.worker = worker.cache(Cache.layered(".scriva_cache"))
# Cap total cost per statement.recipe = recipe.options(max_cost_usd=0.30)Confidence per transaction
Section titled “Confidence per transaction”The standard pattern: auto-accept high-confidence rows; queue low-confidence rows for human review. Inside the recipe:
recipe = recipe.review.hitl( when=lambda r: (r.confidence or 0) < 0.85, sidecar="review.json",)Or after the run:
result = recipe("chase.pdf")auto, review = [], []for tx_region in result.regions_with_role("transaction"): (auto if (tx_region.recognition.confidence or 0) >= 0.85 else review).append(tx_region)Deterministic batch ingest
Section titled “Deterministic batch ingest”import scriva
scriva.batch( sources=Path("statements/").glob("*.pdf"), recipe=recipe, max_concurrency=4, resume_file=".scriva_statements.json", on_error="collect", max_cost_usd=20.0, # batch-wide cap)on_error="collect" returns the list of (source, exception) pairs
that failed so you can re-process them with a different prompt or
model.
Compliance & redaction
Section titled “Compliance & redaction”Statements contain account numbers, addresses, full transaction
histories — high-sensitivity data. Follow the redaction
pattern and consider running an
on-premise Worker (Worker.tesseract() or Worker.paddle() for
typed-statement rows) for the parts you can.
What to read next
Section titled “What to read next”- Worker — engines, decorators, composition.
- Cookbook › Long PDFs — when statements get truly huge.
- Detectors › multi_page_table — cross-page table stitching.
- Orchestrator › Reconstruct — reconciliation primitive.