Case study
A personal finance manager where the AI never invents a number: every figure it cites comes from code running against real data, every AI feature has a non-AI fallback, and the whole app works with zero API keys.
Ask a chatbot "how much did I spend on food last month?" and it will give you a confident answer — a made-up one, because language models are unreliable at arithmetic and have no idea what's in your bank account. That failure mode is why most "AI finance" demos fall apart the moment you check their numbers against the source.
Ledgr is my answer to that: a self-hosted money tracker where you load your transactions, an AI sorts them into categories, writes you a plain-English spending summary, and answers questions about your money — under one hard rule. The model is never allowed to calculate anything. It can only report numbers my code actually computed.
The core of the project is finance_ai/assistant.py. When you ask a question, the model isn't handed your transactions — it's handed four tools. The model decides which to call and with what arguments; my code executes them against the data in pandas and feeds the results back; the loop runs at most five rounds before it bails with "try a more specific question." That cap exists because an unbounded loop with a confused model burns money and hangs the request.
This is function calling, not RAG — there's no embedding or retrieval index anywhere. The README calls it "similar to a tiny RAG-over-SQL setup" as an analogy, but the tools actually query a pandas DataFrame, and the honest term is tool-calling. I'd rather make that distinction myself than have an interviewer make it for me.
The rest of the flow is deliberately boring: app.py is a single Flask app holding every page route and JSON endpoint; finance_ai/db.py wraps a plain SQLite file with two tables and parameterized queries; finance_ai/categorize.py asks the LLM to pick one of 12 fixed categories per transaction; finance_ai/insights.py writes the monthly summary. A Chart.js dashboard, a transactions table with CSV export, and a chat UI sit on top.
The summary feature splits one job into two along the line of what each side is good at. In insights.py, pandas computes every hard number — income, expenses, net, top-5 categories — and the LLM's only task is turning that stats dict into three to five sentences of prose with savings suggestions. Deterministic code for numbers, model for language. The model narrates; it never calculates.
The assistant's dollar figures match the dashboard exactly — not because the model is careful, but because it was never given the chance to be careless.
Every AI feature in Ledgr has a non-AI path underneath it. If no backend is configured — or an LLM call throws — categorization falls back to a keyword rule table in categorize.py ("starbucks" → Food & Dining), the summary becomes a formatted stats readout, and the assistant returns a friendly setup message. One check — is_configured() in llm_config.py — gates every AI path, and the entire app is usable with zero API keys — degraded, never broken.
The backend itself is swappable in finance_ai/llm_config.py: OpenAI's gpt-4o-mini, or a free local model via Ollama's OpenAI-compatible endpoint. Because Ollama mimics OpenAI's API shape, the switch is two environment variables — the rest of the code never knows which brain is live. That kills the portfolio-project failure mode where the demo dies the day the API credit runs out.
| what | count |
|---|---|
| tests collected by pytest | 61 |
| tests the README claims | 58 |
| assistant tools | 4 |
| max tool rounds per chat | 5 |
| fixed categories | 12 |
| concurrent categorization workers | 10 |
| sample transactions (~4 months) | 125 |
Yes, that first pair is a discrepancy: the suite collects 61 tests but the README says 58 — a stale undercount from before I added three argument-coercion tests, and I should bump it. I'd rather publish the mismatch than round it away. CI is a single job — ruff plus pytest on Python 3.12, on every push — not a version matrix, so I won't call it one.
The cache table for repeat categorizations, date normalization on CSV import, and pushing the assistant's tools down into SQL — each one already scoped above, because the limitations list is the roadmap. After that, auth, if this ever grows past one user. Ledgr is meant to stay small enough to explain end to end — as a learner, that was part of the point of building it.
One demo caveat before you click: the live instance runs on Render's free tier, which sleeps — the first load can take about a minute to wake — and the SQLite data resets on every redeploy. So the demo flow is simply: wait for the wake-up, hit "Load sample data," and watch 125 transactions get categorized live.
David Jeremie Anand · New Delhi · every number on this page is counted from the repo, including the one the README gets wrong.