← Back to portfolio
Case Study · 01 / 17
Applied LLM · Full-Stack · 2025

A privacy-first
expense coach
that never leaves your machine.

ZenSpend is an AI expense tracker that runs a local Llama via Ollama — meaning your financial data is read, classified, and queried without ever touching a third-party API. This is the story of why I built it that way, what broke first, and what shipped.

Role
Solo build — design & engineering
Timeline
Six weekends, evenings
Stack
FastAPI · LangChain · pgvector · Ollama
Status
Personal use, open-source
Fig. 01 · ZenSpend.local
01
Your bank statements are more revealing than your search history.
§ 01 — The problem

Existing trackers ask you to upload your soul.

Every "AI" budgeting app I tried wanted the same thing: full read access to my bank, broker, and credit cards — and a quiet promise that it would live happily on someone else's GPU. The trade was supposedly worth it, because LLMs would sort transactions, flag weird charges, and write mildly judgmental summaries of my coffee habit.

The category I cared about most was the one I trusted least. Transactions are biographical. They show where I sleep, who I live with, what I'm sick from, and what I'm trying to quit. Sending all of that to a third party for a feature I could write in a weekend felt backwards.

So I asked the obvious question: how good can a tracker be if the model never leaves the laptop?

A local model is a feature, not a fallback.

§ 02 — Constraints

Three rules I refused to break.

§ 03 — Architecture

Four boxes, one machine.

The system is deliberately small. Each transaction passes through four stages — ingest, embed, store, retrieve — and the whole pipeline runs inside a single docker compose up on a M-series MacBook.

01 — Ingest
CSV / OFX
FastAPI parses statement files, normalises currency & merchant.
02 — Embed
Local Llama
Ollama serves embedding + chat via HTTP on localhost:11434.
03 — Store
Postgres + pgvector
Transactions + embedding column, indexed for ANN search.
04 — Retrieve
React surface
Chat coach queries vectors + SQL aggregates side-by-side.

The interesting bit is step 03: every transaction stores both a structured row and its embedding. That means "how much do I spend on caffeine in a stressful week?" is one vector search joined to one SQL aggregate — not a retraining job, not a re-prompt, just a hybrid query.

§ 04 — What broke first

Local models lie differently than hosted ones.

The first painful lesson was that a 7B local model categorises merchants competently but fabricates totals when asked a numeric question. It would happily report I spent £312 on groceries when the real number was £267 — close enough to sound right, wrong enough to ruin the tool.

The fix was unglamorous: never let the model do arithmetic. Numbers come from SQL. The LLM only does three things — classify, summarise, and pick the right query template. Once that boundary was enforced, every accuracy bug disappeared at once.

94%
Categorisation accuracy on my 18-month statement set
~180ms
Median chat-to-answer latency on M2 Pro, fully local
0
Outbound network calls during normal use
§ 05 — Designing the surface

A coach, not a dashboard.

The biggest design call was rejecting the spreadsheet. Most finance tools open onto a wall of categories, totals, and pie charts. That layout assumes you already know what to ask. I wanted the opposite: one prompt at the top, one chart at the bottom, one piece of advice in between.

The chat coach is therefore the entire app. Charts and tables exist, but they appear inside a conversation as the model decides they're warranted — closer to a teammate explaining your week than a tool you have to operate.

§ 06 — What I'd do differently

Three things, if I built it again.