InsightsAutomation
Automating back-office document workflows with LLMs
How to turn invoices, contracts and forms into validated structured data with a language model, a JSON schema, deterministic checks and a human review queue that catches what the model gets wrong.
Published May 12, 20266 min read
Most back offices still have a step where a person reads a PDF and types numbers into another system. Invoices go into the ERP, supplier onboarding forms go into the vendor master, signed contracts get their renewal dates copied into a spreadsheet. Template-based OCR handled the documents that always look the same. Everything else stayed manual.
Language models change the economics of that long tail. A model can read an invoice it has never seen before and return the supplier, the totals and the line items. What it cannot do on its own is guarantee that the answer is right. The engineering work is in everything around the model: the schema, the checks, the queue for doubtful cases and the record of who decided what.
The shape of the pipeline
- Ingest. Documents arrive by email, upload or scanner. Store the original file unchanged and record its hash.
- Extract. Send the document, text or page images, to the model with a fixed JSON schema it must fill.
- Validate. Run deterministic rules on the result: types, arithmetic, lookups against reference data.
- Route. Documents that pass every check go straight through. Everything else goes to a person.
- Post. Write the approved record to the system of record, with a link back to the source document.
The model is one step in five. Treat it as a fast, inexpensive first reader whose output is always checked, never as the decision maker.
Extract to a schema, not to prose
Ask for structured output and enforce it. Most hosted model APIs can now constrain a response to a JSON schema, either through a structured output mode or by defining a tool whose input schema is the record you want. Either way, the schema is the contract between the model and the rest of the pipeline.
Define the schema once, in code, and generate the JSON schema from it. Pydantic does this well:
from datetime import date
from decimal import Decimal
from pydantic import BaseModel, Field
class LineItem(BaseModel):
description: str
quantity: Decimal
unit_price: Decimal
amount: Decimal
class Invoice(BaseModel):
supplier_name: str
supplier_vat_id: str | None = Field(
default=None, description="VAT or tax ID exactly as printed"
)
invoice_number: str
invoice_date: date
purchase_order: str | None = None
currency: str = Field(description="ISO 4217 code, such as EUR or USD")
line_items: list[LineItem]
net_total: Decimal
tax_total: Decimal
gross_total: Decimal
INVOICE_SCHEMA = Invoice.model_json_schema()A few choices in that schema matter more than they look:
- Nullable fields for things that may be missing. If a purchase order number is required, the model will sometimes invent one. Let it say "not present" and handle that in validation.
- Field descriptions are instructions. "Exactly as printed" discourages the model from normalizing or correcting a tax ID.
- Decimal, not float, for money. Parse model output with
Invoice.model_validate_json(...)so that malformed output fails loudly at the boundary.
Keep the prompt short and versioned alongside the schema. Tell the model to copy values rather than infer them, and to leave a field empty when the document does not contain it.
Validation does the real work
Schema validation only proves the output has the right shape. Business rules prove it is plausible. Write them as plain functions that return named failures:
from decimal import Decimal
TOLERANCE = Decimal("0.02")
def check_invoice(inv, suppliers, open_pos) -> list[str]:
failures = []
lines = sum((li.amount for li in inv.line_items), Decimal(0))
if abs(lines - inv.net_total) > TOLERANCE:
failures.append("line_items_do_not_sum_to_net")
if abs(inv.net_total + inv.tax_total - inv.gross_total) > TOLERANCE:
failures.append("net_plus_tax_not_gross")
supplier = suppliers.match(inv.supplier_vat_id, inv.supplier_name)
if supplier is None:
failures.append("unknown_supplier")
if inv.purchase_order and inv.purchase_order not in open_pos:
failures.append("po_not_open")
if suppliers.seen_invoice(supplier, inv.invoice_number):
failures.append("possible_duplicate")
return failuresArithmetic checks catch a surprising share of extraction errors, because a misread digit rarely leaves the totals consistent. Lookups against master data catch the rest: a supplier that does not exist in the vendor master, a purchase order that is already closed, an invoice number already paid.
Confidence comes from checks, not from the model
It is tempting to ask the model how confident it is. Self-reported confidence from a language model is poorly calibrated, and a model that has hallucinated a value will often report it with high confidence. Use signals you can verify instead:
| Signal | What it tells you |
|---|---|
| All validation rules pass | Totals are consistent and references exist |
| Match against master data | The supplier and purchase order are real |
| Two extractions agree | A second run, or a second model, returned the same key fields |
| Value found in the source text | The extracted string appears verbatim in the OCR or text layer |
| Document type seen before | The supplier's layout has a track record of clean extractions |
Combine them into a simple routing rule: straight-through processing only when every hard check passes and the key fields are corroborated. Start strict, so that most documents go to review in the first weeks, and loosen the rule per supplier or document type as the review data shows where the model is reliable.
Design the review queue as a product
Reviewers spend their day in this queue, so its design decides whether the system saves time. A useful review screen shows:
- The source document next to the extracted fields, with each field highlighted where it was found.
- The failed checks by name, at the top, so the reviewer knows why the document is here.
- Editable fields with the model's value prefilled. Correcting one field is faster than retyping a form.
- Approve, correct and reject actions, and a required reason for rejection.
Every correction is labeled data. Store the model's value and the reviewer's value side by side. That dataset becomes your evaluation set for prompt changes and model upgrades. Before switching models, run the new one on the last few hundred reviewed documents and compare field-level accuracy against what reviewers approved.
Keep an audit trail
Finance and compliance teams will ask why a payment was made. The answer should be reconstructable from stored records, not from memory. For each document, record:
- The original file and its hash.
- The model name and version, prompt version and schema version used.
- The raw model output, before any parsing.
- Each validation result.
- Who reviewed it, when, what they changed, and the final posted record.
Treat this log as append-only. If you reprocess a document with a newer model, add a new extraction record instead of overwriting the old one.
Where it should not be used
Language model extraction is a poor fit in some places:
- Decisions with legal or financial effect on individuals, such as approving a loan or rejecting a claim. Extraction can prepare the case. A person, or an explainable rule, should decide.
- Fixed, high-volume forms. If every document has the same layout, template OCR or a direct integration with the sender is cheaper, faster and fully deterministic.
- Fields you cannot check. If no total, reference or second source can corroborate a value, every document needs human review, and the automation adds less than it seems.
- Handwriting and poor scans where even a person struggles. Fix the input channel first.
Where to start
- Pick one document type with real volume and a clear system of record, usually supplier invoices.
- Collect a few hundred historical documents with their posted values as a ground-truth set.
- Define the schema in code and measure field-level accuracy against that set before building anything else.
- Write the validation rules and run every document through the review queue at first.
- Turn on straight-through processing for the suppliers and fields where reviewed accuracy is consistently high.
- Rerun the evaluation set before any change to the prompt, schema or model.