Inspiration
I've started tracking my expenses maybe five times. It never lasts more than a week, and it always fails the same way: logging a purchase takes longer than making it. You're standing at a counter holding a coffee, and the app wants you to open it, find the right category, tap through a menu, and type a number. So you tell yourself you'll add it later. Later never comes.
I started thinking about it as a time budget rather than a feature problem. If
logging costs t_log seconds and a purchase takes t_buy
seconds, people quietly give up once
$$t_{\text{log}} \gtrsim t_{\text{buy}}$$
because the accounting starts to feel more expensive than the spending. Every
tracker I used sat around t_log ≈ 30 seconds. Buying a coffee
takes about ten.
So the goal wasn't more features — it was getting t_log under two
seconds. And I already had my phone out, with a button on it that records my
voice. Hold, say "spent fifteen on lunch", release. That's the entire
interface.
What it does
You send a Telegram voice message. The bot logs the expense and tells you what's left of your budget.
"Spent 15 on lunch and 30 on gas"
15.00 USD — lunch (food)
30.00 USD — gas (transport)
420.00 USD / 1,000.00 USD
580.00 USD left this month
It handles several expenses in one breath — "a coffee for four, a sandwich for nine, and twenty on the bus" becomes three separate entries with three categories. It understands English, Russian, and Spanish with no setting to configure, including spoken numbers like "полторы тысячи" for 1500. Every entry gets its own undo button, because the model occasionally decides my coffee was groceries. There's a stats view, a monthly budget, and CSV export so my data is never trapped inside it.
How I built it
I run both AI steps on Groq, which hosts Whisper and Llama on hardware fast enough that transcription finishes before I even put my phone down. The free tier covers far more than one person logging a few things a day, so the entire running cost is hosting.
The rest is Python: python-telegram-bot for the interface, asyncpg for
Postgres, async end to end so a slow transcription never blocks another message.
I kept transcription and understanding as separate stages instead of asking one model to do both. They're different problems — sound into words, then words into meaning — and separating them means either can be replaced independently. The whole transcription layer is a single function, so switching to a local offline model later is a one-function change that touches nothing else.
Storing money correctly mattered more than I expected. Amounts are exact
decimals in both Python and Postgres, never floating point. Binary floats can't
represent most decimal fractions, so 0.1 + 0.2 ≠ 0.3 exactly, and across
many transactions the error compounds into cents that don't reconcile and can't
be traced. Exact decimal arithmetic makes the error identically zero rather than
merely small.
The budget bar is deliberately clamped:
$$f = \min\left(1, \frac{S}{B}\right), \qquad \text{filled} = \mathrm{round}(f \cdot w)$$
where S is spent, B is budget, w is bar width. Without the clamp, going
over budget renders a bar longer than the bar — the overspend is reported in
words instead.
Challenges I ran into
The model lies, and JSON mode doesn't stop it. Constrained decoding
guarantees I get syntactically valid JSON. It says nothing about whether the
contents are sane. The model can hand me a negative amount, a category I never
defined, null where a number belongs, or a date in 2027. If any of that reaches
the database I've silently corrupted my own financial records — which is worse
than the bot simply failing, because I'd trust the numbers.
So nothing the model returns reaches Postgres directly — it all goes through a
validation layer first, which I'll come back to below. I tested that layer
against a dozen classes of broken output: nulls, negative amounts, invented
categories, a non-dict where an object should be, dates like "tomorrow". None
of them get through.
My first real voice message crashed the bot. I'd built the prompt with
Python's str.format(), forgetting the prompt is full of literal JSON braces —
Python read {"expenses": ...} as a placeholder name and raised KeyError.
Whisper had transcribed my sentence perfectly and the thing fell over one step
later. I switched to a templating approach where braces carry no special
meaning, which removes the entire class of bug instead of patching the one
instance I'd hit.
I'd tested the wrong half. The database layer had thorough tests — month boundaries, cross-user isolation, cascade deletes — because that was the part I could test without live API keys. The AI path had almost none, for the same reason. The bug appeared exactly where I hadn't looked, which in hindsight is the least surprising outcome possible.
Time zones quietly break "today". An expense logged at 11pm should belong to that day, not the next one in UTC. All month and day boundaries are computed against a configured timezone, and backdated entries are anchored to midday local time so daylight-saving shifts can't push them across a date boundary.
What I learned
A prompt and a guarantee are different things, and I needed both. The project has exactly one prompt — the instructions sent to Llama telling it the output shape, the category list, how to read spoken numbers like "полторы тысячи" as 1500, and how to resolve "yesterday" against today's date. All the language understanding lives there, and it does that job well.
What it can't do is promise anything. A prompt is a request, not a constraint,
so "amount must be a positive number" holds until the day it doesn't. That's the
reason for _coerce(), a thirty-line function between the model and the
database that assumes the response is wrong: the amount has to survive
conversion to a positive Decimal or the entry is dropped, a category outside
my list becomes other, a date past tomorrow snaps back to today.
Splitting it that way is the piece I'd carry into any project built on a model. The prompt is where the intelligence goes and it's cheap to rewrite; the validation is where the correctness goes and it's what I'd defend in review. Neither can do the other's job.
Fixing a class of bug beats fixing an instance. The KeyError could have
been solved by escaping braces. I'd have reintroduced it the next time I edited
the prompt. Changing the templating system meant it couldn't happen again.
I tested where it was convenient, not where it was risky. Genuinely useful lesson, and slightly humbling: the untested path is where the bug will be, and "hard to test" is a reason to try harder rather than a reason to skip it.
The premise is still untested, and that's the interesting part. The whole
project rests on a bet: that dropping t_log from ~30s to ~2s is the
difference between tracking my spending and quietly giving up in week two. The
bot works, but I've only just started using it — I don't yet know whether the
friction was really the thing stopping me, or whether I'll find a new excuse.
That's what the next month is for.
Built With
- api
- asyncpg
- docker
- groq
- postgresql
- python
- python-telegram-bot
- telegram
Log in or sign up for Devpost to join the conversation.