← AI Agents & Automation

Tax-doc review agent

A production-style LangGraph pipeline that extracts invoice fields, validates them deterministically, pauses for human approval and resumes from a durable Redis checkpoint.

How it works

resume · Redis checkpoint ingest document in extract Pydantic fields validate MCP NIP + VAT route human_review interrupt() HITL finalize to ledger
resume · Redis checkpoint ingest document in extract Pydantic fields validate MCP NIP + VAT route human_review interrupt() HITL finalize to ledger

Problem

Invoices are keyed in by hand, and any slip in the NIP or the VAT amount comes back as a correction. Validation through a language model leaks: the same document passes one run and fails the next, and on a process restart all state is gone. What was needed was a pipeline that pulls the fields, checks them with rules, not a model, and can halt in place for a human to look, then resume exactly where it stopped.

Solution

A LangGraph pipeline with deterministic validation: field extraction through Pydantic, NIP and VAT maths through an MCP server, and the human decision point through a dynamic interrupt(). The checkpoint lives in Redis, so after a process restart the same thread resumes from where it stopped. Approval arrives over REST (POST /review with edits), and only then does the document reach the ledger.

What it does

  • Invoice fields extracted through Pydantic structured output
  • NIP and VAT maths validated through an MCP server (nip_check, vat_math_check)
  • Dynamic interrupt(): pauses only on low confidence, a failed rule, or a high amount
  • Resume after a process restart from the Redis checkpoint
  • Human decision over REST: POST /review
  • Hermetic tests on FakeLLM, Docker, mypy, Ruff

How it's built

  • FastAPI → DocumentService → LangGraph StateGraph
  • Nodes: ingest → extract → validate → route → human_review → finalize
  • Validation runs async through an MCP client to a stdio server
  • RedisSaver writes the checkpoint per thread_id
  • Bearer auth on the REST API, correct 4xx responses
  • Docker Compose (Redis 8), Dockerfile, Makefile, GitHub Actions

REST HITL session

# example session: commands verbatim from README; output reconstructed
$ curl -X POST http://localhost:8000/documents \
    -H 'Authorization: Bearer dev-token' -H 'Content-Type: application/json' \
    -d '{"content":{"vendor":"Acme Supplies","nip":"5260250995","amount_net":"2000.00","vat_rate":"23","amount_gross":"2460.00","date":"2025-06-15","category":"office","confidence":0.42}}'

# -> 202 Accepted
# { "thread_id": "a1b2c3", "status": "needs_review",
#   "interrupt": { "field": "category", "reason": "low confidence (0.42)",
#                  "current": "office", "allowed": ["professional_services"] } }

$ curl http://localhost:8000/documents/a1b2c3 -H 'Authorization: Bearer dev-token'
# -> 200 OK   (pending interrupt payload still present after a process restart)

$ curl -X POST http://localhost:8000/documents/a1b2c3/review \
    -H 'Authorization: Bearer dev-token' -H 'Content-Type: application/json' \
    -d '{"approved":true,"edits":{"category":"professional_services"}}'

# -> 200 OK
# { "status": "posted",
#   "ledger_entry": { "entry_id": "ledger-a1b2c3", "vendor": "Acme Supplies",
#                     "amount_gross": "2460.00", "category": "professional_services" } }

What it changed

  • ≥15 hermetic pytest-asyncio tests on FakeLLM (CI with no LLM access)
  • Redis 8 as the checkpointer: resume after a process restart with no state lost
  • Docker Compose (Redis 8), Dockerfile, Makefile, Ruff, mypy, GitHub Actions in the pipeline

Stack

  • Python
  • LangGraph
  • Redis checkpointer
  • interrupt()/HITL
  • MCP server
  • FastAPI
  • Pydantic
  • pytest-asyncio
  • Docker
  • GitHub Actions

Story

InMemorySaver dies when the process exits; RedisSaver keeps checkpoints by thread_id, so a different graph object resumes the same document after a restart. Dynamic interrupt() was used because review is conditional: interrupt_before would pause every run and carries no data-specific JSON payload. Finalization uses a deterministic ledger-{thread_id} key, so a retried final step is safe to reconcile.