Ask your receivables ledger a plain-language question — get the prioritized accounts and the governing policy, cited.
▶ Try it live — no signup, no API key: jorgeed-receivables-agent.hf.space
A self-contained Hugging Face Space running its own tiny local model. The one-click example questions answer instantly (cached plans, re-run live — the ledger ones against the ledger, so the numbers are current; the policy ones against the policy document); a typed question runs the tiny model on a free CPU, so it's slower — the UI streams the agent's steps so you can watch it think. That slowness is the honest free-tier floor: the architecture is the product, and the same code shines pointed at a stronger model.
Collections teams burn hours on two questions every day: who do we chase first, and what does our policy allow? Answering them today means writing SQL against the ledger and digging through a policy document — for every account, every week.
receivables-agent answers both in plain language. Ask "which overdue accounts
above $50k should go on credit hold this week, and what's the rule?" and it
returns the prioritized accounts (from the ledger) with the governing
policy, cited — turning an analyst's afternoon of SQL and PDF-hunting into one
question. Under the hood it's a ReAct agent that combines a guarded
text-to-SQL tool over the ledger with retrieval over the collections
policy.
The honesty boundary. A public, clean-room portfolio project on 100% synthetic data — no proprietary code, no real ledger. Shipped and live: 449 offline tests, CI green. The UI is localized (EN/PT-BR), but the agent's answers come from the model and an English policy corpus — they are not machine-translated.
Depth lives in
docs/DECISIONS.md(the ADRs),docs/DEPLOY.md(the live deploy) anddocs/STATE.md(current progress).
One plain-language question → the agent runs guarded SQL over the ledger and
retrieves the governing policy, then answers with the prioritized accounts
and the cited rule. The tools_used badge shows which tools each answer hit.
Recorded against a local Ollama model (qwen2.5:7b); a GEMINI_API_KEY works
the same way.
Ask things like "Which overdue invoices above $50k should we prioritize this week, and what does our policy say about offering a payment plan?" The agent:
- Plans which tools to call (a ReAct loop on LangGraph).
- Queries the receivables ledger through a parser-validated, allow-listed SQL tool — the statement is checked against DuckDB's own syntax tree, on a read-only connection with external access disabled, so the validator and the executor cannot disagree about what the query says (ADR-022).
- Retrieves relevant rules from a collections-policy knowledge base (RAG).
- Answers in natural language, grounded in both sources.
The figures below are real — queried live from the synthetic ledger — and the policy reference is the actual section in
data/collections_policy.md. The agent's exact wording is produced by the LLM at run time; this shows the shape of an answer.
Question
Which overdue accounts above $50k should go on credit hold this week, and what policy governs that decision?
Agent answer
2,719 accounts are overdue above $50k and 60+ days past due. Per the Credit holds policy, an account goes on hold once an invoice is 60+ days past due without a payment commitment (or the balance exceeds the credit limit). Working highest-risk first (the Prioritisation rule — 90+ bucket above $50k), the top accounts are:
- Hensley-Huang (enterprise) — ~$10.07M overdue, 186 days past due
- Serrano-Jenkins (enterprise) — ~$8.74M overdue, 247 days past due
- Sanders-Banks (enterprise) — ~$8.36M overdue, 655 days past due
Source: Credit holds and Prioritisation rules sections of the collections policy.
Tools used: query_ledger (guarded SQL over the ledger) ·
search_policy (retrieval over the collections policy)
100% generated locally (Faker for the customer dimension + set-based DuckDB for the facts), so the project is clean-room and the figures are reproducible from a fixed seed:
| Metric | Value |
|---|---|
| Invoices | 1,057,402 |
| Payments | 923,200 |
| Dunning communications | 304,773 |
| Customers | 13,000 |
| Aging buckets | 5 — current / 1–30 / 31–60 / 61–90 / 90+ |
| Reporting (as-of) date | 2026-06-30 |
It carries signal, not noise. Every customer gets a payment-behaviour profile that drives a realistic overdue rate, so aging and DSO are meaningful and the agent can actually find the slow payers:
| Behaviour profile | Overdue rate |
|---|---|
| prompt | 0.5% |
| reliable | 1.7% |
| slow | 7.5% |
| delinquent | 19.1% |
| defaulter | 49.6% |
On this ledger that totals ~$1.47B overdue out of ~$2.98B outstanding, with a trailing-90-day DSO ≈ 78 days — the kind of numbers the agent computes on demand from natural-language questions.
(synthetic data) ──► DuckDB ledger
│
(collections policy) ─► ChromaDB (RAG)
│
LangGraph ReAct agent
guarded text-to-SQL + policy retrieval
dual-provider LLM (cloud / local) with fallback
│
FastAPI service
│
React chat UI
See docs/ARCHITECTURE.md for detail.
- Agent: LangGraph (ReAct), dual LLM provider with active fallback
- Data: synthetic generator (Faker + DuckDB), ~1M+ invoices
- Retrieval: ChromaDB, idempotent indexing
- API: FastAPI (async lifespan, API-key auth, Pydantic v2)
- Web: React chat UI
- Tooling: Docker / Compose, pytest, an MCP server, a Claude Code skill
cp .env.example .env # then set GEMINI_API_KEY (and APP_API_KEY) in .env
docker compose up --buildOpen http://localhost:8000. The image builds the React UI, installs the API,
generates the synthetic ledger at build time, and serves the UI + API from a
single container. The demo uses the cloud provider (Gemini); set GEMINI_API_KEY
in .env. APP_API_KEY guards the API and is baked into the UI build so the
same-origin browser can authenticate (keep the two equal).
# 1) API — Ollama primary by default (run `ollama serve` + pull a tool model),
# or set PRIMARY_PROVIDER=gemini + GEMINI_API_KEY in .env.
pip install -e ".[ollama,gemini,data,dev]"
python data/generate.py # build data/ledger.duckdb once
uvicorn src.api.app:app --reload # http://localhost:8000
# 2) Web — Vite dev server, proxies /api to the API above.
cd web && npm install && npm run dev # http://localhost:5173GET /api/health— liveness + whether the agent is built (open, no key).POST /api/chat—{ "message": "...", "history": [...] }with anX-API-Keyheader →{ "reply": "...", "tools_used": ["query_ledger", ...] }. Interactive docs at/docs.
pip install -e ".[dev]"
pytest # offline: SQL guardrail, RAG, API, MCP, evals, plan-cache, turn-control (449 tests)Beyond the app, the ledger and the agent are built to be operated by other AI tools — the surface AI-native teams care about:
- MCP server (
mcp_server/) — exposes the ledger to any MCP (Model Context Protocol) client (Claude Code, Claude Desktop, other agents) as aquery_ledgertool, running the same read-only guardrail as the app, so a new surface is never a weaker one.python -m mcp_server.server. - Eval suite (
evals/) — golden questions scored by properties (used the right tool, cited the policy keyword, stated the right number within tolerance) rather than brittle string matching; numeric expectations are computed from the ledger.python -m evals.rungates a regression with a non-zero exit. The pure checks are unit-tested offline. - Claude Code skill (
.claude/skills/eval-agent/)CLAUDE.md— the conventions and the eval-runner skill that let an AI collaborator work in this repo productively.
On a small/free model the LLM is the slow part of a turn, and demo visitors ask overlapping questions. A naive answer-cache would be fast but wrong: a frozen number lies the moment the ledger changes. So this caches the question → plan — the agent's guard-validated tool calls (which SQL, which policy lookup) — and re-executes it live on every hit:
- A semantically similar question hits the cache (cosine similarity over the same local MiniLM embeddings the RAG index uses — no new dependency).
- On a hit the cached SQL is re-validated through the guardrail and run read-only again, so the number always reflects the current data; only the LLM's reasoning is skipped. Regenerate the ledger and the answer updates.
- Only read-only, guard-valid plans are ever cached; a conservative similarity threshold means a miss simply falls through to the LLM.
- A question that sits between two different plans is also a miss. Being close enough is not enough — it has to be closer to one plan than to the next by a margin, or the LLM answers it (ADR-009 Amendment 2026-08-01).
It's caching the reasoning, not the output — a fast path that can't go stale.
See ADR-009, tests/test_plan_cache.py
(the freshness test mutates the ledger and proves the replayed number moves) and
tests/test_plan_routing.py, which measures the
routing with the production embedding rather than the offline stand-in.
The same code should scale from a free CPU tier to a workstation with a GPU without hand-tuning. Two pieces make the local path do that automatically:
- Hardware-aware model selection (
src/core/hardware.py, ADR-010).OLLAMA_MODEL=autodetects RAM / VRAM (nvidia-smi) / CPU, computes an effective memory budget (VRAM on a GPU box, else ~80% of RAM) and picks the best-fitting already-downloaded model from a public catalog.python -m src.core.hardwareprints the pick for your machine;--selectemits anOLLAMA_MODEL=…line a container entrypoint can consume. No new dependency — psutil is optional, with stdlib fallbacks. - Grammar-constrained tool-calls for tiny models
(
src/agent/constrained.py, ADR-011). A 0.5–1.5B model can't tool-call natively — measured, not assumed: one emits valid JSON but invents argument fields and omits the realsql; another writes correct SQL in prose and emits no tool call at all. So the local model's reply is constrained to a{tool, sql|query, answer}schema (Ollama'sformat, a GBNF grammar underneath) and translated back intotool_calls. Across the five golden questions this moves both tiny models from ≤1/5 to 5/5 well-formed calls — n=5, one run per case, so read it as a demonstration rather than a measurement. It's tier-gated (reusing the hardware catalog): tiny models get the shim, stronger models keep native tool-calling — constraining a capable model would only hold it back.formatfixes the structure; SQL quality still scales with the model — which is the whole point: the architecture is reliable at 0.5B, and it shines with a better model.
A tiny model on a free CPU sometimes over-thinks a novel question — re-calling a
tool it already ran, or burning its step budget without converging. The failure
mode to avoid is the worst first impression: hitting the ceiling and dead-ending
on a generic apology that reads as "I just don't work." Four seams
(src/agent/turn_control.py, ADR-014)
make it degrade gracefully instead:
- Redundant-call dedup. An identical tool call within a turn is served from a
per-turn memo with a firm nudge, never re-executed — not a bigger budget, just
not squandering the budget it has. (Turn state lives in a
ContextVar, so the one process-wide agent stays concurrency-safe.) - Forced finalization. If the loop still hits the ceiling, the agent answers from what it actually gathered — the latest successful ledger query, a policy finding, and the specific gap plus a narrowed next step ("I pulled the overdue list but didn't rank it — ask me to sort by amount") — composed deterministically, never a fabricated number, instead of the canned apology.
- Progress narration. Each step streams a human line ("checking the collections policy on…" → "found 12 rows in the ledger"), so a longer run is visible and productive, not a frozen spinner.
- One cap split into two guards. Narration makes a longer wait tolerable, so the silent path keeps a tight step cap while the narrated streaming path gets a higher cap plus a soft wall-clock budget — a loop guard (steps) and a wait guard (seconds), instead of one number doing both jobs badly. The cap only rises because it ships with dedup + finalization + narration; raising it alone would just recreate a silent thrash.
I architect and review every line; AI accelerates the implementation. The
CLAUDE.md conventions, the decision log
(docs/DECISIONS.md) and the volatile-context file
(docs/STATE.md) are the infrastructure I maintain to keep an
AI collaborator productive without losing context across sessions — the same
discipline a fast-moving team needs. The engineering judgment (the data model,
the SQL guardrail, the dual-provider fallback, the test strategy) is mine; the
tooling just makes me faster.
MIT — see LICENSE.

