Tax-doc review agent
Produkcyjny potok LangGraph: wyciąga pola faktury, waliduje je deterministycznie, zatrzymuje się na zatwierdzenie człowieka i wznawia z trwałego punktu kontrolnego w Redis.
Jak to działa
Problem
Faktury wprowadza się ręcznie, a każda pomyłka w NIP-ie albo w kwocie VAT wraca jako korekta. Walidacja przez model językowy jest nieszczelna: ten sam dokument raz przechodzi, raz nie, a po restarcie procesu cały stan znika. Potrzebny był potok, który wyciąga pola, sprawdza je regułami — nie modelem — i potrafi stanąć w miejscu, żeby ktoś decyzyjny rzucił okiem, a potem wznowić dokładnie tam, gdzie stanął.
Rozwiązanie
Potok LangGraph z walidacją deterministyczną: wyciąganie pól przez Pydantic, sprawdzanie NIP-u i matematyki VAT przez serwer MCP, a zatrzymanie na decyzję człowieka przez dynamiczne interrupt(). Punkt kontrolny siedzi w Redis, więc po restarcie procesu ten sam wątek wznawia się z tego samego miejsca. Zatwierdzenie przychodzi przez REST — POST /review z poprawkami — i tylko wtedy dokument trafia do księgi.
Co robi
- Wyciąganie pól faktury przez Pydantic structured output
- Walidacja NIP-u i matematyki VAT przez serwer MCP (nip_check, vat_math_check)
- Dynamiczne interrupt() — pauza tylko przy niskim ufnościu, błędnej regule lub wysokiej kwocie
- Wznowienie po restarcie procesu z punktu w Redis
- Zatwierdzenie decyzji przez REST: POST /review
- Hermetyczne testy na FakeLLM, Docker, mypy, Ruff
Na czym zbudowane
- FastAPI → DocumentService → LangGraph StateGraph
- Węzły: ingest → extract → validate → route → human_review → finalize
- Walidacja asynchronicznie przez klienta MCP do serwera stdio
- RedisSaver zapisuje punkt kontrolny na thread_id
- Bearer auth w REST, poprawne odpowiedzi 4xx
- Docker Compose (Redis 8), Dockerfile, Makefile, GitHub Actions
Sesja REST (HITL)
# 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" } }Co to zmieniło
- ≥15 hermetycznych testów pytest-asyncio na FakeLLM (CI bez dostępu do LLM)
- Redis 8 jako punkt kontrolny: wznowienie po restarcie procesu bez utraty stanu
- Docker Compose (Redis 8), Dockerfile, Makefile, Ruff, mypy, GitHub Actions w pipeline
Stack
- Python
- LangGraph
- Redis checkpointer
- interrupt()/HITL
- MCP server
- FastAPI
- Pydantic
- pytest-asyncio
- Docker
- GitHub Actions
Historia
InMemorySaver ginie po wyjściu procesu; RedisSaver trzyma punkty kontrolne według thread_id, więc inny obiekt grafu wznawia ten sam dokument po restarcie. Użyto dynamicznego interrupt(), bo przegląd jest warunkowy — interrupt_before zatrzymałby każde uruchomienie i nie niósłby konkretnego ładunku JSON. Finalizacja używa deterministycznego klucza ledger-{thread_id}, więc powtórzony ostatni krok jest bezpieczny do uzgodnienia.