Architecture

A LangGraph agent drives four MCP servers. Every model call goes through one cost-aware gateway; every step is written to a trace the UI streams live.

System diagram

Browser · Next.js
landing, live trace, review form, ask, evals
HTTPS · JSON + server-sent events · session cookie
FastAPI
sessions, uploads, rate limits, budget checks, SSE from the trace table, replay
starts / resumes graph runs
LangGraph agent
per-node tool allowlists · Postgres checkpointer · trace of every step
  1. classify
  2. extract
  3. validate
  4. reextract
  5. human_review
  6. validate_final
  7. save
  8. po_match
  9. summarize

ask: plan SQL → run_select → (fix SQL ≤ 2×) → answer

LLM gateway
LiteLLM · default + strong tier · retries · daily, session and eval budget caps · cost ledger
Gemini · default tierGemini · strong tier
MCP over Streamable HTTP · private network · shared secret
extraction
vision LLM via gateway
validation
no LLM
sqlstore
no LLM
reference
no LLM
SQL (sqlstore, API, checkpointer, ledger)
Postgres 16
row-level security per session · read-only reader role for model SQL · LangGraph checkpoints · trace events · cost ledger
The browser talks to a FastAPI service, which runs a LangGraph agent. The agent calls four MCP servers (extraction, validation, sqlstore, reference) and makes model calls only through the LLM gateway. Data lives in Postgres with row-level security.

MCP servers

Streamable HTTP on a private network, each request authenticated with a shared secret. Never exposed publicly.

extraction

LLM via gateway:8101

Reads the document with a vision model through the gateway. Every value comes back with a confidence, source page and evidence snippet. The model gets no tools, so text in a document cannot trigger actions.

  • classify_documentpurchase order, invoice or unknown
  • extract_fieldstyped record with per-field confidence
  • reextract_fieldsre-read only the flagged fields (strong tier)

validation

no LLM:8102

Deterministic checks and the PO ↔ invoice two-way match in pure Python: no LLM, no database, no network. The same input always gives the same answer.

  • validate_recordarithmetic, dates, formats, low confidence
  • match_invoice_to_poline pairing, price/qty tolerance, remaining value
  • list_rulesthe rule catalogue

sqlstore

no LLM:8103

Postgres access. Reads go through a SELECT-only pipeline under a read-only role with row-level security and a timeout; writes only through typed, parameterised tools.

  • get_schematables and columns the reader can see
  • run_selectone guarded SELECT, row-capped
  • save_recordtyped insert of a reviewed record
  • get_purchase_orderPO plus earlier invoices against it
  • save_match_resulttyped insert of a match result

reference

no LLM:8104

Static reference data bundled with the server: vendor master, illustrative FX rates and tax rates. No external APIs, so the demo has no live dependency here.

  • lookup_vendorfuzzy name / tax-ID match against the vendor master
  • convert_currencystatic illustrative rates
  • lookup_tax_ratestandard / reduced VAT and sales tax

Key design decisions

Deterministic validation, not an LLM judge

Arithmetic, dates, formats and PO matching are plain Python rules with tests. They are cheap, reproducible and auditable, and a model cannot talk its way past them. The LLM only reads the document; deciding whether the numbers add up is code.

Selective re-extraction instead of full retries

When validation flags fields, only those field paths go back to the stronger (and pricier) model tier. Most documents never escalate, and the ones that do pay for a few fields, not a whole page. If the strong tier still isn’t sure, a human decides.

Row-level security for isolation

Every visitor gets an anonymous session; every query runs with the session id set and Postgres RLS policies filter by it. Model-written SQL runs as a separate read-only role with a statement timeout, so even a bad query can neither see another session’s data nor change anything.

Replay mode

The six samples have recorded runs. When the daily LLM budget is used up or live processing is off, the demo replays them with their original timing instead of failing — the public site keeps working at zero marginal cost.

MCP servers as separate processes

Each capability is its own service with its own credentials: of the tools, only sqlstore touches the database and only extraction sends a document to a model. The agent gets per-node tool allowlists (classify and extract cannot reach sqlstore), so a prompt-injected document has nothing dangerous to call.

What changes on-prem

Swap the hosted model for a vision-language model on a local GPU behind the same gateway, and drop all external APIs. The MCP boundaries, validation, review flow, RLS and evals stay as they are; SSO, an audit log and PII redaction would be added.