Inspiration

Indian students and first-job earners make 150–300 small UPI payments a month: chai, Rapido, Swiggy, splitting dinner with friends. At the end of the month the money is gone, and the bank statement doesn't help, because every payment looks like this:

UPI/DR/412345678901/SWIGGY/YESB/swiggy@ybl/Payment

Nobody reads that. Budget apps that read your SMS feel invasive, and pasting a statement into a chatbot means handing over account numbers, friends' names and phone numbers. Chatbots also invent numbers. We wanted a coach that reads the real statement, keeps personal data private, and never makes up a rupee.

What it does

  1. Reads a bank statement (CSV, or PDF, including password-protected PDFs).
  2. Decodes every UPI line into merchant, UPI ID, and person or merchant.
  3. Masks personal data in plain code before any AI sees it: friends become Person_03, and long numbers keep only their last 4 digits.
  4. Nemotron Nano sorts merchants into categories.
  5. pandas computes every number.
  6. Nemotron Super answers questions in English or Hinglish by calling our pandas functions as tools.
  7. Nemotron Ultra writes a monthly coach report from totals only, never individual transactions.

All three models run on Nebius Token Factory.

How we built it

Decode, then mask. Each narration is matched against bank-specific patterns (SBI-style UPI/DR/…, HDFC-style UPI-NAME-vpa-IFSC-ref). A payee counts as a merchant if its name has shop words (STORE, CANTEEN, AUTOPAY…) or its UPI ID looks like a business QR (paytmqr…, paytm.s…, ….rzp). Phone-number IDs and short alphabetic names count as people. People get stable aliases, so Person_03 is the same friend across the whole statement.

Categorise merchants, not transactions. A month has about 200 payments but only about 40 distinct merchants. Rules handle the known ones. Nano sees only the leftovers, in batches, and every answer is cached:

$$ \text{model calls} \approx \left\lceil \frac{#\text{unknown merchants}}{40} \right\rceil \ll #\text{transactions} $$

On our sample statement that was one Nano call for $0.00015. On 100 hand-labelled Indian merchants, rules plus Nano scored 91%.

The model never does arithmetic. Super gets 8 tools (spend_by, compare_months, sum_transactions, recurring_payments, …) that wrap core/insights.py. It decides which calculation to run, and our code runs it. Then a checker pulls every number out of the answer and flags any it can't trace. A number $x$ is traced if it is within ₹1 of a number $s$ in the tool results, or of a sum or difference of two traced numbers the text itself quotes:

$$ \exists\, s \in S:\ |x - s| \le 1 \quad\text{or}\quad \exists\, a,b \in T:\ \big|x - (a \pm b)\big| \le 1 $$

Small counts ($\le 31$), years, and durations like "48 hours" are allowed. In the UI, every answer shows "Computed from N transactions" and a Show the calculation panel.

The coach sees totals only. Ultra gets the month's totals: spend by category, recurring payments, small payments under ₹200, weekday vs weekend, and so on. We overwrite every budget's "last month" figure with our own number, so a typo from the model can't reach the table. For example, the savings rate is ours, not the model's:

$$ \text{savings rate} = \frac{\text{received} - \text{spent}}{\text{received}} \times 100 $$

Stack: Python 3.11, pandas, pdfplumber, pikepdf, Streamlit, the OpenAI SDK pointed at Token Factory, and 176 pytest tests that run without an API key.

Challenges we ran into

Real PDFs aren't sample PDFs. Our parser passed every synthetic test, then failed on the first real HDFC statement: "This bank's PDF layout isn't supported yet." We couldn't share the statement, so we wrote scripts/inspect_pdf.py, which prints the table layout with every letter replaced by A and every digit by 9. That revealed three problems:

  • The header words were glued together (WithdrawalAmt., ClosingBalance).
  • The PDF draws boxes around columns but no lines between rows, so the table reader put a whole page into a single row.
  • The "Narration" heading is centred, so cutting columns at the heading moved UPI- into the date column, and every one of the 116 payments decoded as "unknown".

We now cut along the drawn boxes. To keep it fixed, we built a synthetic PDF with the same layout as a test.

Trusting the parser. A misread row silently corrupts every total. So we check the running balance on every row:

$$ b_i = b_{i-1} + c_i - d_i \pm 0.01 $$

where $b$ is the balance, $c$ a credit and $d$ a debit. We refuse the file if more than $\max(2,\ 0.02n)$ of the $n$ rows break this. Showing an error is better than showing wrong numbers.

Privacy bugs hide in edge cases. Our own audits found that cheque and transfer lines (CHQ DEP-RAHUL SHARMA) skipped masking because they weren't UPI. We also found that shop QR codes carry the owner's name, so shops looked like friends. Each fix came with a test built from the exact line that broke.

Running a public demo on a real API key. Anyone with the link can spend our credits. So the app keeps a daily AI budget. When

$$ \sum_{\text{calls today}} \text{cost} \ \ge\ \text{LLM_DAILY_BUDGET_USD} $$

it switches to rules-only mode until midnight UTC, and the dashboard keeps working.

What we learned

  • Let models choose and explain, and let code do the counting. Tool calling plus a number checker gave 10/10 correct tool choices on our test questions, with no invented figures in the final run.
  • Fewer, smarter calls beat bigger prompts. Merchant-level batching and caching made categorising cost almost nothing. A full coach report from Ultra costs about $0.02 and takes about 13 s.
  • Privacy is a pipeline property, not a prompt. "Don't look at names" in a prompt protects nothing. Removing the names before the model is called does.
  • Test on real data early. Each real statement taught us more than a hundred synthetic ones, and a masked inspector let us debug it without ever sharing it.

What's next

More bank layouts (ICICI, SBI and Axis PDFs), card statements, month-over-month goals, and a fully on-device mode.

Built With

Share this project:

Updates

Submission history