Combining Vision and Tool Use for Document-Processing Agents
A document-processing agent turns messy visual inputs (scans, photos, PDF page renders) into structured records, then uses tools to validate, enrich, and submit those records.
Vision alone is not enough for production.
You need schemas, validators, retries, and systems-of-record tools with clear authority boundaries.
Render or load page images, extract JSON with a multi-modal model against a strict schema, validate in code, repair with a second vision or text pass when needed, then call write tools only after checks pass.
Conceptual pipeline (provider-agnostic; verify vision APIs at build):
import base64import jsonfrom enum import Enumfrom pathlib import Pathfrom pydantic import BaseModel, Field, ValidationError, field_validatorclass DocType(str, Enum): invoice = "invoice" receipt = "receipt" unknown = "unknown"class InvoiceExtract(BaseModel): vendor: str invoice_number: str invoice_date: str # ISO date preferred currency: str = Field(min_length=3, max_length=3) subtotal: float tax: float total: float line_items: list[dict] = [] @field_validator("total") @classmethod def total_nonneg(cls, v: float) -> float: if v < 0: raise ValueError("total must be >= 0") return vdef image_data_url(path: str) -> str: b64 = base64.b64encode(Path(path).read_bytes()).decode("ascii") return f"data:image/png;base64,{b64}"def vision_json(client, model: str, prompt: str, data_url: str) -> str: resp = client.chat.completions.create( model=model, messages=[{ "role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": data_url}}, ], }], max_tokens=1200, ) return resp.choices[0].message.content or ""def extract_invoice(client, model: str, path: str, max_repairs: int = 2) -> InvoiceExtract: data_url = image_data_url(path) prompt = ( "Extract invoice fields as JSON with keys: vendor, invoice_number, " "invoice_date, currency, subtotal, tax, total, line_items. " "Use ISO dates. Numbers as JSON numbers. No markdown." ) last_err = None text = vision_json(client, model, prompt, data_url) for _ in range(max_repairs + 1): try: data = json.loads(text) inv = InvoiceExtract.model_validate(data) # Business rule example if abs((inv.subtotal + inv.tax) - inv.total) > 0.05: raise ValueError("subtotal + tax != total") return inv except (json.JSONDecodeError, ValidationError, ValueError) as e: last_err = e text = vision_json( client, model, prompt + f"\nPrevious output failed validation: {e}\nReturn corrected JSON only.", data_url, ) raise RuntimeError(f"extract failed: {last_err}")# Tools the agent may call after validationdef tool_lookup_vendor(vendor: str) -> dict: return {"ok": True, "vendor_id": "V-104", "normalized_name": vendor.strip().title()}def tool_submit_invoice(payload: dict) -> dict: # Only call when host sets validated=True return {"ok": True, "erp_id": "INV-9001"}
Agent policy sketch:
def run_doc_agent(client, model: str, image_path: str): inv = extract_invoice(client, model, image_path) vendor = tool_lookup_vendor(inv.vendor) record = {**inv.model_dump(), **vendor, "validated": True} # Human gate for amounts over threshold would sit here return tool_submit_invoice(record)
For multi-page PDFs, loop pages, extract per page or per doc, and merge with explicit rules (header on page 1, line items on following pages).
Models may not emit calibrated confidence.
Proxy signals: validator pass, arithmetic checks, vendor lookup hit, second-model agreement.
Send low-trust cases to human review queues rather than auto-posting to ERP.
Should extraction be a tool or an inline model call?
Either works. A dedicated extract_document tool keeps the agent loop clean and lets you swap models without changing the planner.
How many repair retries are sensible?
One or two with validator feedback. More usually needs a different model, higher resolution crop, or a human.
What if the PDF already has a text layer?
Try text extraction first. Fall back to vision when text is empty, garbage, or layout-critical.
How do I handle handwriting?
Use a model known for handwriting, increase resolution crops on signature/amount regions, and route low-trust fields to humans.
Can I batch multiple docs in one model call?
Possible but harder to validate. Prefer one doc (or one page) per extract for cleaner retries and audits.
How should line items be represented?
Arrays of objects with explicit keys (description, qty, unit_price, amount). Reject silent string blobs.
Where do prompts live?
Version prompts next to schemas in git. Pin extract_version on every stored result.
How do I prevent double submit?
Idempotency keys from content hashes (vendor + invoice_number + total) and ERP-side dedupe.
Is function calling required?
For multi-step enrichment and submit, yes in practice. Single-shot extract can be a plain vision call behind an API.
What metrics matter?
Field-level accuracy, validator pass rate, human escalation rate, cost per doc, and time to submit.
How does this differ from browser agents?
Same multi-modal loop pattern, but tools talk to document stores and ERPs more than to mouse clicks. You can still use Playwright when the "document" only exists behind a web portal.
What about adversarial or fraudulent documents?
Do not auto-pay from letterhead alone. Bind vendors to on-file bank details via separate secure channels and anomaly checks.