Case study

Ledgr — the assistant that refuses to do arithmetic

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.

Python · Flask · SQLite · OpenAI / Ollama · 61 tests · live

The problem

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.

How a question gets answered

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.

TOOLS = get_totals · get_spending_by_category · search_transactions · get_largest_transactions
MAX_TOOL_ROUNDS = 5 ← bounded, on purpose
loop: model picks tool → my code runs it on real data → result goes back → model answers

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.

Compute, then narrate

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.

The tool-calling loop that stops the model inventing numbers Your question goes to the model, which chooses one of four declared tools: get_totals, get_spending_by_category, search_transactions or get_largest_transactions. My code executes that query against the real transaction data and sends the result back as a tool message. The model may go round again — at most five rounds — before it writes the sentence. The model only chooses and narrates; it never computes. model: chooses, narrates my code: computes YOUR QUESTION, IN PLAIN ENGLISH 1 THE MODEL chooses one of four declared tools · get_totals · get_spending_by_category · search_transactions · get_largest_transactions the tool call, with its arguments 2 MY CODE executes that query against the real transaction data every number is computed here the result goes back as a tool message AT MOST 5 ROUNDS 3 THE MODEL writes the sentence, quoting the figures it was handed it computes nothing, ever AN ANSWER THAT MATCHES THE DASHBOARD
Dashed boxes are the model, solid ones are my code. The model picks a tool and narrates the result; it never sees a calculation it has to do itself. After five rounds the loop stops and asks you for a more specific question.

Degrade to a dumber version, never to an error page

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.

Decisions I'd defend

Honest numbers

whatcount
tests collected by pytest61
tests the README claims58
assistant tools4
max tool rounds per chat5
fixed categories12
concurrent categorization workers10
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.

Limitations, stated plainly

What's next

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.

Live demo ↗ Read the code ↗ Back to the site →

David Jeremie Anand · New Delhi · every number on this page is counted from the repo, including the one the README gets wrong.