Spaces:
Running on Zero
Running on Zero
Deploy coco-finbot
Browse files- .env.example +64 -0
- .gitignore +25 -0
- Dockerfile +25 -0
- README.md +243 -10
- app.py +346 -0
- deploy.sh +119 -0
- finbot/__init__.py +13 -0
- finbot/advice.py +243 -0
- finbot/analytics.py +288 -0
- finbot/categories.py +243 -0
- finbot/config.py +100 -0
- finbot/extract.py +203 -0
- finbot/features/__init__.py +47 -0
- finbot/features/base.py +150 -0
- finbot/features/budgets.py +81 -0
- finbot/features/coaching.py +51 -0
- finbot/features/core.py +111 -0
- finbot/features/expenses.py +256 -0
- finbot/ingest.py +292 -0
- finbot/limits.py +55 -0
- finbot/llm.py +246 -0
- finbot/models.py +158 -0
- finbot/money.py +180 -0
- finbot/parser.py +188 -0
- finbot/registry.py +128 -0
- finbot/render.py +207 -0
- finbot/store.py +412 -0
- finbot/telegram.py +252 -0
- requirements.txt +9 -0
- tests/test_analytics.py +201 -0
- tests/test_app.py +287 -0
- tests/test_categories.py +86 -0
- tests/test_extract.py +101 -0
- tests/test_ingest.py +232 -0
- tests/test_limits.py +96 -0
- tests/test_money.py +157 -0
- tests/test_parser.py +123 -0
- tests/test_registry.py +183 -0
- tests/test_store.py +190 -0
- tests/test_telegram.py +119 -0
.env.example
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# coco-finbot configuration.
|
| 2 |
+
# Locally: copy to .env. On Hugging Face: set these under Space Settings > Secrets.
|
| 3 |
+
# Never commit the real .env.
|
| 4 |
+
|
| 5 |
+
# --- Telegram -------------------------------------------------------------
|
| 6 |
+
# From @BotFather.
|
| 7 |
+
TELEGRAM_BOT_TOKEN=
|
| 8 |
+
|
| 9 |
+
# Random string you invent. Telegram echoes it back in the
|
| 10 |
+
# X-Telegram-Bot-Api-Secret-Token header; requests without it are rejected.
|
| 11 |
+
# Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
| 12 |
+
TELEGRAM_WEBHOOK_SECRET=
|
| 13 |
+
|
| 14 |
+
# Comma-separated Telegram numeric user IDs allowed to use the bot.
|
| 15 |
+
# Leave EMPTY to allow nobody (safe default -- the bot replies with a refusal
|
| 16 |
+
# and tells the sender their id, so you can read your own id off the reply).
|
| 17 |
+
# Ignored when OPEN_ACCESS=true.
|
| 18 |
+
ALLOWED_TELEGRAM_USER_IDS=
|
| 19 |
+
|
| 20 |
+
# Let ANYONE on Telegram use the bot. Each user gets their own isolated ledger,
|
| 21 |
+
# so this makes the bot multi-tenant -- it does NOT expose your spending to
|
| 22 |
+
# other people. What it does expose is your wallet: every stranger's receipt
|
| 23 |
+
# and statement is read using YOUR HF_TOKEN. Cap that below.
|
| 24 |
+
# Must be set explicitly, so an unconfigured allowlist stays closed.
|
| 25 |
+
OPEN_ACCESS=false
|
| 26 |
+
|
| 27 |
+
# Model-backed uploads (receipt photos, statements) per user per day.
|
| 28 |
+
# Typed expenses are parsed locally and are never counted. 0 disables the cap.
|
| 29 |
+
UPLOADS_PER_USER_PER_DAY=20
|
| 30 |
+
|
| 31 |
+
# --- Inference (Hugging Face Inference Providers) --------------------------
|
| 32 |
+
# A HF token with "Make calls to Inference Providers" permission.
|
| 33 |
+
# On a Space you can leave this unset and the bot still works -- it falls back
|
| 34 |
+
# to the deterministic parser and skips receipt/statement understanding.
|
| 35 |
+
HF_TOKEN=
|
| 36 |
+
|
| 37 |
+
# OpenAI-compatible base URL. HF's router fans one token out to many providers.
|
| 38 |
+
LLM_BASE_URL=https://router.huggingface.co/v1
|
| 39 |
+
|
| 40 |
+
# Text model used for extraction, categorisation and advice.
|
| 41 |
+
LLM_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
| 42 |
+
|
| 43 |
+
# Vision model used for receipt photos. Must accept image_url content parts.
|
| 44 |
+
LLM_VISION_MODEL=Qwen/Qwen2.5-VL-72B-Instruct
|
| 45 |
+
|
| 46 |
+
# --- Durable storage ------------------------------------------------------
|
| 47 |
+
# Spaces disk is EPHEMERAL -- it is wiped on every restart, sleep or rebuild.
|
| 48 |
+
# The bot keeps SQLite locally and mirrors it to a PRIVATE dataset repo so your
|
| 49 |
+
# data survives. Set to a repo you own, e.g. "your-username/finbot-data".
|
| 50 |
+
# Leave empty to run purely local (fine for dev; DATA LOSS on a Space).
|
| 51 |
+
HF_DATASET_REPO=
|
| 52 |
+
|
| 53 |
+
# How often (minutes) to push the database to the dataset repo.
|
| 54 |
+
SYNC_EVERY_MINUTES=5
|
| 55 |
+
|
| 56 |
+
# Where the local SQLite file lives inside the container.
|
| 57 |
+
DATA_DIR=/home/user/data
|
| 58 |
+
|
| 59 |
+
# --- Behaviour ------------------------------------------------------------
|
| 60 |
+
# Default currency for bare amounts like "coffee 250". ISO 4217 code.
|
| 61 |
+
DEFAULT_CURRENCY=INR
|
| 62 |
+
|
| 63 |
+
# IANA timezone used to decide what "today" and "this month" mean.
|
| 64 |
+
TIMEZONE=Asia/Kolkata
|
.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secrets -- never commit. On Spaces these come from Settings > Secrets.
|
| 2 |
+
.env
|
| 3 |
+
*.env
|
| 4 |
+
!.env.example
|
| 5 |
+
|
| 6 |
+
# Local SQLite working copy and any restored snapshots
|
| 7 |
+
data/
|
| 8 |
+
*.db
|
| 9 |
+
*.db-journal
|
| 10 |
+
*.db-wal
|
| 11 |
+
*.db-shm
|
| 12 |
+
|
| 13 |
+
# Python
|
| 14 |
+
__pycache__/
|
| 15 |
+
*.py[cod]
|
| 16 |
+
.venv/
|
| 17 |
+
venv/
|
| 18 |
+
.pytest_cache/
|
| 19 |
+
.ruff_cache/
|
| 20 |
+
*.egg-info/
|
| 21 |
+
|
| 22 |
+
# Editor / OS
|
| 23 |
+
.DS_Store
|
| 24 |
+
.idea/
|
| 25 |
+
.vscode/
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Hugging Face Spaces runs containers as uid 1000. Create a matching user so the
|
| 4 |
+
# app owns its own HOME -- the SQLite working copy lives under $HOME/data.
|
| 5 |
+
RUN useradd -m -u 1000 user
|
| 6 |
+
|
| 7 |
+
ENV HOME=/home/user \
|
| 8 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 9 |
+
PYTHONUNBUFFERED=1 \
|
| 10 |
+
PYTHONDONTWRITEBYTECODE=1
|
| 11 |
+
|
| 12 |
+
WORKDIR $HOME/app
|
| 13 |
+
|
| 14 |
+
COPY --chown=user:user requirements.txt .
|
| 15 |
+
|
| 16 |
+
USER user
|
| 17 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 18 |
+
&& pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
COPY --chown=user:user . .
|
| 21 |
+
|
| 22 |
+
# Spaces routes public traffic to app_port (see README.md frontmatter).
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
|
| 25 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,15 +1,248 @@
|
|
| 1 |
---
|
| 2 |
title: Oracle
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
python_version: '3.12'
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
|
| 12 |
-
short_description: Oracle for personal use
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Oracle
|
| 3 |
+
emoji: 💸
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
|
|
|
|
|
|
| 8 |
pinned: false
|
| 9 |
+
short_description: Expense tracking and budget coaching over Telegram
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# coco-finbot
|
| 13 |
+
|
| 14 |
+
> Deployed as the **Oracle** Space. `sdk: docker` above is what switches the
|
| 15 |
+
> Space off Gradio — do not drop it.
|
| 16 |
+
|
| 17 |
+
A personal finance bot that lives in Telegram and runs as a Hugging Face Docker
|
| 18 |
+
Space. Tell it what you spent, send it receipts and bank statements, and ask it
|
| 19 |
+
what your money is doing.
|
| 20 |
+
|
| 21 |
+
```
|
| 22 |
+
you ▸ coffee 250
|
| 23 |
+
bot ▸ ✅ Logged ₹250.00 — coffee
|
| 24 |
+
Food & Dining · 2026-08-15
|
| 25 |
+
|
| 26 |
+
you ▸ [photo of a restaurant bill]
|
| 27 |
+
bot ▸ ✅ Logged ₹1,840.00 — Toit Brewpub
|
| 28 |
+
Food & Dining · 2026-08-14
|
| 29 |
+
|
| 30 |
+
you ▸ [statement.pdf]
|
| 31 |
+
bot ▸ Found 47 transactions in that PDF · ₹68,204.00
|
| 32 |
+
3 already logged (skipped)
|
| 33 |
+
These were read by a model and not saved yet.
|
| 34 |
+
/confirm to save · /cancel to discard
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## Read this before you deploy
|
| 40 |
+
|
| 41 |
+
**Space disk is ephemeral.** Hugging Face wipes it on every restart, sleep and
|
| 42 |
+
rebuild. If you do not set `HF_DATASET_REPO`, your entire ledger disappears the
|
| 43 |
+
first time the Space sleeps — silently, because nothing errors. The bot mirrors
|
| 44 |
+
SQLite to a **private** dataset repo on a timer and restores it on boot. `/status`
|
| 45 |
+
tells you which mode you are in.
|
| 46 |
+
|
| 47 |
+
**Make the Space private.** This is your spending history. A public Space exposes
|
| 48 |
+
the container, its logs and its `/health` endpoint to anyone.
|
| 49 |
+
|
| 50 |
+
**Decide who can use it.** `ALLOWED_TELEGRAM_USER_IDS` starts empty, which
|
| 51 |
+
authorises nobody — a forgotten allowlist stays closed rather than silently
|
| 52 |
+
opening to the world.
|
| 53 |
+
|
| 54 |
+
Set `OPEN_ACCESS=true` to let anyone message the bot. Ledgers are keyed by
|
| 55 |
+
Telegram user id, so open access makes the bot **multi-tenant**: each person
|
| 56 |
+
gets their own private ledger and nobody can see yours. What open access does
|
| 57 |
+
expose is your **wallet** — every stranger's receipt and statement is read using
|
| 58 |
+
your `HF_TOKEN`. `UPLOADS_PER_USER_PER_DAY` (default 20) caps that; typed
|
| 59 |
+
expenses are parsed locally and never metered. Note also that running an open
|
| 60 |
+
bot makes you the custodian of other people's financial data in your dataset
|
| 61 |
+
repo.
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## Deploy
|
| 66 |
+
|
| 67 |
+
**1. Create the bot.** Message [@BotFather](https://t.me/BotFather) → `/newbot` →
|
| 68 |
+
keep the token.
|
| 69 |
+
|
| 70 |
+
**2. Push the Space and dataset.** `deploy.sh` creates both (private, Docker SDK)
|
| 71 |
+
and uploads the source. It takes the owner from your logged-in account:
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
./.venv/bin/hf auth login && ./deploy.sh
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
./deploy.sh --dry-run # show the target repos, create nothing
|
| 79 |
+
./deploy.sh Uniphy # deploy under an org instead of your account
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
> Use this rather than `git push`. This directory lives inside the larger UniPhy
|
| 83 |
+
> repo, so a `git push` from here would send that entire tree — unrelated repos
|
| 84 |
+
> and any secrets they hold — to a public-by-default host. `deploy.sh` uploads
|
| 85 |
+
> only this folder, minus `.venv`, `.env`, caches and any local database.
|
| 86 |
+
|
| 87 |
+
**3. Set secrets** under Space Settings → Secrets (use the UI, not the CLI, so
|
| 88 |
+
they stay out of your shell history):
|
| 89 |
+
|
| 90 |
+
| Secret | Required | What it does |
|
| 91 |
+
| --- | --- | --- |
|
| 92 |
+
| `TELEGRAM_BOT_TOKEN` | ✅ | From BotFather |
|
| 93 |
+
| `TELEGRAM_WEBHOOK_SECRET` | ✅ | Any random string; rejects forged webhook calls |
|
| 94 |
+
| `ALLOWED_TELEGRAM_USER_IDS` | ✅ | Your numeric Telegram ID (comma-separated for more). Not needed if `OPEN_ACCESS=true` |
|
| 95 |
+
| `OPEN_ACCESS` | | `true` lets anyone use the bot, each with their own ledger. Default `false` |
|
| 96 |
+
| `UPLOADS_PER_USER_PER_DAY` | | Caps model-backed uploads per user. Default `20`, `0` disables |
|
| 97 |
+
| `HF_DATASET_REPO` | ✅ | `<you>/finbot-data` — **without this your data is lost on restart** |
|
| 98 |
+
| `HF_TOKEN` | ⚠️ | Write-scoped token with Inference Providers access. Without it, receipts/statements/advice are off; typing expenses still works |
|
| 99 |
+
| `DEFAULT_CURRENCY` | | ISO code for bare amounts. Default `INR` |
|
| 100 |
+
| `TIMEZONE` | | Decides what "today" means. Default `Asia/Kolkata` |
|
| 101 |
+
| `LLM_MODEL` / `LLM_VISION_MODEL` | | Override the models |
|
| 102 |
+
|
| 103 |
+
Generate a webhook secret with:
|
| 104 |
+
|
| 105 |
+
```bash
|
| 106 |
+
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
**4. Restart the Space** so it picks up the secrets. On boot it reads
|
| 110 |
+
`SPACE_HOST` and registers its own Telegram webhook — nothing else to wire up.
|
| 111 |
+
|
| 112 |
+
**5. Message the bot.** Don't know your Telegram ID? Send it anything: it
|
| 113 |
+
replies with the ID to paste into `ALLOWED_TELEGRAM_USER_IDS`. Then send
|
| 114 |
+
`/status` and confirm storage reads *mirroring*, not *local only*.
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## Using it
|
| 119 |
+
|
| 120 |
+
Anything with a number in it is logged. No command needed.
|
| 121 |
+
|
| 122 |
+
```
|
| 123 |
+
coffee 250
|
| 124 |
+
₹1,240 groceries at bigbasket
|
| 125 |
+
45.50 lunch yesterday
|
| 126 |
+
320 uber #transport ← #tag overrides the category guess
|
| 127 |
+
€40 dinner 12 aug
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
Send a **photo** of a receipt and a vision model reads the total. Send a
|
| 131 |
+
**PDF/CSV/XLSX** statement and it extracts the transactions, drops any already
|
| 132 |
+
in your ledger, and shows you the total before saving — nothing model-extracted
|
| 133 |
+
is written until you `/confirm`.
|
| 134 |
+
|
| 135 |
+
| Command | |
|
| 136 |
+
| --- | --- |
|
| 137 |
+
| `/today` `/week` `/month` | Spending summaries with a category breakdown |
|
| 138 |
+
| `/report <period>` | `today`, `week`, `month`, `last_month`, `30d`, `year`, `all` |
|
| 139 |
+
| `/list [n]` | Recent transactions |
|
| 140 |
+
| `/recurring` | Subscriptions and repeat charges, with the annual cost |
|
| 141 |
+
| `/advice [question]` | Observations grounded in your own numbers |
|
| 142 |
+
| `/budget <category> <amount>` | Set a monthly cap |
|
| 143 |
+
| `/budgets` | How you're tracking |
|
| 144 |
+
| `/categories` | Category names for tags and budgets |
|
| 145 |
+
| `/undo` | Remove the last entry, or the last import wholesale |
|
| 146 |
+
| `/export` | Everything as CSV |
|
| 147 |
+
| `/status` | Config and storage health |
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## How it works
|
| 152 |
+
|
| 153 |
+
```
|
| 154 |
+
Telegram ──webhook──▶ FastAPI (:7860)
|
| 155 |
+
│
|
| 156 |
+
├─ registry ──▶ features: expenses │ budgets │ coaching │ core
|
| 157 |
+
│ │
|
| 158 |
+
│ ├─ parser deterministic, free, first
|
| 159 |
+
│ ├─ extract CSV · XLSX · PDF
|
| 160 |
+
│ └─ llm ──▶ router.huggingface.co/v1
|
| 161 |
+
│
|
| 162 |
+
└─ store: SQLite ──CommitScheduler──▶ private HF dataset
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
**Deterministic first, model second.** The regex parser handles `coffee 250`
|
| 166 |
+
instantly and identically every time. Inference is spent only where judgement
|
| 167 |
+
is genuinely needed: reading a receipt, or working out which column of an
|
| 168 |
+
unfamiliar bank export holds the amount. If `HF_TOKEN` is unset or a provider is
|
| 169 |
+
down, text logging and every summary still work.
|
| 170 |
+
|
| 171 |
+
**Money is never a float.** Amounts are integer minor units (paise, cents, fils)
|
| 172 |
+
plus an ISO 4217 code. Currencies are never summed together — a multi-currency
|
| 173 |
+
month reports one total per currency rather than a fictional combined figure.
|
| 174 |
+
|
| 175 |
+
**Statements are staged, not trusted.** A model that misreads a column would
|
| 176 |
+
otherwise quietly poison months of history, so imports become a pending batch
|
| 177 |
+
you confirm, tagged with a `batch_id` so `/undo` can reverse the whole thing.
|
| 178 |
+
|
| 179 |
+
**Inference is provider-agnostic.** `LLM_BASE_URL` is an OpenAI-compatible
|
| 180 |
+
endpoint. It defaults to HF's router — one token, many providers — but points
|
| 181 |
+
just as happily at Ollama, llama.cpp or OpenAI.
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## Adding a feature
|
| 186 |
+
|
| 187 |
+
The registry is the extension point. A feature declares commands and optionally
|
| 188 |
+
claims plain messages or uploaded files; the first one to return a `Reply` wins.
|
| 189 |
+
Nothing else in the codebase changes.
|
| 190 |
+
|
| 191 |
+
```python
|
| 192 |
+
from finbot.features.base import BotContext, CommandSpec, Reply, SimpleFeature
|
| 193 |
+
|
| 194 |
+
class RemindersFeature(SimpleFeature):
|
| 195 |
+
name = "reminders"
|
| 196 |
+
description = "⏰ Reminders"
|
| 197 |
+
|
| 198 |
+
def commands(self):
|
| 199 |
+
return (CommandSpec("remind", "Set a reminder", "<when> <what>"),)
|
| 200 |
+
|
| 201 |
+
def cmd_remind(self, ctx: BotContext) -> Reply:
|
| 202 |
+
return Reply(f"Noted: {ctx.args}")
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
Add it to `default_features()` in `finbot/features/__init__.py`. `/help` and
|
| 206 |
+
Telegram's command menu build themselves from what is registered, and duplicate
|
| 207 |
+
commands raise at startup rather than shadowing silently.
|
| 208 |
+
|
| 209 |
+
---
|
| 210 |
+
|
| 211 |
+
## Development
|
| 212 |
+
|
| 213 |
+
```bash
|
| 214 |
+
python3.11 -m venv .venv && ./.venv/bin/pip install -r requirements.txt pytest
|
| 215 |
+
cp .env.example .env # fill in what you need
|
| 216 |
+
./.venv/bin/python -m pytest tests/ -q
|
| 217 |
+
./.venv/bin/uvicorn app:app --reload --port 7860
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
To test against real Telegram locally, expose port 7860 with a tunnel and
|
| 221 |
+
register the webhook:
|
| 222 |
+
|
| 223 |
+
```bash
|
| 224 |
+
curl -X POST localhost:7860/admin/setup -H "X-Admin-Secret: $TELEGRAM_WEBHOOK_SECRET" -H 'Content-Type: application/json' -d '{"url":"https://<your-tunnel>/telegram/webhook"}'
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
268 tests cover the pure logic — money arithmetic, parsing, categorisation,
|
| 228 |
+
analytics, the registry contract — plus an end-to-end pass over the webhook with
|
| 229 |
+
a stubbed Telegram transport. None of them touch the network.
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
## Known limits
|
| 234 |
+
|
| 235 |
+
- **Credits are not stored.** Statement imports keep debits only; incoming money
|
| 236 |
+
is counted and reported but not written, because mixing it into the same table
|
| 237 |
+
would corrupt every "total spent" figure. Income tracking is a real feature,
|
| 238 |
+
not a small one.
|
| 239 |
+
- **Scanned PDFs** have no text layer. The bot detects this and tells you to send
|
| 240 |
+
it as a photo instead.
|
| 241 |
+
- **Legacy `.xls`** is not supported — re-save as `.xlsx` or CSV.
|
| 242 |
+
- **20 MB** is Telegram's own cap on bot file downloads.
|
| 243 |
+
- **Pending imports live in memory**, so a Space restart between the preview and
|
| 244 |
+
`/confirm` drops them. Re-send the file.
|
| 245 |
+
- **Recurring detection is lexical.** It unifies `NETFLIX.COM 447192` with
|
| 246 |
+
`Netflix`, but not `AMZN Mktp` with `Amazon`.
|
| 247 |
+
- **Advice is budgeting only.** It comments on your logged spending and is
|
| 248 |
+
explicitly prevented from recommending investments or giving tax advice.
|
app.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI entrypoint -- the Hugging Face Space serves this on port 7860.
|
| 2 |
+
|
| 3 |
+
The webhook answers Telegram immediately and does the real work in a background
|
| 4 |
+
task. Reading a statement can take a minute; holding the webhook open that long
|
| 5 |
+
makes Telegram time out and redeliver, which would import the same file twice.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from contextlib import asynccontextmanager
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from fastapi import BackgroundTasks, FastAPI, Header, Request
|
| 16 |
+
from fastapi.concurrency import run_in_threadpool
|
| 17 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 18 |
+
|
| 19 |
+
from finbot import __version__
|
| 20 |
+
from finbot.advice import Advisor
|
| 21 |
+
from finbot.analytics import today_in
|
| 22 |
+
from finbot.config import get_settings
|
| 23 |
+
from finbot.features import default_features
|
| 24 |
+
from finbot.features.base import Attachment, BotContext, Services
|
| 25 |
+
from finbot.ingest import Ingestor
|
| 26 |
+
from finbot.limits import RateLimiter
|
| 27 |
+
from finbot.llm import LLMClient
|
| 28 |
+
from finbot.registry import FeatureRegistry
|
| 29 |
+
from finbot.store import open_store
|
| 30 |
+
from finbot.telegram import (
|
| 31 |
+
MAX_DOWNLOAD_BYTES,
|
| 32 |
+
ParsedUpdate,
|
| 33 |
+
SeenUpdates,
|
| 34 |
+
TelegramClient,
|
| 35 |
+
parse_update,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
logging.basicConfig(
|
| 39 |
+
level=os.environ.get("LOG_LEVEL", "INFO"),
|
| 40 |
+
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
| 41 |
+
)
|
| 42 |
+
log = logging.getLogger("finbot")
|
| 43 |
+
|
| 44 |
+
WEBHOOK_PATH = "/telegram/webhook"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def build_services() -> Services:
|
| 48 |
+
settings = get_settings()
|
| 49 |
+
registry = FeatureRegistry()
|
| 50 |
+
llm = LLMClient(settings)
|
| 51 |
+
|
| 52 |
+
services = Services(
|
| 53 |
+
settings=settings,
|
| 54 |
+
store=open_store(settings),
|
| 55 |
+
llm=llm,
|
| 56 |
+
ingestor=Ingestor(llm, settings.default_currency),
|
| 57 |
+
advisor=Advisor(llm),
|
| 58 |
+
registry=registry,
|
| 59 |
+
)
|
| 60 |
+
registry.register_all(default_features())
|
| 61 |
+
return services
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def space_webhook_url() -> str:
|
| 65 |
+
"""Public URL of this Space, if we are running on one."""
|
| 66 |
+
if host := os.environ.get("SPACE_HOST"):
|
| 67 |
+
return f"https://{host.rstrip('/')}{WEBHOOK_PATH}"
|
| 68 |
+
return ""
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@asynccontextmanager
|
| 72 |
+
async def lifespan(app: FastAPI):
|
| 73 |
+
services = build_services()
|
| 74 |
+
settings = services.settings
|
| 75 |
+
telegram = TelegramClient(settings.telegram_bot_token)
|
| 76 |
+
|
| 77 |
+
app.state.services = services
|
| 78 |
+
app.state.telegram = telegram
|
| 79 |
+
app.state.seen = SeenUpdates()
|
| 80 |
+
app.state.limiter = RateLimiter(settings.uploads_per_user_per_day)
|
| 81 |
+
|
| 82 |
+
for gap in settings.missing_required():
|
| 83 |
+
log.warning("Not configured: %s", gap)
|
| 84 |
+
|
| 85 |
+
if settings.open_access:
|
| 86 |
+
log.warning(
|
| 87 |
+
"OPEN ACCESS: anyone on Telegram may use this bot. Ledgers are "
|
| 88 |
+
"isolated per user, but uploads spend this Space's inference "
|
| 89 |
+
"credits (capped at %s/user/day).",
|
| 90 |
+
settings.uploads_per_user_per_day or "unlimited",
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if telegram.enabled:
|
| 94 |
+
try:
|
| 95 |
+
me = await telegram.get_me()
|
| 96 |
+
log.info("Connected to Telegram as @%s", me.get("username"))
|
| 97 |
+
await telegram.set_my_commands(services.registry.telegram_commands())
|
| 98 |
+
|
| 99 |
+
if (url := space_webhook_url()) and settings.telegram_webhook_secret:
|
| 100 |
+
await telegram.set_webhook(url, settings.telegram_webhook_secret)
|
| 101 |
+
log.info("Webhook registered at %s", url)
|
| 102 |
+
elif not settings.telegram_webhook_secret:
|
| 103 |
+
log.warning("TELEGRAM_WEBHOOK_SECRET unset; webhook not registered.")
|
| 104 |
+
else:
|
| 105 |
+
log.info(
|
| 106 |
+
"No SPACE_HOST; register the webhook manually via POST /admin/setup"
|
| 107 |
+
)
|
| 108 |
+
except Exception as exc:
|
| 109 |
+
# A Telegram hiccup must not stop the Space from booting -- a live
|
| 110 |
+
# container that reports the problem beats a crash loop.
|
| 111 |
+
log.error("Telegram bootstrap failed: %s", exc)
|
| 112 |
+
else:
|
| 113 |
+
log.warning("TELEGRAM_BOT_TOKEN unset; running without a bot connection.")
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
yield
|
| 117 |
+
finally:
|
| 118 |
+
log.info("Shutting down; flushing ledger to the Hub.")
|
| 119 |
+
await telegram.close()
|
| 120 |
+
services.llm.close()
|
| 121 |
+
await run_in_threadpool(services.store.close)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
app = FastAPI(title="coco-finbot", version=__version__, lifespan=lifespan)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# --------------------------------------------------------------------- pages
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@app.get("/", response_class=HTMLResponse)
|
| 131 |
+
async def index() -> str:
|
| 132 |
+
"""Deliberately stateless.
|
| 133 |
+
|
| 134 |
+
This Space may be public, and transaction counts, configuration gaps and
|
| 135 |
+
sync state are personal operational detail. They are available to the owner
|
| 136 |
+
over Telegram via /status, and here only behind /admin/diagnostics.
|
| 137 |
+
"""
|
| 138 |
+
return f"""<!doctype html>
|
| 139 |
+
<meta charset="utf-8"><title>Oracle</title>
|
| 140 |
+
<style>
|
| 141 |
+
body {{ font: 16px/1.6 system-ui, sans-serif; max-width: 40rem;
|
| 142 |
+
margin: 4rem auto; padding: 0 1rem; }}
|
| 143 |
+
a {{ color: inherit; }}
|
| 144 |
+
</style>
|
| 145 |
+
<h1>💸 Oracle</h1>
|
| 146 |
+
<p>A private expense-tracking and budget-coaching bot. It runs on Telegram —
|
| 147 |
+
there is nothing to use on this page.</p>
|
| 148 |
+
<p>Built with <code>coco-finbot</code> v{__version__}.</p>
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@app.get("/health")
|
| 153 |
+
async def health() -> JSONResponse:
|
| 154 |
+
"""Liveness only. No ledger or configuration detail — this may be public."""
|
| 155 |
+
return JSONResponse({"status": "ok", "version": __version__})
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@app.get("/admin/diagnostics")
|
| 159 |
+
async def diagnostics(
|
| 160 |
+
x_admin_secret: Optional[str] = Header(default=None),
|
| 161 |
+
) -> JSONResponse:
|
| 162 |
+
"""Operational detail, gated on the webhook secret."""
|
| 163 |
+
services: Services = app.state.services
|
| 164 |
+
settings = services.settings
|
| 165 |
+
|
| 166 |
+
if not settings.telegram_webhook_secret or x_admin_secret != settings.telegram_webhook_secret:
|
| 167 |
+
return JSONResponse({"ok": False, "error": "forbidden"}, status_code=403)
|
| 168 |
+
|
| 169 |
+
return JSONResponse(
|
| 170 |
+
{
|
| 171 |
+
"ok": True,
|
| 172 |
+
"version": __version__,
|
| 173 |
+
"llm_enabled": settings.llm_enabled,
|
| 174 |
+
"sync_enabled": settings.sync_enabled,
|
| 175 |
+
"sync_status": services.store.sync_status,
|
| 176 |
+
"rows": services.store.count(),
|
| 177 |
+
"missing": settings.missing_required(),
|
| 178 |
+
}
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ------------------------------------------------------------------ webhook
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@app.post(WEBHOOK_PATH)
|
| 186 |
+
async def telegram_webhook(
|
| 187 |
+
request: Request,
|
| 188 |
+
background: BackgroundTasks,
|
| 189 |
+
x_telegram_bot_api_secret_token: Optional[str] = Header(default=None),
|
| 190 |
+
) -> JSONResponse:
|
| 191 |
+
services: Services = app.state.services
|
| 192 |
+
settings = services.settings
|
| 193 |
+
|
| 194 |
+
expected = settings.telegram_webhook_secret
|
| 195 |
+
if not expected or x_telegram_bot_api_secret_token != expected:
|
| 196 |
+
log.warning("Rejected webhook call with a bad secret token.")
|
| 197 |
+
return JSONResponse({"ok": False}, status_code=403)
|
| 198 |
+
|
| 199 |
+
try:
|
| 200 |
+
update = await request.json()
|
| 201 |
+
except Exception:
|
| 202 |
+
return JSONResponse({"ok": False, "error": "bad json"}, status_code=400)
|
| 203 |
+
|
| 204 |
+
telegram: TelegramClient = app.state.telegram
|
| 205 |
+
parsed = parse_update(update, telegram.username)
|
| 206 |
+
if parsed is None:
|
| 207 |
+
return JSONResponse({"ok": True, "skipped": "unsupported update"})
|
| 208 |
+
|
| 209 |
+
if not app.state.seen.add_if_new(parsed.update_id):
|
| 210 |
+
log.info("Ignoring redelivered update %s", parsed.update_id)
|
| 211 |
+
return JSONResponse({"ok": True, "skipped": "duplicate"})
|
| 212 |
+
|
| 213 |
+
# Answer now, work later: Telegram redelivers if we hold this open.
|
| 214 |
+
background.add_task(handle_update, parsed)
|
| 215 |
+
return JSONResponse({"ok": True})
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
async def handle_update(parsed: ParsedUpdate) -> None:
|
| 219 |
+
services: Services = app.state.services
|
| 220 |
+
telegram: TelegramClient = app.state.telegram
|
| 221 |
+
settings = services.settings
|
| 222 |
+
|
| 223 |
+
if not settings.is_authorised(parsed.user_id):
|
| 224 |
+
log.warning("Unauthorised user %s attempted access", parsed.user_id)
|
| 225 |
+
await telegram.send_message(
|
| 226 |
+
parsed.chat_id,
|
| 227 |
+
"This is a private bot and you're not on its allowlist.\n\n"
|
| 228 |
+
f"If it's yours, add <code>{parsed.user_id}</code> to "
|
| 229 |
+
"<code>ALLOWED_TELEGRAM_USER_IDS</code> in the Space secrets "
|
| 230 |
+
"and restart it.",
|
| 231 |
+
)
|
| 232 |
+
return
|
| 233 |
+
|
| 234 |
+
try:
|
| 235 |
+
attachment = None
|
| 236 |
+
if parsed.has_file:
|
| 237 |
+
if parsed.file_size > MAX_DOWNLOAD_BYTES:
|
| 238 |
+
await telegram.send_message(
|
| 239 |
+
parsed.chat_id,
|
| 240 |
+
"That file is over Telegram's 20 MB bot limit. "
|
| 241 |
+
"Try exporting a narrower date range.",
|
| 242 |
+
)
|
| 243 |
+
return
|
| 244 |
+
|
| 245 |
+
# Reading files costs the Space owner real inference credits, so
|
| 246 |
+
# cap it per user. Typing expenses stays unmetered and free.
|
| 247 |
+
if not app.state.limiter.check(parsed.user_id):
|
| 248 |
+
await telegram.send_message(
|
| 249 |
+
parsed.chat_id,
|
| 250 |
+
"You've hit today's limit for reading receipts and "
|
| 251 |
+
"statements. It resets in 24 hours — you can still log "
|
| 252 |
+
"expenses by typing them.",
|
| 253 |
+
)
|
| 254 |
+
return
|
| 255 |
+
|
| 256 |
+
await telegram.send_typing(parsed.chat_id)
|
| 257 |
+
data, file_path = await telegram.download(parsed.file_id)
|
| 258 |
+
attachment = Attachment(
|
| 259 |
+
data=data,
|
| 260 |
+
filename=parsed.file_name or file_path.rsplit("/", 1)[-1],
|
| 261 |
+
mime_type=parsed.mime_type,
|
| 262 |
+
kind=parsed.file_kind,
|
| 263 |
+
caption=parsed.caption,
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
ctx = BotContext(
|
| 267 |
+
user_id=parsed.user_id,
|
| 268 |
+
chat_id=parsed.chat_id,
|
| 269 |
+
services=services,
|
| 270 |
+
today=today_in(settings.timezone),
|
| 271 |
+
text=parsed.text or parsed.caption,
|
| 272 |
+
command=parsed.command,
|
| 273 |
+
args=parsed.args,
|
| 274 |
+
attachment=attachment,
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
if attachment is not None or parsed.command in {"advice", "report"}:
|
| 278 |
+
await telegram.send_typing(parsed.chat_id)
|
| 279 |
+
|
| 280 |
+
reply = await run_in_threadpool(services.registry.dispatch, ctx)
|
| 281 |
+
|
| 282 |
+
if reply is None:
|
| 283 |
+
if not parsed.command and ctx.text.strip():
|
| 284 |
+
await telegram.send_message(
|
| 285 |
+
parsed.chat_id,
|
| 286 |
+
"I couldn't find an amount in that. Try "
|
| 287 |
+
"<code>250 coffee</code>, or /help.",
|
| 288 |
+
)
|
| 289 |
+
return
|
| 290 |
+
|
| 291 |
+
if reply.document is not None:
|
| 292 |
+
filename, payload = reply.document
|
| 293 |
+
await telegram.send_document(
|
| 294 |
+
parsed.chat_id, filename, payload, caption=reply.text
|
| 295 |
+
)
|
| 296 |
+
return
|
| 297 |
+
|
| 298 |
+
await telegram.send_message(parsed.chat_id, reply.text, reply.parse_mode)
|
| 299 |
+
|
| 300 |
+
except Exception:
|
| 301 |
+
log.exception("Failed handling update %s", parsed.update_id)
|
| 302 |
+
try:
|
| 303 |
+
await telegram.send_message(
|
| 304 |
+
parsed.chat_id,
|
| 305 |
+
"Something went wrong on my side. The details are in the Space logs.",
|
| 306 |
+
)
|
| 307 |
+
except Exception:
|
| 308 |
+
pass
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
# -------------------------------------------------------------------- admin
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
@app.post("/admin/setup")
|
| 315 |
+
async def admin_setup(
|
| 316 |
+
request: Request,
|
| 317 |
+
x_admin_secret: Optional[str] = Header(default=None),
|
| 318 |
+
) -> JSONResponse:
|
| 319 |
+
"""Register the webhook manually (local dev behind a tunnel, or no SPACE_HOST).
|
| 320 |
+
|
| 321 |
+
Authorised with the same webhook secret, so no extra credential to manage.
|
| 322 |
+
"""
|
| 323 |
+
services: Services = app.state.services
|
| 324 |
+
settings = services.settings
|
| 325 |
+
telegram: TelegramClient = app.state.telegram
|
| 326 |
+
|
| 327 |
+
if not settings.telegram_webhook_secret or x_admin_secret != settings.telegram_webhook_secret:
|
| 328 |
+
return JSONResponse({"ok": False, "error": "forbidden"}, status_code=403)
|
| 329 |
+
|
| 330 |
+
body = {}
|
| 331 |
+
try:
|
| 332 |
+
body = await request.json()
|
| 333 |
+
except Exception:
|
| 334 |
+
pass
|
| 335 |
+
|
| 336 |
+
url = body.get("url") or space_webhook_url()
|
| 337 |
+
if not url:
|
| 338 |
+
return JSONResponse(
|
| 339 |
+
{"ok": False, "error": "no url given and SPACE_HOST is unset"},
|
| 340 |
+
status_code=400,
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
await telegram.set_webhook(url, settings.telegram_webhook_secret)
|
| 344 |
+
await telegram.set_my_commands(services.registry.telegram_commands())
|
| 345 |
+
info = await telegram.get_webhook_info()
|
| 346 |
+
return JSONResponse({"ok": True, "webhook": info})
|
deploy.sh
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# Push coco-finbot to a Hugging Face Space.
|
| 4 |
+
#
|
| 5 |
+
# Uses `hf upload` rather than git, deliberately: this directory sits inside the
|
| 6 |
+
# larger UniPhy repo, so `git push` here would try to push that entire tree --
|
| 7 |
+
# unrelated repos and any secrets they hold -- to a public-by-default host.
|
| 8 |
+
#
|
| 9 |
+
# Run `hf auth login` first. Secrets are NOT set here; put them in the Space
|
| 10 |
+
# Settings > Secrets UI so they never land in your shell history.
|
| 11 |
+
#
|
| 12 |
+
# Usage:
|
| 13 |
+
# ./deploy.sh # deploy under your own account
|
| 14 |
+
# ./deploy.sh Uniphy # deploy under an org you belong to
|
| 15 |
+
# ./deploy.sh "" my-bot my-data # custom space / dataset names
|
| 16 |
+
# ./deploy.sh --dry-run # show what would happen, create nothing
|
| 17 |
+
|
| 18 |
+
set -euo pipefail
|
| 19 |
+
|
| 20 |
+
DRY_RUN=0
|
| 21 |
+
if [[ "${1:-}" == "--dry-run" ]]; then
|
| 22 |
+
DRY_RUN=1
|
| 23 |
+
shift
|
| 24 |
+
fi
|
| 25 |
+
|
| 26 |
+
OWNER="${1:-}"
|
| 27 |
+
SPACE_NAME="${2:-coco-finbot}"
|
| 28 |
+
DATA_NAME="${3:-finbot-data}"
|
| 29 |
+
|
| 30 |
+
HF="./.venv/bin/hf"
|
| 31 |
+
PY="./.venv/bin/python"
|
| 32 |
+
[[ -x "$HF" ]] || HF="hf"
|
| 33 |
+
[[ -x "$PY" ]] || PY="python3"
|
| 34 |
+
|
| 35 |
+
echo "▸ Checking authentication"
|
| 36 |
+
# `hf auth whoami` prints ANSI-coloured labelled lines ("user: name") and exits
|
| 37 |
+
# 0 even when logged out, so ask the API for a clean value instead of scraping.
|
| 38 |
+
ACCOUNT="$(
|
| 39 |
+
"$PY" - <<'PY' 2>/dev/null || true
|
| 40 |
+
try:
|
| 41 |
+
from huggingface_hub import HfApi
|
| 42 |
+
print(HfApi().whoami()["name"])
|
| 43 |
+
except Exception:
|
| 44 |
+
pass
|
| 45 |
+
PY
|
| 46 |
+
)"
|
| 47 |
+
|
| 48 |
+
if [[ -z "$ACCOUNT" ]]; then
|
| 49 |
+
echo " Not logged in. Run: $HF auth login" >&2
|
| 50 |
+
exit 1
|
| 51 |
+
fi
|
| 52 |
+
echo " logged in as: $ACCOUNT"
|
| 53 |
+
|
| 54 |
+
OWNER="${OWNER:-$ACCOUNT}"
|
| 55 |
+
|
| 56 |
+
SPACE_ID="${OWNER}/${SPACE_NAME}"
|
| 57 |
+
DATA_ID="${OWNER}/${DATA_NAME}"
|
| 58 |
+
|
| 59 |
+
# Fail early with a readable message rather than a validation traceback.
|
| 60 |
+
if [[ ! "$OWNER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
| 61 |
+
echo " Invalid owner name: '$OWNER'" >&2
|
| 62 |
+
exit 1
|
| 63 |
+
fi
|
| 64 |
+
|
| 65 |
+
echo "▸ Target Space: $SPACE_ID (docker, private)"
|
| 66 |
+
echo "▸ Target dataset: $DATA_ID (private, durable ledger)"
|
| 67 |
+
|
| 68 |
+
if [[ "$DRY_RUN" == "1" ]]; then
|
| 69 |
+
echo
|
| 70 |
+
echo "(dry run — nothing created or uploaded)"
|
| 71 |
+
exit 0
|
| 72 |
+
fi
|
| 73 |
+
|
| 74 |
+
echo "▸ Creating Space"
|
| 75 |
+
"$HF" repo create "$SPACE_ID" --repo-type space --space_sdk docker --private --exist-ok
|
| 76 |
+
|
| 77 |
+
echo "▸ Creating dataset"
|
| 78 |
+
"$HF" repo create "$DATA_ID" --repo-type dataset --private --exist-ok
|
| 79 |
+
|
| 80 |
+
echo "▸ Uploading source"
|
| 81 |
+
# Everything the container does not need, or that must never leave this machine.
|
| 82 |
+
"$HF" upload "$SPACE_ID" . . \
|
| 83 |
+
--repo-type space \
|
| 84 |
+
--commit-message "Deploy coco-finbot" \
|
| 85 |
+
--exclude \
|
| 86 |
+
".venv/*" \
|
| 87 |
+
"**/__pycache__/*" \
|
| 88 |
+
".pytest_cache/*" \
|
| 89 |
+
".ruff_cache/*" \
|
| 90 |
+
"data/*" \
|
| 91 |
+
"*.db" \
|
| 92 |
+
".env" \
|
| 93 |
+
".git/*"
|
| 94 |
+
|
| 95 |
+
SECRET="$("$PY" -c "import secrets; print(secrets.token_urlsafe(32))" 2>/dev/null || echo "<generate a random string>")"
|
| 96 |
+
|
| 97 |
+
cat <<EOF
|
| 98 |
+
|
| 99 |
+
✅ Uploaded to https://huggingface.co/spaces/${SPACE_ID}
|
| 100 |
+
|
| 101 |
+
Still to do (only you can do these):
|
| 102 |
+
|
| 103 |
+
1. Space Settings > Secrets, add:
|
| 104 |
+
TELEGRAM_BOT_TOKEN from @BotFather
|
| 105 |
+
TELEGRAM_WEBHOOK_SECRET ${SECRET}
|
| 106 |
+
ALLOWED_TELEGRAM_USER_IDS your numeric Telegram id
|
| 107 |
+
HF_DATASET_REPO ${DATA_ID}
|
| 108 |
+
HF_TOKEN a write token with Inference Providers access
|
| 109 |
+
|
| 110 |
+
2. Confirm the Space is Private (Settings > Change visibility).
|
| 111 |
+
|
| 112 |
+
3. Restart the Space. It registers its own Telegram webhook on boot.
|
| 113 |
+
|
| 114 |
+
4. Message your bot. If you don't know your Telegram id, send it anything --
|
| 115 |
+
it replies with the id to paste into ALLOWED_TELEGRAM_USER_IDS, then
|
| 116 |
+
restart the Space once more.
|
| 117 |
+
|
| 118 |
+
5. Send /status and confirm storage says "mirroring", not "local only".
|
| 119 |
+
EOF
|
finbot/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""coco-finbot -- personal expense tracking and budget coaching over Telegram.
|
| 2 |
+
|
| 3 |
+
Designed to run as a Hugging Face Docker Space. Two constraints from that
|
| 4 |
+
environment shape the whole design:
|
| 5 |
+
|
| 6 |
+
* Space disk is **ephemeral** -- wiped on every restart, sleep and rebuild. All
|
| 7 |
+
durable state therefore lives in a private Hub dataset (see
|
| 8 |
+
:mod:`finbot.store`), with SQLite as a local working copy.
|
| 9 |
+
* Free Spaces **sleep** after inactivity, so the bot is webhook-driven rather
|
| 10 |
+
than long-polling.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
__version__ = "0.1.0"
|
finbot/advice.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Budget coaching grounded in the user's own numbers.
|
| 2 |
+
|
| 3 |
+
Two deliberate constraints:
|
| 4 |
+
|
| 5 |
+
* **Grounded.** The model is handed a computed brief -- real totals, real
|
| 6 |
+
categories, real recurring charges -- and told to comment only on that. It
|
| 7 |
+
never sees a free-form question it can answer from general knowledge, so it
|
| 8 |
+
cannot invent a number that looks authoritative and is wrong.
|
| 9 |
+
* **Scoped to budgeting.** This is a spending coach, not a financial adviser.
|
| 10 |
+
The prompt forbids investment, tax and legal recommendations, and the
|
| 11 |
+
fallback path below is pure arithmetic over the ledger.
|
| 12 |
+
|
| 13 |
+
If inference is unavailable the deterministic observations still fire, so
|
| 14 |
+
``/advice`` always returns something useful.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
from datetime import date
|
| 21 |
+
from typing import Optional
|
| 22 |
+
|
| 23 |
+
from .analytics import (
|
| 24 |
+
BudgetStatus,
|
| 25 |
+
Recurring,
|
| 26 |
+
by_category,
|
| 27 |
+
dominant_currency,
|
| 28 |
+
month_bounds,
|
| 29 |
+
project_month_end,
|
| 30 |
+
top_merchants,
|
| 31 |
+
total_minor,
|
| 32 |
+
)
|
| 33 |
+
from .categories import label as category_label
|
| 34 |
+
from .llm import LLMClient, LLMUnavailable
|
| 35 |
+
from .models import Expense
|
| 36 |
+
from .money import format_money
|
| 37 |
+
|
| 38 |
+
log = logging.getLogger(__name__)
|
| 39 |
+
|
| 40 |
+
DISCLAIMER = (
|
| 41 |
+
"_General budgeting observations based only on what you've logged — "
|
| 42 |
+
"not financial advice._"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
_SYSTEM = """You are a personal budgeting coach embedded in a Telegram bot.
|
| 46 |
+
|
| 47 |
+
You will be given a factual brief computed from one person's own logged
|
| 48 |
+
spending. Comment ONLY on that brief.
|
| 49 |
+
|
| 50 |
+
Hard rules:
|
| 51 |
+
- Never recommend specific investments, securities, funds, stocks, crypto,
|
| 52 |
+
insurance or financial products, and never suggest how to allocate savings.
|
| 53 |
+
You are not a licensed financial adviser. If the user seems to want that,
|
| 54 |
+
say plainly that it is outside what you do.
|
| 55 |
+
- Never give tax or legal advice.
|
| 56 |
+
- Never invent a number. Every figure you cite must appear in the brief.
|
| 57 |
+
- If the brief is thin, say the data is thin rather than padding it out.
|
| 58 |
+
- Do not moralise about the spending. The user asked for observations, not
|
| 59 |
+
judgement about whether their choices were wise.
|
| 60 |
+
|
| 61 |
+
Style: 120 words or fewer, plain sentences, at most three short bullets. Lead
|
| 62 |
+
with the single most useful observation. Be concrete and specific."""
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _fmt_pct(value: float) -> str:
|
| 66 |
+
return f"{value:+.0f}%"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def build_brief(
|
| 70 |
+
expenses_this_month: list[Expense],
|
| 71 |
+
expenses_last_month: list[Expense],
|
| 72 |
+
recurring: list[Recurring],
|
| 73 |
+
budgets: list[BudgetStatus],
|
| 74 |
+
currency: str,
|
| 75 |
+
today: date,
|
| 76 |
+
) -> str:
|
| 77 |
+
"""Compose the factual brief the model is allowed to talk about."""
|
| 78 |
+
start, _ = month_bounds(today)
|
| 79 |
+
this_total = total_minor(expenses_this_month)
|
| 80 |
+
last_total = total_minor(expenses_last_month)
|
| 81 |
+
|
| 82 |
+
lines: list[str] = [
|
| 83 |
+
f"Date: {today.isoformat()}",
|
| 84 |
+
f"Currency: {currency}",
|
| 85 |
+
f"Month to date ({start.isoformat()} to {today.isoformat()}): "
|
| 86 |
+
f"{format_money(this_total, currency)} across {len(expenses_this_month)} transactions",
|
| 87 |
+
f"Same period last month: {format_money(last_total, currency)}",
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
if last_total:
|
| 91 |
+
change = (this_total - last_total) / last_total * 100
|
| 92 |
+
lines.append(f"Change vs last month: {_fmt_pct(change)}")
|
| 93 |
+
|
| 94 |
+
projected = project_month_end(expenses_this_month, today)
|
| 95 |
+
lines.append(f"Projected month-end at current pace: {format_money(projected, currency)}")
|
| 96 |
+
|
| 97 |
+
if categories := by_category(expenses_this_month):
|
| 98 |
+
lines.append("Top categories this month:")
|
| 99 |
+
for name, amount in categories[:6]:
|
| 100 |
+
share = amount / this_total * 100 if this_total else 0
|
| 101 |
+
lines.append(
|
| 102 |
+
f" - {category_label(name)}: {format_money(amount, currency)} ({share:.0f}%)"
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
if merchants := top_merchants(expenses_this_month, limit=5):
|
| 106 |
+
lines.append("Top merchants this month:")
|
| 107 |
+
for name, amount in merchants:
|
| 108 |
+
lines.append(f" - {name}: {format_money(amount, currency)}")
|
| 109 |
+
|
| 110 |
+
if recurring:
|
| 111 |
+
lines.append("Recurring charges detected:")
|
| 112 |
+
for item in recurring[:8]:
|
| 113 |
+
lines.append(
|
| 114 |
+
f" - {item.label}: {format_money(item.typical_minor, item.currency)}/month "
|
| 115 |
+
f"(seen in {item.months} months, last {item.last_seen.isoformat()})"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
if budgets:
|
| 119 |
+
lines.append("Budgets this month:")
|
| 120 |
+
for status in budgets:
|
| 121 |
+
state = "OVER" if status.over else "within"
|
| 122 |
+
lines.append(
|
| 123 |
+
f" - {category_label(status.category)}: "
|
| 124 |
+
f"{format_money(status.spent_minor, status.currency)} of "
|
| 125 |
+
f"{format_money(status.limit_minor, status.currency)} ({status.pct:.0f}%, {state})"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
return "\n".join(lines)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def deterministic_observations(
|
| 132 |
+
expenses_this_month: list[Expense],
|
| 133 |
+
expenses_last_month: list[Expense],
|
| 134 |
+
recurring: list[Recurring],
|
| 135 |
+
budgets: list[BudgetStatus],
|
| 136 |
+
currency: str,
|
| 137 |
+
today: date,
|
| 138 |
+
) -> list[str]:
|
| 139 |
+
"""Rule-based findings. Always available, no inference required."""
|
| 140 |
+
notes: list[str] = []
|
| 141 |
+
this_total = total_minor(expenses_this_month)
|
| 142 |
+
last_total = total_minor(expenses_last_month)
|
| 143 |
+
|
| 144 |
+
if not expenses_this_month:
|
| 145 |
+
return ["Nothing logged this month yet — send me an expense to get started."]
|
| 146 |
+
|
| 147 |
+
if last_total:
|
| 148 |
+
change = (this_total - last_total) / last_total * 100
|
| 149 |
+
if abs(change) >= 10:
|
| 150 |
+
direction = "up" if change > 0 else "down"
|
| 151 |
+
notes.append(
|
| 152 |
+
f"Spending is {direction} {abs(change):.0f}% on last month "
|
| 153 |
+
f"({format_money(this_total, currency)} vs {format_money(last_total, currency)})."
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
projected = project_month_end(expenses_this_month, today)
|
| 157 |
+
if projected > this_total:
|
| 158 |
+
notes.append(
|
| 159 |
+
f"At this pace you'll finish the month around {format_money(projected, currency)}."
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
categories = by_category(expenses_this_month)
|
| 163 |
+
if categories and this_total:
|
| 164 |
+
name, amount = categories[0]
|
| 165 |
+
share = amount / this_total * 100
|
| 166 |
+
if share >= 30:
|
| 167 |
+
notes.append(
|
| 168 |
+
f"{category_label(name)} is {share:.0f}% of this month's spend "
|
| 169 |
+
f"({format_money(amount, currency)})."
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
for status in budgets:
|
| 173 |
+
if status.over:
|
| 174 |
+
notes.append(
|
| 175 |
+
f"{category_label(status.category)} is over budget by "
|
| 176 |
+
f"{format_money(-status.remaining_minor, status.currency)}."
|
| 177 |
+
)
|
| 178 |
+
elif status.pct >= 80:
|
| 179 |
+
notes.append(
|
| 180 |
+
f"{category_label(status.category)} is at {status.pct:.0f}% of budget."
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
if recurring:
|
| 184 |
+
annual = sum(r.annualised_minor for r in recurring if r.currency == currency)
|
| 185 |
+
if annual:
|
| 186 |
+
notes.append(
|
| 187 |
+
f"{len(recurring)} recurring charges detected, "
|
| 188 |
+
f"about {format_money(annual, currency)} a year if all continue."
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
return notes or ["Nothing unusual stands out this month."]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class Advisor:
|
| 195 |
+
def __init__(self, llm: LLMClient):
|
| 196 |
+
self.llm = llm
|
| 197 |
+
|
| 198 |
+
def advise(
|
| 199 |
+
self,
|
| 200 |
+
expenses_this_month: list[Expense],
|
| 201 |
+
expenses_last_month: list[Expense],
|
| 202 |
+
recurring: list[Recurring],
|
| 203 |
+
budgets: list[BudgetStatus],
|
| 204 |
+
default_currency: str,
|
| 205 |
+
today: date,
|
| 206 |
+
question: Optional[str] = None,
|
| 207 |
+
) -> str:
|
| 208 |
+
currency = dominant_currency(expenses_this_month, default_currency)
|
| 209 |
+
facts = deterministic_observations(
|
| 210 |
+
expenses_this_month, expenses_last_month, recurring, budgets, currency, today
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
if not self.llm.enabled or not expenses_this_month:
|
| 214 |
+
return "\n".join(f"• {note}" for note in facts)
|
| 215 |
+
|
| 216 |
+
brief = build_brief(
|
| 217 |
+
expenses_this_month, expenses_last_month, recurring, budgets, currency, today
|
| 218 |
+
)
|
| 219 |
+
user_block = f"Brief:\n{brief}"
|
| 220 |
+
if question:
|
| 221 |
+
user_block += (
|
| 222 |
+
f"\n\nThe user asked: {question}\n"
|
| 223 |
+
"Answer only if the brief supports it; otherwise say what you'd "
|
| 224 |
+
"need them to log."
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
try:
|
| 228 |
+
response = self.llm.chat(
|
| 229 |
+
[
|
| 230 |
+
{"role": "system", "content": _SYSTEM},
|
| 231 |
+
{"role": "user", "content": user_block},
|
| 232 |
+
],
|
| 233 |
+
temperature=0.3,
|
| 234 |
+
max_tokens=500,
|
| 235 |
+
)
|
| 236 |
+
except LLMUnavailable as exc:
|
| 237 |
+
log.info("Advice fell back to deterministic notes: %s", exc)
|
| 238 |
+
return "\n".join(f"• {note}" for note in facts)
|
| 239 |
+
|
| 240 |
+
response = (response or "").strip()
|
| 241 |
+
if not response:
|
| 242 |
+
return "\n".join(f"• {note}" for note in facts)
|
| 243 |
+
return response
|
finbot/analytics.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aggregation over the ledger.
|
| 2 |
+
|
| 3 |
+
All pure functions over lists of expenses -- no I/O, no model calls -- so the
|
| 4 |
+
numbers are testable and always reproducible. Everything is computed
|
| 5 |
+
per-currency: summing INR and USD into one figure would be meaningless, so a
|
| 6 |
+
multi-currency ledger yields one summary per currency rather than a fictional
|
| 7 |
+
total.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import re
|
| 13 |
+
from calendar import monthrange
|
| 14 |
+
from collections import defaultdict
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from datetime import date, datetime, timedelta
|
| 17 |
+
from typing import Iterable, Optional
|
| 18 |
+
from zoneinfo import ZoneInfo
|
| 19 |
+
|
| 20 |
+
from .models import Expense, Summary
|
| 21 |
+
|
| 22 |
+
PERIODS = ("today", "yesterday", "week", "month", "last_month", "year", "30d", "all")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def today_in(timezone: str) -> date:
|
| 26 |
+
"""Current date in the user's timezone.
|
| 27 |
+
|
| 28 |
+
The container runs in UTC; for a user in Asia/Kolkata a spend logged at
|
| 29 |
+
2am local would otherwise land on the previous day.
|
| 30 |
+
"""
|
| 31 |
+
try:
|
| 32 |
+
return datetime.now(ZoneInfo(timezone)).date()
|
| 33 |
+
except Exception:
|
| 34 |
+
return date.today()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def month_bounds(day: date) -> tuple[date, date]:
|
| 38 |
+
return day.replace(day=1), day.replace(day=monthrange(day.year, day.month)[1])
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def previous_month_bounds(day: date) -> tuple[date, date]:
|
| 42 |
+
first = day.replace(day=1)
|
| 43 |
+
last_prev = first - timedelta(days=1)
|
| 44 |
+
return month_bounds(last_prev)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def period_bounds(name: str, today: date) -> tuple[date, date, str]:
|
| 48 |
+
"""Resolve a period keyword to (start, end, human label)."""
|
| 49 |
+
name = (name or "month").lower().strip()
|
| 50 |
+
|
| 51 |
+
if name == "today":
|
| 52 |
+
return today, today, "Today"
|
| 53 |
+
if name == "yesterday":
|
| 54 |
+
day = today - timedelta(days=1)
|
| 55 |
+
return day, day, "Yesterday"
|
| 56 |
+
if name == "week":
|
| 57 |
+
start = today - timedelta(days=today.weekday())
|
| 58 |
+
return start, today, "This week"
|
| 59 |
+
if name == "last_month":
|
| 60 |
+
start, end = previous_month_bounds(today)
|
| 61 |
+
return start, end, start.strftime("%B %Y")
|
| 62 |
+
if name == "year":
|
| 63 |
+
return today.replace(month=1, day=1), today, str(today.year)
|
| 64 |
+
if name == "30d":
|
| 65 |
+
return today - timedelta(days=29), today, "Last 30 days"
|
| 66 |
+
if name == "all":
|
| 67 |
+
return date(1970, 1, 1), today, "All time"
|
| 68 |
+
|
| 69 |
+
start, _ = month_bounds(today)
|
| 70 |
+
return start, today, today.strftime("%B %Y")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def split_by_currency(expenses: Iterable[Expense]) -> dict[str, list[Expense]]:
|
| 74 |
+
buckets: dict[str, list[Expense]] = defaultdict(list)
|
| 75 |
+
for expense in expenses:
|
| 76 |
+
buckets[expense.currency.upper()].append(expense)
|
| 77 |
+
return dict(buckets)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def dominant_currency(expenses: Iterable[Expense], fallback: str) -> str:
|
| 81 |
+
buckets = split_by_currency(expenses)
|
| 82 |
+
if not buckets:
|
| 83 |
+
return fallback.upper()
|
| 84 |
+
return max(buckets.items(), key=lambda kv: (len(kv[1]), sum(e.amount_minor for e in kv[1])))[0]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def total_minor(expenses: Iterable[Expense]) -> int:
|
| 88 |
+
return sum(e.amount_minor for e in expenses)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def by_category(expenses: Iterable[Expense]) -> list[tuple[str, int]]:
|
| 92 |
+
totals: dict[str, int] = defaultdict(int)
|
| 93 |
+
for expense in expenses:
|
| 94 |
+
totals[expense.category] += expense.amount_minor
|
| 95 |
+
return sorted(totals.items(), key=lambda kv: kv[1], reverse=True)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def summarise(
|
| 99 |
+
expenses: list[Expense],
|
| 100 |
+
currency: str,
|
| 101 |
+
label: str,
|
| 102 |
+
start: date,
|
| 103 |
+
end: date,
|
| 104 |
+
previous: Optional[list[Expense]] = None,
|
| 105 |
+
) -> Summary:
|
| 106 |
+
scoped = [e for e in expenses if e.currency.upper() == currency.upper()]
|
| 107 |
+
previous_total = None
|
| 108 |
+
if previous is not None:
|
| 109 |
+
previous_total = total_minor(
|
| 110 |
+
[e for e in previous if e.currency.upper() == currency.upper()]
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
return Summary(
|
| 114 |
+
label=label,
|
| 115 |
+
start=start,
|
| 116 |
+
end=end,
|
| 117 |
+
currency=currency.upper(),
|
| 118 |
+
total_minor=total_minor(scoped),
|
| 119 |
+
count=len(scoped),
|
| 120 |
+
by_category=by_category(scoped),
|
| 121 |
+
previous_total_minor=previous_total,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ----------------------------------------------------------------- recurring
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@dataclass
|
| 129 |
+
class Recurring:
|
| 130 |
+
label: str
|
| 131 |
+
category: str
|
| 132 |
+
currency: str
|
| 133 |
+
typical_minor: int
|
| 134 |
+
occurrences: int
|
| 135 |
+
months: int
|
| 136 |
+
last_seen: date
|
| 137 |
+
|
| 138 |
+
@property
|
| 139 |
+
def annualised_minor(self) -> int:
|
| 140 |
+
return self.typical_minor * 12
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
_NOISE_RE = re.compile(r"\b(?:\d{4,}|ref|txn|upi|no|id)\b[-\s]*\w*", re.I)
|
| 144 |
+
_TLD_RE = re.compile(r"\.(?:com|in|co|net|org|io|app|me|shop|store)\b", re.I)
|
| 145 |
+
|
| 146 |
+
#: Structural filler that appears in card narrations regardless of merchant.
|
| 147 |
+
#: Deliberately not locale-specific -- no city or country names, which would
|
| 148 |
+
#: only work for whichever market we happened to hardcode.
|
| 149 |
+
_NOISE_TOKENS = frozenset(
|
| 150 |
+
{
|
| 151 |
+
"com", "www", "net", "org", "ltd", "pvt", "inc", "llc", "limited",
|
| 152 |
+
"the", "pos", "pay", "payment", "purchase", "online", "debit",
|
| 153 |
+
"card", "transaction", "auto", "recurring",
|
| 154 |
+
}
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _merchant_key(expense: Expense) -> str:
|
| 159 |
+
"""Normalise a merchant so 'NETFLIX.COM 447192' and 'Netflix' collapse.
|
| 160 |
+
|
| 161 |
+
Purely lexical, so it will not unify names that share no words
|
| 162 |
+
("AMZN Mktp" vs "Amazon"). Keeping only the first few significant tokens
|
| 163 |
+
absorbs the trailing reference numbers and city suffixes banks append.
|
| 164 |
+
"""
|
| 165 |
+
raw = (expense.merchant or expense.description or "").lower()
|
| 166 |
+
raw = _TLD_RE.sub(" ", raw)
|
| 167 |
+
raw = _NOISE_RE.sub(" ", raw)
|
| 168 |
+
raw = re.sub(r"[^a-z ]+", " ", raw)
|
| 169 |
+
tokens = [
|
| 170 |
+
t for t in raw.split() if len(t) > 2 and t not in _NOISE_TOKENS
|
| 171 |
+
]
|
| 172 |
+
return " ".join(tokens[:3])
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def detect_recurring(
|
| 176 |
+
expenses: Iterable[Expense],
|
| 177 |
+
min_months: int = 3,
|
| 178 |
+
tolerance: float = 0.20,
|
| 179 |
+
) -> list[Recurring]:
|
| 180 |
+
"""Find charges that repeat monthly at a similar amount.
|
| 181 |
+
|
| 182 |
+
Requires hits in at least ``min_months`` distinct calendar months so that
|
| 183 |
+
three coffees in one week don't register as a subscription.
|
| 184 |
+
"""
|
| 185 |
+
groups: dict[tuple[str, str], list[Expense]] = defaultdict(list)
|
| 186 |
+
for expense in expenses:
|
| 187 |
+
key = _merchant_key(expense)
|
| 188 |
+
if not key:
|
| 189 |
+
continue
|
| 190 |
+
groups[(key, expense.currency.upper())].append(expense)
|
| 191 |
+
|
| 192 |
+
found: list[Recurring] = []
|
| 193 |
+
for (key, currency), items in groups.items():
|
| 194 |
+
months = {(e.occurred_on.year, e.occurred_on.month) for e in items}
|
| 195 |
+
if len(months) < min_months:
|
| 196 |
+
continue
|
| 197 |
+
|
| 198 |
+
amounts = sorted(e.amount_minor for e in items)
|
| 199 |
+
median = amounts[len(amounts) // 2]
|
| 200 |
+
if median <= 0:
|
| 201 |
+
continue
|
| 202 |
+
|
| 203 |
+
# Consistent amount is what separates a subscription from ordinary
|
| 204 |
+
# repeat spending at the same shop.
|
| 205 |
+
consistent = [a for a in amounts if abs(a - median) <= median * tolerance]
|
| 206 |
+
if len(consistent) < len(amounts) * 0.6:
|
| 207 |
+
continue
|
| 208 |
+
|
| 209 |
+
best = max(items, key=lambda e: e.occurred_on)
|
| 210 |
+
found.append(
|
| 211 |
+
Recurring(
|
| 212 |
+
label=(best.merchant or best.description or key).strip()[:60],
|
| 213 |
+
category=best.category,
|
| 214 |
+
currency=currency,
|
| 215 |
+
typical_minor=median,
|
| 216 |
+
occurrences=len(items),
|
| 217 |
+
months=len(months),
|
| 218 |
+
last_seen=best.occurred_on,
|
| 219 |
+
)
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
return sorted(found, key=lambda r: r.typical_minor, reverse=True)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ------------------------------------------------------------------- budgets
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
@dataclass
|
| 229 |
+
class BudgetStatus:
|
| 230 |
+
category: str
|
| 231 |
+
currency: str
|
| 232 |
+
limit_minor: int
|
| 233 |
+
spent_minor: int
|
| 234 |
+
|
| 235 |
+
@property
|
| 236 |
+
def pct(self) -> float:
|
| 237 |
+
if self.limit_minor <= 0:
|
| 238 |
+
return 0.0
|
| 239 |
+
return self.spent_minor / self.limit_minor * 100.0
|
| 240 |
+
|
| 241 |
+
@property
|
| 242 |
+
def remaining_minor(self) -> int:
|
| 243 |
+
return self.limit_minor - self.spent_minor
|
| 244 |
+
|
| 245 |
+
@property
|
| 246 |
+
def over(self) -> bool:
|
| 247 |
+
return self.spent_minor > self.limit_minor
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def budget_status(
|
| 251 |
+
budgets: Iterable, month_expenses: Iterable[Expense]
|
| 252 |
+
) -> list[BudgetStatus]:
|
| 253 |
+
spent: dict[tuple[str, str], int] = defaultdict(int)
|
| 254 |
+
for expense in month_expenses:
|
| 255 |
+
spent[(expense.category, expense.currency.upper())] += expense.amount_minor
|
| 256 |
+
|
| 257 |
+
statuses = [
|
| 258 |
+
BudgetStatus(
|
| 259 |
+
category=budget.category,
|
| 260 |
+
currency=budget.currency.upper(),
|
| 261 |
+
limit_minor=budget.amount_minor,
|
| 262 |
+
spent_minor=spent.get((budget.category, budget.currency.upper()), 0),
|
| 263 |
+
)
|
| 264 |
+
for budget in budgets
|
| 265 |
+
]
|
| 266 |
+
return sorted(statuses, key=lambda s: s.pct, reverse=True)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def daily_burn(expenses: list[Expense], start: date, end: date) -> float:
|
| 270 |
+
"""Average spend per elapsed day, in minor units."""
|
| 271 |
+
days = max(1, (end - start).days + 1)
|
| 272 |
+
return total_minor(expenses) / days
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def project_month_end(expenses: list[Expense], today: date) -> int:
|
| 276 |
+
"""Straight-line projection of this month's total from spend so far."""
|
| 277 |
+
start, end = month_bounds(today)
|
| 278 |
+
elapsed = max(1, (today - start).days + 1)
|
| 279 |
+
days_in_month = (end - start).days + 1
|
| 280 |
+
return int(total_minor(expenses) / elapsed * days_in_month)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def top_merchants(expenses: Iterable[Expense], limit: int = 5) -> list[tuple[str, int]]:
|
| 284 |
+
totals: dict[str, int] = defaultdict(int)
|
| 285 |
+
for expense in expenses:
|
| 286 |
+
label = (expense.merchant or expense.description or "unlabelled").strip()[:40]
|
| 287 |
+
totals[label] += expense.amount_minor
|
| 288 |
+
return sorted(totals.items(), key=lambda kv: kv[1], reverse=True)[:limit]
|
finbot/categories.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Category taxonomy and deterministic keyword matching.
|
| 2 |
+
|
| 3 |
+
Rules run before any model call. Most everyday spend ("uber", "netflix",
|
| 4 |
+
"petrol") is unambiguous, and a lookup is free, instant and reproducible where
|
| 5 |
+
a model call is none of those. The LLM only sees what the rules could not
|
| 6 |
+
place.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
|
| 13 |
+
#: Canonical categories. Keep this list short -- a taxonomy nobody can hold in
|
| 14 |
+
#: their head produces inconsistent categorisation, which makes trends lie.
|
| 15 |
+
CATEGORIES: tuple[str, ...] = (
|
| 16 |
+
"groceries",
|
| 17 |
+
"food_dining",
|
| 18 |
+
"transport",
|
| 19 |
+
"fuel",
|
| 20 |
+
"shopping",
|
| 21 |
+
"utilities",
|
| 22 |
+
"rent",
|
| 23 |
+
"health",
|
| 24 |
+
"entertainment",
|
| 25 |
+
"subscriptions",
|
| 26 |
+
"education",
|
| 27 |
+
"travel",
|
| 28 |
+
"fees_charges",
|
| 29 |
+
"transfers",
|
| 30 |
+
"cash",
|
| 31 |
+
"income",
|
| 32 |
+
"other",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
#: Human labels for display.
|
| 36 |
+
LABELS: dict[str, str] = {
|
| 37 |
+
"groceries": "Groceries",
|
| 38 |
+
"food_dining": "Food & Dining",
|
| 39 |
+
"transport": "Transport",
|
| 40 |
+
"fuel": "Fuel",
|
| 41 |
+
"shopping": "Shopping",
|
| 42 |
+
"utilities": "Utilities",
|
| 43 |
+
"rent": "Rent & Housing",
|
| 44 |
+
"health": "Health",
|
| 45 |
+
"entertainment": "Entertainment",
|
| 46 |
+
"subscriptions": "Subscriptions",
|
| 47 |
+
"education": "Education",
|
| 48 |
+
"travel": "Travel",
|
| 49 |
+
"fees_charges": "Fees & Charges",
|
| 50 |
+
"transfers": "Transfers",
|
| 51 |
+
"cash": "Cash",
|
| 52 |
+
"income": "Income",
|
| 53 |
+
"other": "Other",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# Ordered most-specific first: "fuel" must win over "transport" for a petrol
|
| 57 |
+
# pump, and "subscriptions" over "entertainment" for Netflix.
|
| 58 |
+
_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
| 59 |
+
(
|
| 60 |
+
"subscriptions",
|
| 61 |
+
(
|
| 62 |
+
"netflix", "spotify", "prime video", "hotstar", "disney+", "youtube premium",
|
| 63 |
+
"icloud", "google one", "dropbox", "notion", "figma", "adobe", "github",
|
| 64 |
+
"openai", "chatgpt", "claude", "anthropic", "midjourney", "subscription",
|
| 65 |
+
"patreon", "substack", "audible", "kindle unlimited",
|
| 66 |
+
),
|
| 67 |
+
),
|
| 68 |
+
(
|
| 69 |
+
"fuel",
|
| 70 |
+
(
|
| 71 |
+
"petrol", "diesel", "fuel", "gas station", "shell", "bharat petroleum",
|
| 72 |
+
"hp petrol", "indian oil", "iocl", "bpcl", "hpcl", "chevron", "exxon",
|
| 73 |
+
),
|
| 74 |
+
),
|
| 75 |
+
(
|
| 76 |
+
"transport",
|
| 77 |
+
(
|
| 78 |
+
"uber", "ola", "lyft", "rapido", "taxi", "cab", "metro", "bus", "train",
|
| 79 |
+
"irctc", "railway", "toll", "fastag", "parking", "auto rickshaw", "grab",
|
| 80 |
+
"bolt", "blablacar",
|
| 81 |
+
),
|
| 82 |
+
),
|
| 83 |
+
(
|
| 84 |
+
"groceries",
|
| 85 |
+
(
|
| 86 |
+
"grocery", "groceries", "supermarket", "bigbasket", "blinkit", "zepto",
|
| 87 |
+
"dmart", "reliance fresh", "more retail", "spencer", "tesco", "aldi",
|
| 88 |
+
"lidl", "walmart", "costco", "sainsbury", "kroger", "instacart",
|
| 89 |
+
"vegetables", "fruits", "milk", "kirana",
|
| 90 |
+
),
|
| 91 |
+
),
|
| 92 |
+
(
|
| 93 |
+
"food_dining",
|
| 94 |
+
(
|
| 95 |
+
"swiggy", "zomato", "doordash", "ubereats", "deliveroo", "just eat",
|
| 96 |
+
"restaurant", "cafe", "coffee", "starbucks", "mcdonald", "kfc", "domino",
|
| 97 |
+
"pizza", "burger", "dinner", "lunch", "breakfast", "snack", "chai",
|
| 98 |
+
"bakery", "biryani", "food", "dining", "brunch", "takeaway",
|
| 99 |
+
),
|
| 100 |
+
),
|
| 101 |
+
(
|
| 102 |
+
"utilities",
|
| 103 |
+
(
|
| 104 |
+
"electricity", "water bill", "gas bill", "broadband", "internet", "wifi",
|
| 105 |
+
"airtel", "jio", "vodafone", "vi postpaid", "bsnl", "act fibernet",
|
| 106 |
+
"recharge", "mobile bill", "phone bill", "dth", "tata power", "adani",
|
| 107 |
+
"utility", "council tax",
|
| 108 |
+
),
|
| 109 |
+
),
|
| 110 |
+
(
|
| 111 |
+
"rent",
|
| 112 |
+
("rent", "landlord", "lease", "maintenance charge", "society charge", "mortgage"),
|
| 113 |
+
),
|
| 114 |
+
(
|
| 115 |
+
"health",
|
| 116 |
+
(
|
| 117 |
+
"pharmacy", "chemist", "apollo", "medplus", "1mg", "pharmeasy", "hospital",
|
| 118 |
+
"clinic", "doctor", "dentist", "medicine", "medical", "diagnostic", "lab test",
|
| 119 |
+
"insurance premium", "gym", "fitness", "cult.fit", "physio", "optician",
|
| 120 |
+
),
|
| 121 |
+
),
|
| 122 |
+
(
|
| 123 |
+
"entertainment",
|
| 124 |
+
(
|
| 125 |
+
"movie", "cinema", "pvr", "inox", "bookmyshow", "concert", "theatre",
|
| 126 |
+
"game", "steam", "playstation", "xbox", "nintendo", "bar", "pub", "club",
|
| 127 |
+
),
|
| 128 |
+
),
|
| 129 |
+
(
|
| 130 |
+
"shopping",
|
| 131 |
+
(
|
| 132 |
+
"amazon", "flipkart", "myntra", "ajio", "meesho", "nykaa", "ikea", "zara",
|
| 133 |
+
"h&m", "uniqlo", "decathlon", "croma", "reliance digital", "apple store",
|
| 134 |
+
"ebay", "etsy", "aliexpress", "shein", "clothes", "shoes", "electronics",
|
| 135 |
+
),
|
| 136 |
+
),
|
| 137 |
+
(
|
| 138 |
+
"travel",
|
| 139 |
+
(
|
| 140 |
+
"flight", "airline", "indigo", "vistara", "air india", "spicejet", "emirates",
|
| 141 |
+
"makemytrip", "goibibo", "cleartrip", "booking.com", "airbnb", "oyo", "hotel",
|
| 142 |
+
"hostel", "visa fee", "passport", "travel",
|
| 143 |
+
),
|
| 144 |
+
),
|
| 145 |
+
(
|
| 146 |
+
"education",
|
| 147 |
+
(
|
| 148 |
+
"course", "udemy", "coursera", "tuition", "school fee", "college", "university",
|
| 149 |
+
"textbook", "exam fee", "byju", "unacademy", "edx", "workshop",
|
| 150 |
+
),
|
| 151 |
+
),
|
| 152 |
+
(
|
| 153 |
+
"fees_charges",
|
| 154 |
+
(
|
| 155 |
+
"bank charge", "service charge", "convenience fee", "processing fee",
|
| 156 |
+
"late fee", "penalty", "interest charge", "gst", "atm charge", "annual fee",
|
| 157 |
+
"overdraft", "forex markup",
|
| 158 |
+
),
|
| 159 |
+
),
|
| 160 |
+
(
|
| 161 |
+
"transfers",
|
| 162 |
+
(
|
| 163 |
+
"transfer", "neft", "imps", "rtgs", "upi to", "sent to", "paytm wallet",
|
| 164 |
+
"self transfer", "credit card payment", "loan emi", "emi",
|
| 165 |
+
),
|
| 166 |
+
),
|
| 167 |
+
("cash", ("atm", "cash withdrawal", "withdrawal", "cash")),
|
| 168 |
+
(
|
| 169 |
+
"income",
|
| 170 |
+
("salary", "payroll", "credited", "refund", "cashback", "interest earned", "dividend"),
|
| 171 |
+
),
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _normalise(text: str) -> str:
|
| 176 |
+
return re.sub(r"\s+", " ", text.lower()).strip()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def categorise(text: str, merchant: str = "") -> tuple[str, float]:
|
| 180 |
+
"""Map free text to a category deterministically.
|
| 181 |
+
|
| 182 |
+
Returns (category, confidence). Confidence is 1.0 for a keyword hit and 0.0
|
| 183 |
+
for the ``other`` fallback, which is the signal the caller uses to decide
|
| 184 |
+
whether to spend a model call on it.
|
| 185 |
+
"""
|
| 186 |
+
haystack = _normalise(f"{merchant} {text}")
|
| 187 |
+
if not haystack:
|
| 188 |
+
return "other", 0.0
|
| 189 |
+
|
| 190 |
+
for category, keywords in _RULES:
|
| 191 |
+
for keyword in keywords:
|
| 192 |
+
# Word-boundary match so "gas" doesn't fire on "gasket" and
|
| 193 |
+
# "cash" doesn't fire on "cashew".
|
| 194 |
+
if re.search(rf"(?<![a-z0-9]){re.escape(keyword)}(?![a-z0-9])", haystack):
|
| 195 |
+
return category, 1.0
|
| 196 |
+
return "other", 0.0
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def is_valid(category: str) -> bool:
|
| 200 |
+
return category in CATEGORIES
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def coerce(category: str) -> str:
|
| 204 |
+
"""Force an arbitrary string onto the taxonomy.
|
| 205 |
+
|
| 206 |
+
Models are asked for a category from the list but do not always comply;
|
| 207 |
+
this keeps a stray label out of the ledger where it would fragment totals.
|
| 208 |
+
"""
|
| 209 |
+
if not category:
|
| 210 |
+
return "other"
|
| 211 |
+
candidate = _normalise(category).replace(" ", "_").replace("&", "").strip("_")
|
| 212 |
+
if candidate in CATEGORIES:
|
| 213 |
+
return candidate
|
| 214 |
+
aliases = {
|
| 215 |
+
"food": "food_dining",
|
| 216 |
+
"dining": "food_dining",
|
| 217 |
+
"restaurants": "food_dining",
|
| 218 |
+
"eating_out": "food_dining",
|
| 219 |
+
"grocery": "groceries",
|
| 220 |
+
"commute": "transport",
|
| 221 |
+
"travel_transport": "transport",
|
| 222 |
+
"petrol": "fuel",
|
| 223 |
+
"bills": "utilities",
|
| 224 |
+
"housing": "rent",
|
| 225 |
+
"medical": "health",
|
| 226 |
+
"healthcare": "health",
|
| 227 |
+
"fitness": "health",
|
| 228 |
+
"leisure": "entertainment",
|
| 229 |
+
"subscription": "subscriptions",
|
| 230 |
+
"fees": "fees_charges",
|
| 231 |
+
"charges": "fees_charges",
|
| 232 |
+
"transfer": "transfers",
|
| 233 |
+
"salary": "income",
|
| 234 |
+
"misc": "other",
|
| 235 |
+
"miscellaneous": "other",
|
| 236 |
+
"uncategorized": "other",
|
| 237 |
+
"uncategorised": "other",
|
| 238 |
+
}
|
| 239 |
+
return aliases.get(candidate, "other")
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def label(category: str) -> str:
|
| 243 |
+
return LABELS.get(category, category.replace("_", " ").title())
|
finbot/config.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runtime configuration, read once from the environment.
|
| 2 |
+
|
| 3 |
+
On Hugging Face these come from Space Settings > Secrets; locally from a .env
|
| 4 |
+
file. Everything has a default that keeps the app bootable, so a missing
|
| 5 |
+
optional secret degrades a feature rather than crashing the container -- a
|
| 6 |
+
Space that won't boot is much harder to debug than one that boots and says
|
| 7 |
+
what it is missing.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from functools import lru_cache
|
| 13 |
+
|
| 14 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Settings(BaseSettings):
|
| 18 |
+
model_config = SettingsConfigDict(
|
| 19 |
+
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# --- Telegram ---
|
| 23 |
+
telegram_bot_token: str = ""
|
| 24 |
+
telegram_webhook_secret: str = ""
|
| 25 |
+
allowed_telegram_user_ids: str = ""
|
| 26 |
+
#: Let anyone message the bot. Each Telegram user still gets their own
|
| 27 |
+
#: isolated ledger -- open access makes this multi-tenant, it does not
|
| 28 |
+
#: expose one user's spending to another. It does mean strangers spend the
|
| 29 |
+
#: Space owner's inference credits, which is what uploads_per_user_per_day
|
| 30 |
+
#: is for. Must be set explicitly, so a forgotten allowlist stays closed
|
| 31 |
+
#: rather than silently opening to the world.
|
| 32 |
+
open_access: bool = False
|
| 33 |
+
#: Model-backed uploads (receipts, statements) allowed per user per day.
|
| 34 |
+
#: 0 or less disables the cap.
|
| 35 |
+
uploads_per_user_per_day: int = 20
|
| 36 |
+
|
| 37 |
+
# --- Inference ---
|
| 38 |
+
hf_token: str = ""
|
| 39 |
+
llm_base_url: str = "https://router.huggingface.co/v1"
|
| 40 |
+
llm_model: str = "Qwen/Qwen3-235B-A22B-Instruct-2507"
|
| 41 |
+
llm_vision_model: str = "Qwen/Qwen2.5-VL-72B-Instruct"
|
| 42 |
+
|
| 43 |
+
# --- Storage ---
|
| 44 |
+
hf_dataset_repo: str = ""
|
| 45 |
+
sync_every_minutes: int = 5
|
| 46 |
+
data_dir: str = "/home/user/data"
|
| 47 |
+
|
| 48 |
+
# --- Behaviour ---
|
| 49 |
+
default_currency: str = "INR"
|
| 50 |
+
timezone: str = "Asia/Kolkata"
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def allowed_user_ids(self) -> frozenset[int]:
|
| 54 |
+
"""Telegram user IDs permitted to talk to the bot.
|
| 55 |
+
|
| 56 |
+
Empty means nobody. This is deliberate: an open finance bot would let
|
| 57 |
+
any stranger who finds the username write to your ledger and read your
|
| 58 |
+
spending back out.
|
| 59 |
+
"""
|
| 60 |
+
ids: set[int] = set()
|
| 61 |
+
for chunk in self.allowed_telegram_user_ids.split(","):
|
| 62 |
+
chunk = chunk.strip()
|
| 63 |
+
if not chunk:
|
| 64 |
+
continue
|
| 65 |
+
try:
|
| 66 |
+
ids.add(int(chunk))
|
| 67 |
+
except ValueError:
|
| 68 |
+
continue
|
| 69 |
+
return frozenset(ids)
|
| 70 |
+
|
| 71 |
+
def is_authorised(self, user_id: int) -> bool:
|
| 72 |
+
return self.open_access or user_id in self.allowed_user_ids
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def llm_enabled(self) -> bool:
|
| 76 |
+
return bool(self.hf_token)
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def sync_enabled(self) -> bool:
|
| 80 |
+
return bool(self.hf_dataset_repo and self.hf_token)
|
| 81 |
+
|
| 82 |
+
def missing_required(self) -> list[str]:
|
| 83 |
+
"""Config gaps that stop the bot doing its job, worst first."""
|
| 84 |
+
gaps: list[str] = []
|
| 85 |
+
if not self.telegram_bot_token:
|
| 86 |
+
gaps.append("TELEGRAM_BOT_TOKEN (bot cannot send or receive)")
|
| 87 |
+
if not self.telegram_webhook_secret:
|
| 88 |
+
gaps.append("TELEGRAM_WEBHOOK_SECRET (webhook cannot be verified)")
|
| 89 |
+
if not self.open_access and not self.allowed_user_ids:
|
| 90 |
+
gaps.append("ALLOWED_TELEGRAM_USER_IDS (nobody is authorised yet)")
|
| 91 |
+
if not self.hf_dataset_repo:
|
| 92 |
+
gaps.append("HF_DATASET_REPO (data will be lost when the Space restarts)")
|
| 93 |
+
if not self.hf_token:
|
| 94 |
+
gaps.append("HF_TOKEN (receipts, statements and advice are disabled)")
|
| 95 |
+
return gaps
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@lru_cache(maxsize=1)
|
| 99 |
+
def get_settings() -> Settings:
|
| 100 |
+
return Settings()
|
finbot/extract.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turn an uploaded file into text and rows.
|
| 2 |
+
|
| 3 |
+
Deliberately format-agnostic and bank-agnostic: this module only gets the
|
| 4 |
+
content *out* of the file. Deciding which column is a date and which is an
|
| 5 |
+
amount is :mod:`finbot.ingest`'s job, and is done by the model so that any
|
| 6 |
+
bank's export works without a hand-written layout per institution.
|
| 7 |
+
|
| 8 |
+
Nothing here calls the network, so all of it is unit-testable offline.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import csv
|
| 14 |
+
import io
|
| 15 |
+
import logging
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
#: Guard rails. A personal statement is a few hundred rows; anything far past
|
| 21 |
+
#: that is either a mistake or would blow the model's context and the user's
|
| 22 |
+
#: token budget, so it is truncated with a visible note rather than silently.
|
| 23 |
+
MAX_ROWS = 1500
|
| 24 |
+
MAX_TEXT_CHARS = 120_000
|
| 25 |
+
MAX_PDF_PAGES = 40
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class ExtractedDocument:
|
| 30 |
+
kind: str
|
| 31 |
+
text: str = ""
|
| 32 |
+
rows: list[list[str]] = field(default_factory=list)
|
| 33 |
+
pages: int = 0
|
| 34 |
+
truncated: bool = False
|
| 35 |
+
note: str = ""
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def is_empty(self) -> bool:
|
| 39 |
+
return not self.text.strip() and not self.rows
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _decode(data: bytes) -> str:
|
| 43 |
+
for encoding in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
|
| 44 |
+
try:
|
| 45 |
+
return data.decode(encoding)
|
| 46 |
+
except UnicodeDecodeError:
|
| 47 |
+
continue
|
| 48 |
+
return data.decode("utf-8", errors="replace")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _clean_row(row: list) -> list[str]:
|
| 52 |
+
return [("" if c is None else str(c)).strip() for c in row]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _is_blank(row: list[str]) -> bool:
|
| 56 |
+
return not any(cell for cell in row)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def extract_csv(data: bytes) -> ExtractedDocument:
|
| 60 |
+
text = _decode(data)
|
| 61 |
+
sample = text[:8192]
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
|
| 65 |
+
except csv.Error:
|
| 66 |
+
# Sniffing fails on single-column or irregular exports; comma is the
|
| 67 |
+
# safe default and the model tolerates a wrong split better than a crash.
|
| 68 |
+
dialect = csv.excel
|
| 69 |
+
|
| 70 |
+
rows: list[list[str]] = []
|
| 71 |
+
truncated = False
|
| 72 |
+
for row in csv.reader(io.StringIO(text), dialect):
|
| 73 |
+
cleaned = _clean_row(row)
|
| 74 |
+
if _is_blank(cleaned):
|
| 75 |
+
continue
|
| 76 |
+
rows.append(cleaned)
|
| 77 |
+
if len(rows) >= MAX_ROWS:
|
| 78 |
+
truncated = True
|
| 79 |
+
break
|
| 80 |
+
|
| 81 |
+
return ExtractedDocument(kind="csv", rows=rows, truncated=truncated)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def extract_xlsx(data: bytes) -> ExtractedDocument:
|
| 85 |
+
from openpyxl import load_workbook
|
| 86 |
+
|
| 87 |
+
workbook = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
| 88 |
+
rows: list[list[str]] = []
|
| 89 |
+
truncated = False
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
for sheet in workbook.worksheets:
|
| 93 |
+
if truncated:
|
| 94 |
+
break
|
| 95 |
+
if len(workbook.worksheets) > 1:
|
| 96 |
+
rows.append([f"# sheet: {sheet.title}"])
|
| 97 |
+
for row in sheet.iter_rows(values_only=True):
|
| 98 |
+
cleaned = _clean_row(list(row))
|
| 99 |
+
if _is_blank(cleaned):
|
| 100 |
+
continue
|
| 101 |
+
rows.append(cleaned)
|
| 102 |
+
if len(rows) >= MAX_ROWS:
|
| 103 |
+
truncated = True
|
| 104 |
+
break
|
| 105 |
+
finally:
|
| 106 |
+
workbook.close()
|
| 107 |
+
|
| 108 |
+
return ExtractedDocument(kind="xlsx", rows=rows, truncated=truncated)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def extract_pdf(data: bytes) -> ExtractedDocument:
|
| 112 |
+
"""Pull tables (preferred) and text out of a PDF statement.
|
| 113 |
+
|
| 114 |
+
Scanned statements contain no text layer. That is detected and reported
|
| 115 |
+
rather than silently returning nothing, because "0 transactions found" and
|
| 116 |
+
"this file is a photograph of a statement" need very different user advice.
|
| 117 |
+
"""
|
| 118 |
+
import pdfplumber
|
| 119 |
+
|
| 120 |
+
rows: list[list[str]] = []
|
| 121 |
+
text_parts: list[str] = []
|
| 122 |
+
truncated = False
|
| 123 |
+
page_count = 0
|
| 124 |
+
|
| 125 |
+
with pdfplumber.open(io.BytesIO(data)) as pdf:
|
| 126 |
+
page_count = len(pdf.pages)
|
| 127 |
+
for index, page in enumerate(pdf.pages):
|
| 128 |
+
if index >= MAX_PDF_PAGES:
|
| 129 |
+
truncated = True
|
| 130 |
+
break
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
for table in page.extract_tables() or []:
|
| 134 |
+
for row in table:
|
| 135 |
+
cleaned = _clean_row(list(row))
|
| 136 |
+
if _is_blank(cleaned):
|
| 137 |
+
continue
|
| 138 |
+
rows.append(cleaned)
|
| 139 |
+
if len(rows) >= MAX_ROWS:
|
| 140 |
+
truncated = True
|
| 141 |
+
break
|
| 142 |
+
if truncated:
|
| 143 |
+
break
|
| 144 |
+
except Exception as exc:
|
| 145 |
+
log.warning("Table extraction failed on page %d: %s", index + 1, exc)
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
if page_text := page.extract_text():
|
| 149 |
+
text_parts.append(page_text)
|
| 150 |
+
except Exception as exc:
|
| 151 |
+
log.warning("Text extraction failed on page %d: %s", index + 1, exc)
|
| 152 |
+
|
| 153 |
+
if truncated:
|
| 154 |
+
break
|
| 155 |
+
|
| 156 |
+
text = "\n".join(text_parts)[:MAX_TEXT_CHARS]
|
| 157 |
+
doc = ExtractedDocument(
|
| 158 |
+
kind="pdf", text=text, rows=rows, pages=page_count, truncated=truncated
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
if doc.is_empty:
|
| 162 |
+
doc.note = (
|
| 163 |
+
"This PDF has no selectable text, so it is almost certainly a scan "
|
| 164 |
+
"or photo of a statement. Send it as a photo instead and I'll read "
|
| 165 |
+
"it with the vision model, or export a CSV/XLSX from your bank."
|
| 166 |
+
)
|
| 167 |
+
return doc
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def extract(data: bytes, filename: str = "", mime_type: str = "") -> ExtractedDocument:
|
| 171 |
+
"""Dispatch on filename extension, falling back to MIME type."""
|
| 172 |
+
name = (filename or "").lower()
|
| 173 |
+
mime = (mime_type or "").lower()
|
| 174 |
+
|
| 175 |
+
if name.endswith(".csv") or name.endswith(".tsv") or "csv" in mime:
|
| 176 |
+
return extract_csv(data)
|
| 177 |
+
if name.endswith((".xlsx", ".xlsm", ".xltx")) or "spreadsheet" in mime or "excel" in mime:
|
| 178 |
+
return extract_xlsx(data)
|
| 179 |
+
if name.endswith(".pdf") or "pdf" in mime:
|
| 180 |
+
return extract_pdf(data)
|
| 181 |
+
if name.endswith((".txt", ".md")) or mime.startswith("text/"):
|
| 182 |
+
return ExtractedDocument(kind="text", text=_decode(data)[:MAX_TEXT_CHARS])
|
| 183 |
+
|
| 184 |
+
# .xls (old binary format) is not readable by openpyxl -- say so precisely.
|
| 185 |
+
if name.endswith(".xls"):
|
| 186 |
+
return ExtractedDocument(
|
| 187 |
+
kind="unsupported",
|
| 188 |
+
note=(
|
| 189 |
+
"Old .xls files aren't supported. Open it and re-save as .xlsx "
|
| 190 |
+
"or export CSV, then send it again."
|
| 191 |
+
),
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
return ExtractedDocument(
|
| 195 |
+
kind="unsupported",
|
| 196 |
+
note=f"I can't read {filename or 'that file'}. Send a CSV, XLSX, PDF or a photo.",
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def rows_to_text(rows: list[list[str]], limit: int = MAX_ROWS) -> str:
|
| 201 |
+
"""Render rows as pipe-delimited lines for a model prompt."""
|
| 202 |
+
lines = [" | ".join(cell for cell in row) for row in rows[:limit]]
|
| 203 |
+
return "\n".join(lines)[:MAX_TEXT_CHARS]
|
finbot/features/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bundled features.
|
| 2 |
+
|
| 3 |
+
To add your own: subclass :class:`~finbot.features.base.Feature`, then include
|
| 4 |
+
it in the list passed to the registry (see ``finbot.app.build_services``).
|
| 5 |
+
Nothing else in the codebase needs to change.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from .base import (
|
| 11 |
+
Attachment,
|
| 12 |
+
BotContext,
|
| 13 |
+
CommandSpec,
|
| 14 |
+
Feature,
|
| 15 |
+
Reply,
|
| 16 |
+
Services,
|
| 17 |
+
SimpleFeature,
|
| 18 |
+
)
|
| 19 |
+
from .budgets import BudgetsFeature
|
| 20 |
+
from .coaching import CoachingFeature
|
| 21 |
+
from .core import CoreFeature
|
| 22 |
+
from .expenses import ExpensesFeature
|
| 23 |
+
|
| 24 |
+
__all__ = [
|
| 25 |
+
"Attachment",
|
| 26 |
+
"BotContext",
|
| 27 |
+
"BudgetsFeature",
|
| 28 |
+
"CoachingFeature",
|
| 29 |
+
"CommandSpec",
|
| 30 |
+
"CoreFeature",
|
| 31 |
+
"ExpensesFeature",
|
| 32 |
+
"Feature",
|
| 33 |
+
"Reply",
|
| 34 |
+
"Services",
|
| 35 |
+
"SimpleFeature",
|
| 36 |
+
"default_features",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def default_features() -> list[Feature]:
|
| 41 |
+
"""The stock feature set, in registration order."""
|
| 42 |
+
return [
|
| 43 |
+
ExpensesFeature(),
|
| 44 |
+
BudgetsFeature(),
|
| 45 |
+
CoachingFeature(),
|
| 46 |
+
CoreFeature(),
|
| 47 |
+
]
|
finbot/features/base.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The extension point.
|
| 2 |
+
|
| 3 |
+
A *feature* is a self-contained bundle of bot behaviour: some commands, and
|
| 4 |
+
optionally an interest in plain messages or uploaded files. Adding one means
|
| 5 |
+
subclassing :class:`Feature` and registering it -- no edits to the router, the
|
| 6 |
+
webhook, or any other feature.
|
| 7 |
+
|
| 8 |
+
Expense tracking is simply the first feature registered. Anything else a bot
|
| 9 |
+
needs to do (reminders, notes, standups, ops alerts) drops in beside it on
|
| 10 |
+
equal terms.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from abc import ABC, abstractmethod
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from datetime import date
|
| 18 |
+
from typing import TYPE_CHECKING, Optional, Sequence
|
| 19 |
+
|
| 20 |
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
| 21 |
+
from ..advice import Advisor
|
| 22 |
+
from ..config import Settings
|
| 23 |
+
from ..ingest import Ingestor
|
| 24 |
+
from ..llm import LLMClient
|
| 25 |
+
from ..models import PendingImport
|
| 26 |
+
from ..store import Store
|
| 27 |
+
from ..registry import FeatureRegistry
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class CommandSpec:
|
| 32 |
+
"""One slash command, and enough metadata to build /help and setMyCommands."""
|
| 33 |
+
|
| 34 |
+
name: str
|
| 35 |
+
help: str
|
| 36 |
+
args: str = ""
|
| 37 |
+
aliases: tuple[str, ...] = ()
|
| 38 |
+
#: Hidden commands still work but stay out of the menu (e.g. /confirm).
|
| 39 |
+
hidden: bool = False
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def usage(self) -> str:
|
| 43 |
+
return f"/{self.name} {self.args}".strip()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class Attachment:
|
| 48 |
+
"""A file the user sent, already downloaded."""
|
| 49 |
+
|
| 50 |
+
data: bytes
|
| 51 |
+
filename: str = ""
|
| 52 |
+
mime_type: str = ""
|
| 53 |
+
kind: str = "document" # "photo" | "document"
|
| 54 |
+
caption: str = ""
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def is_image(self) -> bool:
|
| 58 |
+
return self.kind == "photo" or self.mime_type.startswith("image/")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class Services:
|
| 63 |
+
"""Shared dependencies handed to every feature."""
|
| 64 |
+
|
| 65 |
+
settings: "Settings"
|
| 66 |
+
store: "Store"
|
| 67 |
+
llm: "LLMClient"
|
| 68 |
+
ingestor: "Ingestor"
|
| 69 |
+
advisor: "Advisor"
|
| 70 |
+
registry: "FeatureRegistry"
|
| 71 |
+
#: Imports awaiting /confirm, keyed by user id. Held in memory only -- a
|
| 72 |
+
#: Space restart drops them, which is acceptable because the user simply
|
| 73 |
+
#: re-sends the file, and it keeps unconfirmed model output out of storage.
|
| 74 |
+
pending: dict[int, "PendingImport"] = field(default_factory=dict)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class BotContext:
|
| 79 |
+
"""Everything a feature needs to answer one update."""
|
| 80 |
+
|
| 81 |
+
user_id: int
|
| 82 |
+
chat_id: int
|
| 83 |
+
services: Services
|
| 84 |
+
today: date
|
| 85 |
+
text: str = ""
|
| 86 |
+
command: str = ""
|
| 87 |
+
args: str = ""
|
| 88 |
+
attachment: Optional[Attachment] = None
|
| 89 |
+
|
| 90 |
+
@property
|
| 91 |
+
def settings(self) -> "Settings":
|
| 92 |
+
return self.services.settings
|
| 93 |
+
|
| 94 |
+
@property
|
| 95 |
+
def store(self) -> "Store":
|
| 96 |
+
return self.services.store
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@dataclass
|
| 100 |
+
class Reply:
|
| 101 |
+
"""What to send back. HTML parse mode -- see finbot.render for why."""
|
| 102 |
+
|
| 103 |
+
text: str
|
| 104 |
+
parse_mode: Optional[str] = "HTML"
|
| 105 |
+
disable_preview: bool = True
|
| 106 |
+
#: Optional file to send alongside the text, as (filename, bytes).
|
| 107 |
+
document: Optional[tuple[str, bytes]] = None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class Feature(ABC):
|
| 111 |
+
#: Stable identifier, used in logs and /help grouping.
|
| 112 |
+
name: str = "feature"
|
| 113 |
+
#: One line shown in /help.
|
| 114 |
+
description: str = ""
|
| 115 |
+
|
| 116 |
+
def commands(self) -> Sequence[CommandSpec]:
|
| 117 |
+
"""Slash commands this feature owns."""
|
| 118 |
+
return ()
|
| 119 |
+
|
| 120 |
+
def handle_command(self, ctx: BotContext) -> Optional[Reply]:
|
| 121 |
+
"""Handle one of this feature's commands. Return None to decline."""
|
| 122 |
+
return None
|
| 123 |
+
|
| 124 |
+
def handle_message(self, ctx: BotContext) -> Optional[Reply]:
|
| 125 |
+
"""Handle plain (non-command) text. Return None to let others try."""
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
def handle_attachment(self, ctx: BotContext) -> Optional[Reply]:
|
| 129 |
+
"""Handle an uploaded file. Return None to let others try."""
|
| 130 |
+
return None
|
| 131 |
+
|
| 132 |
+
#: Lower runs first when several features want the same plain message.
|
| 133 |
+
priority: int = 100
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class SimpleFeature(Feature):
|
| 137 |
+
"""Convenience base that routes commands to ``cmd_<name>`` methods."""
|
| 138 |
+
|
| 139 |
+
@abstractmethod
|
| 140 |
+
def commands(self) -> Sequence[CommandSpec]: # pragma: no cover - abstract
|
| 141 |
+
...
|
| 142 |
+
|
| 143 |
+
def handle_command(self, ctx: BotContext) -> Optional[Reply]:
|
| 144 |
+
for spec in self.commands():
|
| 145 |
+
if ctx.command == spec.name or ctx.command in spec.aliases:
|
| 146 |
+
handler = getattr(self, f"cmd_{spec.name}", None)
|
| 147 |
+
if handler is None:
|
| 148 |
+
return None
|
| 149 |
+
return handler(ctx)
|
| 150 |
+
return None
|
finbot/features/budgets.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Monthly budgets per category."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Sequence
|
| 6 |
+
|
| 7 |
+
from .. import render
|
| 8 |
+
from ..analytics import budget_status, month_bounds
|
| 9 |
+
from ..categories import CATEGORIES, coerce
|
| 10 |
+
from ..categories import label as category_label
|
| 11 |
+
from ..models import Budget
|
| 12 |
+
from ..money import parse_amount
|
| 13 |
+
from .base import BotContext, CommandSpec, Reply, SimpleFeature
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class BudgetsFeature(SimpleFeature):
|
| 17 |
+
name = "budgets"
|
| 18 |
+
description = "🎯 Budgets"
|
| 19 |
+
priority = 20
|
| 20 |
+
|
| 21 |
+
def commands(self) -> Sequence[CommandSpec]:
|
| 22 |
+
return (
|
| 23 |
+
CommandSpec("budget", "Set a monthly budget", "<category> <amount>"),
|
| 24 |
+
CommandSpec("budgets", "How you're tracking against budgets"),
|
| 25 |
+
CommandSpec("unbudget", "Remove a budget", "<category>"),
|
| 26 |
+
CommandSpec("categories", "List the category names"),
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
def cmd_budget(self, ctx: BotContext) -> Reply:
|
| 30 |
+
args = ctx.args.strip()
|
| 31 |
+
if not args:
|
| 32 |
+
return Reply(
|
| 33 |
+
"Set a monthly cap, e.g. <code>/budget food_dining 8000</code>.\n"
|
| 34 |
+
"/categories lists the names."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
parts = args.split()
|
| 38 |
+
category = coerce(parts[0])
|
| 39 |
+
if category == "other" and parts[0].lower() not in ("other",):
|
| 40 |
+
return Reply(
|
| 41 |
+
f"I don't recognise the category <code>{render.escape(parts[0])}</code>.\n"
|
| 42 |
+
"/categories lists what I understand."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
amount = parse_amount(" ".join(parts[1:]), ctx.settings.default_currency)
|
| 46 |
+
if amount is None or amount.minor <= 0:
|
| 47 |
+
return Reply("I need an amount, e.g. <code>/budget groceries 12000</code>.")
|
| 48 |
+
|
| 49 |
+
budget = Budget(
|
| 50 |
+
user_id=ctx.user_id,
|
| 51 |
+
category=category,
|
| 52 |
+
amount_minor=amount.minor,
|
| 53 |
+
currency=amount.currency or ctx.settings.default_currency,
|
| 54 |
+
)
|
| 55 |
+
ctx.store.set_budget(budget)
|
| 56 |
+
return Reply(
|
| 57 |
+
f"🎯 Budget set: {render.escape(category_label(category))} "
|
| 58 |
+
f"{render.money(budget.amount_minor, budget.currency)}/month."
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
def cmd_budgets(self, ctx: BotContext) -> Reply:
|
| 62 |
+
budgets = ctx.store.get_budgets(ctx.user_id)
|
| 63 |
+
start, _ = month_bounds(ctx.today)
|
| 64 |
+
expenses = ctx.store.list_expenses(ctx.user_id, start, ctx.today)
|
| 65 |
+
return Reply(render.budget_list(budget_status(budgets, expenses)))
|
| 66 |
+
|
| 67 |
+
def cmd_unbudget(self, ctx: BotContext) -> Reply:
|
| 68 |
+
category = coerce(ctx.args.strip())
|
| 69 |
+
if ctx.store.delete_budget(ctx.user_id, category):
|
| 70 |
+
return Reply(f"Removed the {render.escape(category_label(category))} budget.")
|
| 71 |
+
return Reply("No budget set for that category.")
|
| 72 |
+
|
| 73 |
+
def cmd_categories(self, ctx: BotContext) -> Reply:
|
| 74 |
+
lines = ["<b>Categories</b>"]
|
| 75 |
+
lines.extend(
|
| 76 |
+
f"<code>{name}</code> — {render.escape(category_label(name))}"
|
| 77 |
+
for name in CATEGORIES
|
| 78 |
+
)
|
| 79 |
+
lines.append("")
|
| 80 |
+
lines.append("Tag an entry to override: <code>250 lunch #transport</code>")
|
| 81 |
+
return Reply("\n".join(lines))
|
finbot/features/coaching.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Budget coaching over the user's own logged spending."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Sequence
|
| 6 |
+
|
| 7 |
+
from .. import render
|
| 8 |
+
from ..advice import DISCLAIMER
|
| 9 |
+
from ..analytics import (
|
| 10 |
+
budget_status,
|
| 11 |
+
detect_recurring,
|
| 12 |
+
month_bounds,
|
| 13 |
+
previous_month_bounds,
|
| 14 |
+
)
|
| 15 |
+
from .base import BotContext, CommandSpec, Reply, SimpleFeature
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class CoachingFeature(SimpleFeature):
|
| 19 |
+
name = "coaching"
|
| 20 |
+
description = "🧭 Coaching"
|
| 21 |
+
priority = 30
|
| 22 |
+
|
| 23 |
+
def commands(self) -> Sequence[CommandSpec]:
|
| 24 |
+
return (
|
| 25 |
+
CommandSpec("advice", "Observations on your spending", "[question]"),
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def cmd_advice(self, ctx: BotContext) -> Reply:
|
| 29 |
+
start, _ = month_bounds(ctx.today)
|
| 30 |
+
this_month = ctx.store.list_expenses(ctx.user_id, start, ctx.today)
|
| 31 |
+
|
| 32 |
+
prev_start, prev_end = previous_month_bounds(ctx.today)
|
| 33 |
+
last_month = ctx.store.list_expenses(ctx.user_id, prev_start, prev_end)
|
| 34 |
+
|
| 35 |
+
all_expenses = ctx.store.list_expenses(ctx.user_id)
|
| 36 |
+
recurring = detect_recurring(all_expenses)
|
| 37 |
+
budgets = budget_status(ctx.store.get_budgets(ctx.user_id), this_month)
|
| 38 |
+
|
| 39 |
+
text = ctx.services.advisor.advise(
|
| 40 |
+
expenses_this_month=this_month,
|
| 41 |
+
expenses_last_month=last_month,
|
| 42 |
+
recurring=recurring,
|
| 43 |
+
budgets=budgets,
|
| 44 |
+
default_currency=ctx.settings.default_currency,
|
| 45 |
+
today=ctx.today,
|
| 46 |
+
question=ctx.args.strip() or None,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
body = render.escape(text)
|
| 50 |
+
footer = render.escape(DISCLAIMER.replace("_", ""))
|
| 51 |
+
return Reply(f"{body}\n\n<i>{footer}</i>")
|
finbot/features/core.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Onboarding, help and diagnostics. Registered last so /help sees everything."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Sequence
|
| 6 |
+
|
| 7 |
+
from .. import render
|
| 8 |
+
from ..analytics import month_bounds
|
| 9 |
+
from .base import BotContext, CommandSpec, Reply, SimpleFeature
|
| 10 |
+
|
| 11 |
+
_WELCOME = """<b>Hello 👋</b>
|
| 12 |
+
|
| 13 |
+
Just tell me what you spent and I'll keep track.
|
| 14 |
+
|
| 15 |
+
<b>Log it</b>
|
| 16 |
+
<code>250 coffee</code>
|
| 17 |
+
<code>₹1,240 groceries at bigbasket</code>
|
| 18 |
+
<code>45.50 lunch yesterday</code>
|
| 19 |
+
<code>320 uber #transport</code>
|
| 20 |
+
|
| 21 |
+
<b>Send me things</b>
|
| 22 |
+
📷 a receipt photo — I'll read the total
|
| 23 |
+
📄 a bank statement (PDF, CSV, XLSX) — I'll pull out the transactions and ask before saving
|
| 24 |
+
|
| 25 |
+
<b>See it back</b>
|
| 26 |
+
/today · /week · /month · /recurring · /advice
|
| 27 |
+
|
| 28 |
+
/help for everything."""
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class CoreFeature(SimpleFeature):
|
| 32 |
+
name = "core"
|
| 33 |
+
description = "⚙️ General"
|
| 34 |
+
priority = 90
|
| 35 |
+
|
| 36 |
+
def commands(self) -> Sequence[CommandSpec]:
|
| 37 |
+
return (
|
| 38 |
+
CommandSpec("start", "What this bot does"),
|
| 39 |
+
CommandSpec("help", "All commands"),
|
| 40 |
+
CommandSpec("status", "Configuration and storage health"),
|
| 41 |
+
CommandSpec("whoami", "Your Telegram user ID", hidden=True),
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
def cmd_start(self, ctx: BotContext) -> Reply:
|
| 45 |
+
return Reply(_WELCOME)
|
| 46 |
+
|
| 47 |
+
def cmd_help(self, ctx: BotContext) -> Reply:
|
| 48 |
+
body = ctx.services.registry.help_text()
|
| 49 |
+
tail = (
|
| 50 |
+
"\n\n<i>Plain text with a number is logged as an expense. "
|
| 51 |
+
"Add #category to override the guess.</i>"
|
| 52 |
+
)
|
| 53 |
+
return Reply(body + tail)
|
| 54 |
+
|
| 55 |
+
def cmd_whoami(self, ctx: BotContext) -> Reply:
|
| 56 |
+
return Reply(f"Your Telegram user ID is <code>{ctx.user_id}</code>.")
|
| 57 |
+
|
| 58 |
+
def cmd_status(self, ctx: BotContext) -> Reply:
|
| 59 |
+
settings = ctx.settings
|
| 60 |
+
store = ctx.store
|
| 61 |
+
|
| 62 |
+
start, _ = month_bounds(ctx.today)
|
| 63 |
+
month_count = len(ctx.store.list_expenses(ctx.user_id, start, ctx.today))
|
| 64 |
+
|
| 65 |
+
lines = [
|
| 66 |
+
"<b>Status</b>",
|
| 67 |
+
f"Transactions logged: {store.count(ctx.user_id)} ({month_count} this month)",
|
| 68 |
+
f"Today (in {render.escape(settings.timezone)}): {ctx.today.isoformat()}",
|
| 69 |
+
"",
|
| 70 |
+
"<b>Inference</b>",
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
if settings.llm_enabled:
|
| 74 |
+
lines.append(f"✅ {render.escape(settings.llm_model)}")
|
| 75 |
+
lines.append(f" vision: {render.escape(settings.llm_vision_model)}")
|
| 76 |
+
lines.append(f" via {render.escape(settings.llm_base_url)}")
|
| 77 |
+
else:
|
| 78 |
+
lines.append("⚠️ No HF_TOKEN — receipts, statements and advice are off.")
|
| 79 |
+
lines.append(" Plain-text logging still works.")
|
| 80 |
+
|
| 81 |
+
lines.append("")
|
| 82 |
+
lines.append("<b>Access</b>")
|
| 83 |
+
if settings.open_access:
|
| 84 |
+
cap = settings.uploads_per_user_per_day
|
| 85 |
+
lines.append("🌍 Open — anyone on Telegram can use this bot.")
|
| 86 |
+
lines.append(" Each person's ledger is separate; nobody sees yours.")
|
| 87 |
+
lines.append(
|
| 88 |
+
f" Uploads capped at {cap}/user/day."
|
| 89 |
+
if cap > 0
|
| 90 |
+
else " ⚠️ Uploads uncapped — strangers can spend your inference credits."
|
| 91 |
+
)
|
| 92 |
+
else:
|
| 93 |
+
lines.append(f"🔒 Allowlist — {len(settings.allowed_user_ids)} user(s).")
|
| 94 |
+
|
| 95 |
+
lines.append("")
|
| 96 |
+
lines.append("<b>Storage</b>")
|
| 97 |
+
if settings.sync_enabled:
|
| 98 |
+
lines.append(f"✅ {render.escape(store.sync_status)}")
|
| 99 |
+
else:
|
| 100 |
+
lines.append(
|
| 101 |
+
"🔴 <b>Local only — Space disk is ephemeral.</b>\n"
|
| 102 |
+
" Your data will be lost on the next restart or sleep.\n"
|
| 103 |
+
" Set HF_DATASET_REPO to fix this."
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
if gaps := settings.missing_required():
|
| 107 |
+
lines.append("")
|
| 108 |
+
lines.append("<b>Not configured</b>")
|
| 109 |
+
lines.extend(f"• {render.escape(gap)}" for gap in gaps)
|
| 110 |
+
|
| 111 |
+
return Reply("\n".join(lines))
|
finbot/features/expenses.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Expense logging, summaries and imports -- the first registered feature."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional, Sequence
|
| 7 |
+
|
| 8 |
+
from .. import render
|
| 9 |
+
from ..analytics import (
|
| 10 |
+
detect_recurring,
|
| 11 |
+
dominant_currency,
|
| 12 |
+
period_bounds,
|
| 13 |
+
previous_month_bounds,
|
| 14 |
+
summarise,
|
| 15 |
+
)
|
| 16 |
+
from ..extract import extract
|
| 17 |
+
from ..models import Expense
|
| 18 |
+
from ..money import format_money
|
| 19 |
+
from .base import BotContext, CommandSpec, Reply, SimpleFeature
|
| 20 |
+
|
| 21 |
+
log = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
#: Above this many rows, remind the user that /undo drops the whole batch.
|
| 24 |
+
LARGE_IMPORT = 50
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ExpensesFeature(SimpleFeature):
|
| 28 |
+
name = "expenses"
|
| 29 |
+
description = "💸 Spending"
|
| 30 |
+
priority = 10
|
| 31 |
+
|
| 32 |
+
def commands(self) -> Sequence[CommandSpec]:
|
| 33 |
+
return (
|
| 34 |
+
CommandSpec("spend", "Log an expense", "<amount> <what>"),
|
| 35 |
+
CommandSpec("today", "What you spent today"),
|
| 36 |
+
CommandSpec("week", "This week's spending"),
|
| 37 |
+
CommandSpec("month", "This month's spending"),
|
| 38 |
+
CommandSpec(
|
| 39 |
+
"report",
|
| 40 |
+
"Summary for a period",
|
| 41 |
+
"[today|week|month|last_month|30d|year|all]",
|
| 42 |
+
),
|
| 43 |
+
CommandSpec("list", "Recent transactions", "[count]"),
|
| 44 |
+
CommandSpec("recurring", "Subscriptions and repeat charges"),
|
| 45 |
+
CommandSpec("undo", "Remove the last entry or import"),
|
| 46 |
+
CommandSpec("export", "Download everything as CSV"),
|
| 47 |
+
CommandSpec("confirm", "Save a pending import", hidden=True),
|
| 48 |
+
CommandSpec("cancel", "Discard a pending import", hidden=True),
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# ------------------------------------------------------------- logging
|
| 52 |
+
|
| 53 |
+
def _log_draft(self, ctx: BotContext, draft, source: str) -> Expense:
|
| 54 |
+
expense = draft.to_expense(ctx.user_id, source=source, batch_id="")
|
| 55 |
+
ctx.store.add_expense(expense)
|
| 56 |
+
return expense
|
| 57 |
+
|
| 58 |
+
def cmd_spend(self, ctx: BotContext) -> Reply:
|
| 59 |
+
if not ctx.args.strip():
|
| 60 |
+
return Reply(
|
| 61 |
+
"Tell me what to log, e.g. <code>/spend 250 coffee</code>.\n"
|
| 62 |
+
"You can also just send <code>250 coffee</code> with no command."
|
| 63 |
+
)
|
| 64 |
+
return self._log_text(ctx, ctx.args)
|
| 65 |
+
|
| 66 |
+
def handle_message(self, ctx: BotContext) -> Optional[Reply]:
|
| 67 |
+
"""Plain text is treated as an expense if it contains an amount."""
|
| 68 |
+
if not ctx.text.strip():
|
| 69 |
+
return None
|
| 70 |
+
return self._log_text(ctx, ctx.text)
|
| 71 |
+
|
| 72 |
+
def _log_text(self, ctx: BotContext, text: str) -> Optional[Reply]:
|
| 73 |
+
draft = ctx.services.ingestor.from_text(text, ctx.today)
|
| 74 |
+
if draft is None:
|
| 75 |
+
return None # no amount found -- let another feature try
|
| 76 |
+
|
| 77 |
+
expense = self._log_draft(ctx, draft, source="text")
|
| 78 |
+
note = ""
|
| 79 |
+
if expense.category == "other":
|
| 80 |
+
note = "Couldn't categorise it — add #groceries etc. next time to pin it."
|
| 81 |
+
return Reply(render.logged(expense, note))
|
| 82 |
+
|
| 83 |
+
# --------------------------------------------------------- attachments
|
| 84 |
+
|
| 85 |
+
def handle_attachment(self, ctx: BotContext) -> Optional[Reply]:
|
| 86 |
+
attachment = ctx.attachment
|
| 87 |
+
if attachment is None:
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
if attachment.is_image:
|
| 91 |
+
return self._handle_receipt(ctx, attachment)
|
| 92 |
+
return self._handle_statement(ctx, attachment)
|
| 93 |
+
|
| 94 |
+
def _handle_receipt(self, ctx: BotContext, attachment) -> Reply:
|
| 95 |
+
draft, error = ctx.services.ingestor.from_receipt(
|
| 96 |
+
attachment.data, attachment.mime_type or "image/jpeg", ctx.today
|
| 97 |
+
)
|
| 98 |
+
if draft is None:
|
| 99 |
+
return Reply(render.escape(error))
|
| 100 |
+
|
| 101 |
+
if attachment.caption:
|
| 102 |
+
draft.description = attachment.caption[:200]
|
| 103 |
+
|
| 104 |
+
expense = self._log_draft(ctx, draft, source="photo")
|
| 105 |
+
confidence = ""
|
| 106 |
+
if expense.confidence < 0.7:
|
| 107 |
+
confidence = "I wasn't fully confident reading that — check the amount."
|
| 108 |
+
return Reply(render.logged(expense, confidence))
|
| 109 |
+
|
| 110 |
+
def _handle_statement(self, ctx: BotContext, attachment) -> Reply:
|
| 111 |
+
doc = extract(attachment.data, attachment.filename, attachment.mime_type)
|
| 112 |
+
pending, error = ctx.services.ingestor.from_document(
|
| 113 |
+
doc, ctx.user_id, ctx.today, label=attachment.filename
|
| 114 |
+
)
|
| 115 |
+
if pending is None:
|
| 116 |
+
return Reply(render.escape(error))
|
| 117 |
+
|
| 118 |
+
# Drop rows already in the ledger so re-sending a statement is safe.
|
| 119 |
+
# Paired as a list, not a dict: ExpenseDraft is an unhashable dataclass.
|
| 120 |
+
pairs = [
|
| 121 |
+
(
|
| 122 |
+
draft,
|
| 123 |
+
draft.to_expense(
|
| 124 |
+
ctx.user_id, pending.source, pending.batch_id
|
| 125 |
+
).dedupe_key,
|
| 126 |
+
)
|
| 127 |
+
for draft in pending.drafts
|
| 128 |
+
]
|
| 129 |
+
existing = ctx.store.existing_dedupe_keys(ctx.user_id, [k for _, k in pairs])
|
| 130 |
+
|
| 131 |
+
kept = [draft for draft, key in pairs if key not in existing]
|
| 132 |
+
pending.duplicates = len(pending.drafts) - len(kept)
|
| 133 |
+
pending.drafts = kept
|
| 134 |
+
|
| 135 |
+
if not pending.drafts:
|
| 136 |
+
return Reply(
|
| 137 |
+
f"Every transaction in that file is already logged "
|
| 138 |
+
f"({pending.duplicates} duplicates). Nothing to add."
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
ctx.services.pending[ctx.user_id] = pending
|
| 142 |
+
return Reply(render.pending_import(pending))
|
| 143 |
+
|
| 144 |
+
def cmd_confirm(self, ctx: BotContext) -> Reply:
|
| 145 |
+
pending = ctx.services.pending.pop(ctx.user_id, None)
|
| 146 |
+
if pending is None:
|
| 147 |
+
return Reply("Nothing waiting to be confirmed.")
|
| 148 |
+
|
| 149 |
+
expenses = [
|
| 150 |
+
draft.to_expense(ctx.user_id, pending.source, pending.batch_id)
|
| 151 |
+
for draft in pending.drafts
|
| 152 |
+
]
|
| 153 |
+
ctx.store.add_expenses(expenses)
|
| 154 |
+
ctx.store.flush() # push straight to the Hub; imports are expensive to redo
|
| 155 |
+
|
| 156 |
+
totals = pending.total_by_currency()
|
| 157 |
+
total_str = " + ".join(
|
| 158 |
+
format_money(v, k) for k, v in sorted(totals.items())
|
| 159 |
+
)
|
| 160 |
+
note = ""
|
| 161 |
+
if len(expenses) >= LARGE_IMPORT:
|
| 162 |
+
note = "\n<i>/undo removes this whole import.</i>"
|
| 163 |
+
return Reply(
|
| 164 |
+
f"✅ Saved {len(expenses)} transactions · {render.escape(total_str)}{note}"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
def cmd_cancel(self, ctx: BotContext) -> Reply:
|
| 168 |
+
pending = ctx.services.pending.pop(ctx.user_id, None)
|
| 169 |
+
if pending is None:
|
| 170 |
+
return Reply("Nothing pending.")
|
| 171 |
+
return Reply(f"Discarded {len(pending.drafts)} unsaved transactions.")
|
| 172 |
+
|
| 173 |
+
# ---------------------------------------------------------- summaries
|
| 174 |
+
|
| 175 |
+
def _summary_reply(self, ctx: BotContext, period: str) -> Reply:
|
| 176 |
+
start, end, label = period_bounds(period, ctx.today)
|
| 177 |
+
expenses = ctx.store.list_expenses(ctx.user_id, start, end)
|
| 178 |
+
currency = dominant_currency(expenses, ctx.settings.default_currency)
|
| 179 |
+
|
| 180 |
+
previous = None
|
| 181 |
+
if period == "month":
|
| 182 |
+
prev_start, prev_end = previous_month_bounds(ctx.today)
|
| 183 |
+
# Compare like with like: the same elapsed slice of last month, not
|
| 184 |
+
# a full month against a partial one.
|
| 185 |
+
clipped_end = min(prev_end, prev_start + (ctx.today - start))
|
| 186 |
+
previous = ctx.store.list_expenses(ctx.user_id, prev_start, clipped_end)
|
| 187 |
+
|
| 188 |
+
summ = render.summary(
|
| 189 |
+
summarise(expenses, currency, label, start, end, previous)
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
others = [c for c in {e.currency.upper() for e in expenses} if c != currency]
|
| 193 |
+
if others:
|
| 194 |
+
extra = []
|
| 195 |
+
for code in sorted(others):
|
| 196 |
+
subtotal = sum(
|
| 197 |
+
e.amount_minor for e in expenses if e.currency.upper() == code
|
| 198 |
+
)
|
| 199 |
+
extra.append(format_money(subtotal, code))
|
| 200 |
+
summ += f"\n<i>Also: {render.escape(' · '.join(extra))}</i>"
|
| 201 |
+
|
| 202 |
+
return Reply(summ)
|
| 203 |
+
|
| 204 |
+
def cmd_today(self, ctx: BotContext) -> Reply:
|
| 205 |
+
return self._summary_reply(ctx, "today")
|
| 206 |
+
|
| 207 |
+
def cmd_week(self, ctx: BotContext) -> Reply:
|
| 208 |
+
return self._summary_reply(ctx, "week")
|
| 209 |
+
|
| 210 |
+
def cmd_month(self, ctx: BotContext) -> Reply:
|
| 211 |
+
return self._summary_reply(ctx, "month")
|
| 212 |
+
|
| 213 |
+
def cmd_report(self, ctx: BotContext) -> Reply:
|
| 214 |
+
period = (ctx.args.strip().lower() or "month").replace(" ", "_")
|
| 215 |
+
if period in {"lastmonth", "last-month"}:
|
| 216 |
+
period = "last_month"
|
| 217 |
+
return self._summary_reply(ctx, period)
|
| 218 |
+
|
| 219 |
+
def cmd_list(self, ctx: BotContext) -> Reply:
|
| 220 |
+
try:
|
| 221 |
+
count = max(1, min(50, int(ctx.args.strip()))) if ctx.args.strip() else 15
|
| 222 |
+
except ValueError:
|
| 223 |
+
count = 15
|
| 224 |
+
expenses = ctx.store.list_expenses(ctx.user_id, limit=count)
|
| 225 |
+
return Reply(render.expense_list(expenses, f"Last {len(expenses)} transactions"))
|
| 226 |
+
|
| 227 |
+
def cmd_recurring(self, ctx: BotContext) -> Reply:
|
| 228 |
+
expenses = ctx.store.list_expenses(ctx.user_id)
|
| 229 |
+
currency = dominant_currency(expenses, ctx.settings.default_currency)
|
| 230 |
+
return Reply(render.recurring_list(detect_recurring(expenses), currency))
|
| 231 |
+
|
| 232 |
+
# -------------------------------------------------------------- undo
|
| 233 |
+
|
| 234 |
+
def cmd_undo(self, ctx: BotContext) -> Reply:
|
| 235 |
+
last = ctx.store.last_expense(ctx.user_id)
|
| 236 |
+
if last is None:
|
| 237 |
+
return Reply("Nothing to undo.")
|
| 238 |
+
|
| 239 |
+
if last.batch_id:
|
| 240 |
+
removed = ctx.store.delete_batch(ctx.user_id, last.batch_id)
|
| 241 |
+
return Reply(f"Removed the last import — {removed} transactions.")
|
| 242 |
+
|
| 243 |
+
ctx.store.delete_expense(ctx.user_id, last.id)
|
| 244 |
+
return Reply(f"Removed: {render.expense_line(last)}")
|
| 245 |
+
|
| 246 |
+
# ------------------------------------------------------------ export
|
| 247 |
+
|
| 248 |
+
def cmd_export(self, ctx: BotContext) -> Reply:
|
| 249 |
+
expenses = ctx.store.list_expenses(ctx.user_id)
|
| 250 |
+
if not expenses:
|
| 251 |
+
return Reply("Nothing to export yet.")
|
| 252 |
+
payload = render.csv_export(expenses).encode("utf-8")
|
| 253 |
+
return Reply(
|
| 254 |
+
f"📄 {len(expenses)} transactions.",
|
| 255 |
+
document=(f"finbot-export-{ctx.today.isoformat()}.csv", payload),
|
| 256 |
+
)
|
finbot/ingest.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turn user input into expense drafts.
|
| 2 |
+
|
| 3 |
+
Order of preference throughout: deterministic parsing first, model second. The
|
| 4 |
+
regex parser handles "coffee 250" instantly, for free, and identically every
|
| 5 |
+
time. The model is spent only on what actually needs judgement -- reading a
|
| 6 |
+
receipt photo, or working out which column of an unfamiliar bank export is the
|
| 7 |
+
amount.
|
| 8 |
+
|
| 9 |
+
Statement imports never write straight to the ledger. They produce a
|
| 10 |
+
:class:`~finbot.models.PendingImport` that the user confirms, because a model
|
| 11 |
+
that misreads a column would otherwise quietly poison months of history.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
from datetime import date, datetime
|
| 18 |
+
from typing import Any, Optional
|
| 19 |
+
|
| 20 |
+
from .categories import CATEGORIES, categorise, coerce
|
| 21 |
+
from .extract import ExtractedDocument, rows_to_text
|
| 22 |
+
from .llm import LLMClient, LLMUnavailable
|
| 23 |
+
from .models import ExpenseDraft, PendingImport
|
| 24 |
+
from .money import to_minor
|
| 25 |
+
from .parser import parse_expense
|
| 26 |
+
|
| 27 |
+
log = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
_CATEGORY_LIST = ", ".join(CATEGORIES)
|
| 30 |
+
|
| 31 |
+
_STATEMENT_SYSTEM = f"""You extract transactions from bank and card statements.
|
| 32 |
+
|
| 33 |
+
The statement may come from any bank in any country, in any currency, with any
|
| 34 |
+
column order or language. Work out the structure yourself from the content.
|
| 35 |
+
|
| 36 |
+
Return a JSON object with one key, "transactions", whose value is an array.
|
| 37 |
+
Each element must have exactly these fields:
|
| 38 |
+
"date": ISO 8601 date, YYYY-MM-DD
|
| 39 |
+
"description": the merchant or narration, cleaned of reference numbers
|
| 40 |
+
"amount": a positive number in major units (e.g. 1234.56), never a string
|
| 41 |
+
"currency": ISO 4217 code, e.g. INR, USD, EUR
|
| 42 |
+
"direction": "debit" if money left the account, "credit" if it came in
|
| 43 |
+
"category": one of: {_CATEGORY_LIST}
|
| 44 |
+
|
| 45 |
+
Rules:
|
| 46 |
+
- Infer the currency from symbols or codes in the document. If genuinely absent,
|
| 47 |
+
use "{{default_currency}}".
|
| 48 |
+
- Amounts are always positive; use "direction" to express the sign.
|
| 49 |
+
- Ambiguous dates are day-first unless the document proves otherwise.
|
| 50 |
+
- Skip opening/closing balances, subtotals, carried-forward lines and headers.
|
| 51 |
+
- If you cannot read a row confidently, omit it rather than guessing.
|
| 52 |
+
- Return only the JSON object."""
|
| 53 |
+
|
| 54 |
+
_RECEIPT_SYSTEM = f"""You read receipts and bills from photographs.
|
| 55 |
+
|
| 56 |
+
Return a JSON object with these fields:
|
| 57 |
+
"merchant": the shop or business name, "" if unreadable
|
| 58 |
+
"date": ISO 8601 date YYYY-MM-DD, or "" if not visible
|
| 59 |
+
"amount": the FINAL TOTAL paid, positive, in major units, as a number
|
| 60 |
+
"currency": ISO 4217 code inferred from the receipt
|
| 61 |
+
"category": one of: {_CATEGORY_LIST}
|
| 62 |
+
"confidence": 0.0-1.0, how sure you are of the total
|
| 63 |
+
|
| 64 |
+
Take the grand total actually paid -- not a subtotal, not a single line item,
|
| 65 |
+
not the tax. If the image is not a receipt, set amount to 0."""
|
| 66 |
+
|
| 67 |
+
_FREETEXT_SYSTEM = f"""You turn a person's shorthand note into one expense record.
|
| 68 |
+
|
| 69 |
+
Return a JSON object with:
|
| 70 |
+
"amount": positive number in major units
|
| 71 |
+
"currency": ISO 4217 code
|
| 72 |
+
"description": short, human-readable
|
| 73 |
+
"date": ISO 8601 YYYY-MM-DD
|
| 74 |
+
"category": one of: {_CATEGORY_LIST}
|
| 75 |
+
|
| 76 |
+
If the note contains no spend at all, return {{"amount": 0}}."""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _parse_any_date(value: Any, fallback: date) -> date:
|
| 80 |
+
if isinstance(value, date) and not isinstance(value, datetime):
|
| 81 |
+
return value
|
| 82 |
+
if isinstance(value, datetime):
|
| 83 |
+
return value.date()
|
| 84 |
+
text = str(value or "").strip()
|
| 85 |
+
if not text:
|
| 86 |
+
return fallback
|
| 87 |
+
try:
|
| 88 |
+
return date.fromisoformat(text[:10])
|
| 89 |
+
except ValueError:
|
| 90 |
+
pass
|
| 91 |
+
try:
|
| 92 |
+
from dateutil import parser as dateparser
|
| 93 |
+
|
| 94 |
+
return dateparser.parse(text, dayfirst=True, fuzzy=True).date()
|
| 95 |
+
except Exception:
|
| 96 |
+
return fallback
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _coerce_amount(value: Any) -> Optional[float]:
|
| 100 |
+
if isinstance(value, (int, float)):
|
| 101 |
+
return abs(float(value))
|
| 102 |
+
text = str(value or "").strip()
|
| 103 |
+
if not text:
|
| 104 |
+
return None
|
| 105 |
+
# Strip everything that isn't numeric, then reuse the shared separator logic.
|
| 106 |
+
from .money import _normalise_number # local import: private helper
|
| 107 |
+
|
| 108 |
+
cleaned = "".join(ch for ch in text if ch.isdigit() or ch in ".,-")
|
| 109 |
+
cleaned = cleaned.lstrip("-")
|
| 110 |
+
normalised = _normalise_number(cleaned)
|
| 111 |
+
if normalised is None:
|
| 112 |
+
return None
|
| 113 |
+
try:
|
| 114 |
+
return abs(float(normalised))
|
| 115 |
+
except ValueError:
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _draft_from_obj(
|
| 120 |
+
obj: dict, default_currency: str, today: date
|
| 121 |
+
) -> Optional[ExpenseDraft]:
|
| 122 |
+
"""Validate one model-emitted record into a draft, or drop it."""
|
| 123 |
+
if not isinstance(obj, dict):
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
amount = _coerce_amount(obj.get("amount"))
|
| 127 |
+
if not amount or amount <= 0:
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
currency = str(obj.get("currency") or default_currency).upper()[:3]
|
| 131 |
+
if not currency.isalpha():
|
| 132 |
+
currency = default_currency.upper()
|
| 133 |
+
|
| 134 |
+
description = str(obj.get("description") or obj.get("merchant") or "").strip()
|
| 135 |
+
description = " ".join(description.split())[:200] or "unlabelled"
|
| 136 |
+
|
| 137 |
+
category = coerce(str(obj.get("category") or ""))
|
| 138 |
+
if category == "other":
|
| 139 |
+
# The model's label was unusable; keyword rules often still know this one.
|
| 140 |
+
guessed, hit = categorise(description)
|
| 141 |
+
if hit:
|
| 142 |
+
category = guessed
|
| 143 |
+
|
| 144 |
+
return ExpenseDraft(
|
| 145 |
+
amount_minor=to_minor(amount, currency),
|
| 146 |
+
currency=currency,
|
| 147 |
+
description=description,
|
| 148 |
+
occurred_on=_parse_any_date(obj.get("date"), today),
|
| 149 |
+
category=category,
|
| 150 |
+
merchant=str(obj.get("merchant") or "").strip()[:120],
|
| 151 |
+
confidence=float(obj.get("confidence") or 0.7),
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class Ingestor:
|
| 156 |
+
def __init__(self, llm: LLMClient, default_currency: str = "INR"):
|
| 157 |
+
self.llm = llm
|
| 158 |
+
self.default_currency = default_currency
|
| 159 |
+
|
| 160 |
+
# ------------------------------------------------------------ free text
|
| 161 |
+
|
| 162 |
+
def from_text(self, text: str, today: Optional[date] = None) -> Optional[ExpenseDraft]:
|
| 163 |
+
"""Deterministic parse, with a model fallback only if that fails."""
|
| 164 |
+
today = today or date.today()
|
| 165 |
+
|
| 166 |
+
draft = parse_expense(text, self.default_currency, today)
|
| 167 |
+
if draft is not None:
|
| 168 |
+
return draft
|
| 169 |
+
|
| 170 |
+
if not self.llm.enabled:
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
obj = self.llm.chat_json(
|
| 175 |
+
_FREETEXT_SYSTEM,
|
| 176 |
+
f"Today is {today.isoformat()}.\n\nNote: {text}",
|
| 177 |
+
max_tokens=400,
|
| 178 |
+
)
|
| 179 |
+
except (LLMUnavailable, ValueError) as exc:
|
| 180 |
+
log.info("Free-text model fallback unavailable: %s", exc)
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
return _draft_from_obj(obj, self.default_currency, today)
|
| 184 |
+
|
| 185 |
+
# -------------------------------------------------------------- receipts
|
| 186 |
+
|
| 187 |
+
def from_receipt(
|
| 188 |
+
self, image_bytes: bytes, mime_type: str, today: Optional[date] = None
|
| 189 |
+
) -> tuple[Optional[ExpenseDraft], str]:
|
| 190 |
+
"""Read a receipt photo. Returns (draft, error_message)."""
|
| 191 |
+
today = today or date.today()
|
| 192 |
+
|
| 193 |
+
if not self.llm.enabled:
|
| 194 |
+
return None, (
|
| 195 |
+
"Reading receipt photos needs an inference token. "
|
| 196 |
+
"Set HF_TOKEN in the Space secrets, or type the amount instead."
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
try:
|
| 200 |
+
obj = self.llm.vision_json(
|
| 201 |
+
_RECEIPT_SYSTEM,
|
| 202 |
+
f"Today is {today.isoformat()}. Read this receipt.",
|
| 203 |
+
image_bytes,
|
| 204 |
+
mime_type,
|
| 205 |
+
)
|
| 206 |
+
except (LLMUnavailable, ValueError) as exc:
|
| 207 |
+
log.warning("Receipt read failed: %s", exc)
|
| 208 |
+
return None, f"I couldn't read that receipt ({exc}). Try a clearer photo?"
|
| 209 |
+
|
| 210 |
+
draft = _draft_from_obj(obj, self.default_currency, today)
|
| 211 |
+
if draft is None:
|
| 212 |
+
return None, (
|
| 213 |
+
"I couldn't find a total on that image. If it is a receipt, "
|
| 214 |
+
"try a straighter, better-lit photo, or just type the amount."
|
| 215 |
+
)
|
| 216 |
+
return draft, ""
|
| 217 |
+
|
| 218 |
+
# ------------------------------------------------------------ statements
|
| 219 |
+
|
| 220 |
+
def from_document(
|
| 221 |
+
self,
|
| 222 |
+
doc: ExtractedDocument,
|
| 223 |
+
user_id: int,
|
| 224 |
+
today: Optional[date] = None,
|
| 225 |
+
label: str = "",
|
| 226 |
+
) -> tuple[Optional[PendingImport], str]:
|
| 227 |
+
"""Map an extracted statement into drafts. Returns (pending, error)."""
|
| 228 |
+
today = today or date.today()
|
| 229 |
+
|
| 230 |
+
if doc.kind == "unsupported" or doc.is_empty:
|
| 231 |
+
return None, doc.note or "There was nothing readable in that file."
|
| 232 |
+
|
| 233 |
+
if not self.llm.enabled:
|
| 234 |
+
return None, (
|
| 235 |
+
"Reading statements needs an inference token. Set HF_TOKEN in "
|
| 236 |
+
"the Space secrets and send the file again."
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
payload = rows_to_text(doc.rows) if doc.rows else doc.text
|
| 240 |
+
if not payload.strip():
|
| 241 |
+
return None, "There was nothing readable in that file."
|
| 242 |
+
|
| 243 |
+
system = _STATEMENT_SYSTEM.replace("{default_currency}", self.default_currency)
|
| 244 |
+
try:
|
| 245 |
+
result = self.llm.chat_json(
|
| 246 |
+
system,
|
| 247 |
+
f"Today is {today.isoformat()}. Extract every transaction.\n\n{payload}",
|
| 248 |
+
max_tokens=8000,
|
| 249 |
+
)
|
| 250 |
+
except (LLMUnavailable, ValueError) as exc:
|
| 251 |
+
log.warning("Statement extraction failed: %s", exc)
|
| 252 |
+
return None, f"I couldn't read that statement ({exc})."
|
| 253 |
+
|
| 254 |
+
records = result.get("transactions") if isinstance(result, dict) else result
|
| 255 |
+
if not isinstance(records, list):
|
| 256 |
+
return None, "The extractor returned an unexpected shape. Try again?"
|
| 257 |
+
|
| 258 |
+
drafts: list[ExpenseDraft] = []
|
| 259 |
+
skipped = 0
|
| 260 |
+
credits = 0
|
| 261 |
+
for record in records:
|
| 262 |
+
if not isinstance(record, dict):
|
| 263 |
+
skipped += 1
|
| 264 |
+
continue
|
| 265 |
+
# Credits are money coming in. Storing them alongside spend would
|
| 266 |
+
# corrupt every "total spent" figure, so they are counted and
|
| 267 |
+
# reported rather than written.
|
| 268 |
+
if str(record.get("direction", "debit")).lower().startswith("cred"):
|
| 269 |
+
credits += 1
|
| 270 |
+
continue
|
| 271 |
+
draft = _draft_from_obj(record, self.default_currency, today)
|
| 272 |
+
if draft is None:
|
| 273 |
+
skipped += 1
|
| 274 |
+
continue
|
| 275 |
+
drafts.append(draft)
|
| 276 |
+
|
| 277 |
+
if not drafts:
|
| 278 |
+
detail = f" ({credits} credits, {skipped} unreadable rows)" if (credits or skipped) else ""
|
| 279 |
+
return None, f"I found no spending transactions in that file{detail}."
|
| 280 |
+
|
| 281 |
+
pending = PendingImport(
|
| 282 |
+
user_id=user_id,
|
| 283 |
+
drafts=drafts,
|
| 284 |
+
source=doc.kind,
|
| 285 |
+
skipped=skipped,
|
| 286 |
+
label=label,
|
| 287 |
+
)
|
| 288 |
+
if credits:
|
| 289 |
+
pending.label = (pending.label + f" · {credits} credits skipped").strip(" ·")
|
| 290 |
+
if doc.truncated:
|
| 291 |
+
pending.label = (pending.label + " · file truncated").strip(" ·")
|
| 292 |
+
return pending, ""
|
finbot/limits.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-user throttling for model-backed work.
|
| 2 |
+
|
| 3 |
+
Only relevant in open-access mode. Reading a receipt or a statement spends the
|
| 4 |
+
Space owner's inference credits, so without a cap a single stranger with a
|
| 5 |
+
folder of PDFs can run up an unbounded bill on someone else's token.
|
| 6 |
+
|
| 7 |
+
Deliberately in-memory: it resets when the Space restarts or sleeps, which is
|
| 8 |
+
fine for a budget guard and avoids putting other people's usage records in the
|
| 9 |
+
owner's dataset. Plain text costs nothing (the deterministic parser handles it)
|
| 10 |
+
and is never throttled.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import threading
|
| 16 |
+
import time
|
| 17 |
+
from collections import defaultdict
|
| 18 |
+
|
| 19 |
+
WINDOW_SECONDS = 24 * 60 * 60
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class RateLimiter:
|
| 23 |
+
def __init__(self, per_day: int):
|
| 24 |
+
#: Zero or negative disables the limiter entirely.
|
| 25 |
+
self.per_day = per_day
|
| 26 |
+
self._hits: dict[int, list[float]] = defaultdict(list)
|
| 27 |
+
self._lock = threading.Lock()
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def enabled(self) -> bool:
|
| 31 |
+
return self.per_day > 0
|
| 32 |
+
|
| 33 |
+
def _prune(self, user_id: int, now: float) -> list[float]:
|
| 34 |
+
recent = [t for t in self._hits[user_id] if now - t < WINDOW_SECONDS]
|
| 35 |
+
self._hits[user_id] = recent
|
| 36 |
+
return recent
|
| 37 |
+
|
| 38 |
+
def check(self, user_id: int, now: float | None = None) -> bool:
|
| 39 |
+
"""Record an attempt. False means the user is over their daily cap."""
|
| 40 |
+
if not self.enabled:
|
| 41 |
+
return True
|
| 42 |
+
now = now if now is not None else time.time()
|
| 43 |
+
with self._lock:
|
| 44 |
+
recent = self._prune(user_id, now)
|
| 45 |
+
if len(recent) >= self.per_day:
|
| 46 |
+
return False
|
| 47 |
+
recent.append(now)
|
| 48 |
+
return True
|
| 49 |
+
|
| 50 |
+
def remaining(self, user_id: int, now: float | None = None) -> int:
|
| 51 |
+
if not self.enabled:
|
| 52 |
+
return -1 # unlimited
|
| 53 |
+
now = now if now is not None else time.time()
|
| 54 |
+
with self._lock:
|
| 55 |
+
return max(0, self.per_day - len(self._prune(user_id, now)))
|
finbot/llm.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inference against Hugging Face Inference Providers.
|
| 2 |
+
|
| 3 |
+
The router at ``https://router.huggingface.co/v1`` is OpenAI-compatible, so one
|
| 4 |
+
HF token fans out across Cerebras, Together, Fireworks and friends without any
|
| 5 |
+
provider-specific code. Point ``LLM_BASE_URL`` somewhere else -- Ollama, a
|
| 6 |
+
llama.cpp server, OpenAI proper -- and everything here still works.
|
| 7 |
+
|
| 8 |
+
Every entry point degrades rather than raising into the request path: if no
|
| 9 |
+
token is configured, or the provider is down, the caller gets
|
| 10 |
+
:class:`LLMUnavailable` and falls back to deterministic parsing. Losing receipt
|
| 11 |
+
understanding is an inconvenience; a bot that stops accepting "coffee 250"
|
| 12 |
+
because an inference provider is having a bad day is a broken bot.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import base64
|
| 18 |
+
import json
|
| 19 |
+
import logging
|
| 20 |
+
import re
|
| 21 |
+
import time
|
| 22 |
+
from typing import Any, Optional
|
| 23 |
+
|
| 24 |
+
import httpx
|
| 25 |
+
|
| 26 |
+
from .config import Settings
|
| 27 |
+
|
| 28 |
+
log = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
_RETRY_STATUS = {408, 409, 425, 429, 500, 502, 503, 504}
|
| 31 |
+
_MAX_ATTEMPTS = 3
|
| 32 |
+
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.S)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class LLMUnavailable(RuntimeError):
|
| 36 |
+
"""Inference could not be performed. Callers must degrade gracefully."""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _extract_json_blob(text: str) -> Any:
|
| 40 |
+
"""Parse JSON out of a model response that may be wrapped in prose or fences."""
|
| 41 |
+
text = (text or "").strip()
|
| 42 |
+
if not text:
|
| 43 |
+
raise ValueError("empty response")
|
| 44 |
+
|
| 45 |
+
if match := _FENCE_RE.search(text):
|
| 46 |
+
text = match.group(1).strip()
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
return json.loads(text)
|
| 50 |
+
except json.JSONDecodeError:
|
| 51 |
+
pass
|
| 52 |
+
|
| 53 |
+
# Fall back to the first balanced {...} or [...] in the response.
|
| 54 |
+
for opener, closer in (("{", "}"), ("[", "]")):
|
| 55 |
+
start = text.find(opener)
|
| 56 |
+
if start == -1:
|
| 57 |
+
continue
|
| 58 |
+
depth = 0
|
| 59 |
+
in_string = False
|
| 60 |
+
escape = False
|
| 61 |
+
for i in range(start, len(text)):
|
| 62 |
+
ch = text[i]
|
| 63 |
+
if escape:
|
| 64 |
+
escape = False
|
| 65 |
+
continue
|
| 66 |
+
if ch == "\\":
|
| 67 |
+
escape = True
|
| 68 |
+
continue
|
| 69 |
+
if ch == '"':
|
| 70 |
+
in_string = not in_string
|
| 71 |
+
continue
|
| 72 |
+
if in_string:
|
| 73 |
+
continue
|
| 74 |
+
if ch == opener:
|
| 75 |
+
depth += 1
|
| 76 |
+
elif ch == closer:
|
| 77 |
+
depth -= 1
|
| 78 |
+
if depth == 0:
|
| 79 |
+
return json.loads(text[start : i + 1])
|
| 80 |
+
raise ValueError(f"no JSON found in response: {text[:200]}")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class LLMClient:
|
| 84 |
+
def __init__(self, settings: Settings):
|
| 85 |
+
self.settings = settings
|
| 86 |
+
self._client: Optional[httpx.Client] = None
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def enabled(self) -> bool:
|
| 90 |
+
return self.settings.llm_enabled
|
| 91 |
+
|
| 92 |
+
def _http(self) -> httpx.Client:
|
| 93 |
+
if self._client is None:
|
| 94 |
+
self._client = httpx.Client(
|
| 95 |
+
base_url=self.settings.llm_base_url.rstrip("/"),
|
| 96 |
+
headers={
|
| 97 |
+
"Authorization": f"Bearer {self.settings.hf_token}",
|
| 98 |
+
"Content-Type": "application/json",
|
| 99 |
+
},
|
| 100 |
+
timeout=httpx.Timeout(90.0, connect=15.0),
|
| 101 |
+
)
|
| 102 |
+
return self._client
|
| 103 |
+
|
| 104 |
+
def close(self) -> None:
|
| 105 |
+
if self._client is not None:
|
| 106 |
+
self._client.close()
|
| 107 |
+
self._client = None
|
| 108 |
+
|
| 109 |
+
def _post_chat(self, payload: dict) -> str:
|
| 110 |
+
if not self.enabled:
|
| 111 |
+
raise LLMUnavailable("HF_TOKEN is not set")
|
| 112 |
+
|
| 113 |
+
last_error: Optional[str] = None
|
| 114 |
+
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
| 115 |
+
try:
|
| 116 |
+
response = self._http().post("/chat/completions", json=payload)
|
| 117 |
+
except httpx.RequestError as exc:
|
| 118 |
+
last_error = f"network error: {exc}"
|
| 119 |
+
log.warning("Inference attempt %d failed: %s", attempt, last_error)
|
| 120 |
+
time.sleep(min(2 ** attempt, 8))
|
| 121 |
+
continue
|
| 122 |
+
|
| 123 |
+
if response.status_code in _RETRY_STATUS and attempt < _MAX_ATTEMPTS:
|
| 124 |
+
retry_after = response.headers.get("retry-after")
|
| 125 |
+
delay = float(retry_after) if retry_after and retry_after.isdigit() else min(2 ** attempt, 8)
|
| 126 |
+
last_error = f"HTTP {response.status_code}"
|
| 127 |
+
log.warning("Inference attempt %d got %s; retrying", attempt, last_error)
|
| 128 |
+
time.sleep(delay)
|
| 129 |
+
continue
|
| 130 |
+
|
| 131 |
+
if response.status_code >= 400:
|
| 132 |
+
raise LLMUnavailable(
|
| 133 |
+
f"HTTP {response.status_code}: {response.text[:300]}"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
data = response.json()
|
| 137 |
+
try:
|
| 138 |
+
return data["choices"][0]["message"]["content"] or ""
|
| 139 |
+
except (KeyError, IndexError, TypeError) as exc:
|
| 140 |
+
raise LLMUnavailable(f"unexpected response shape: {exc}") from exc
|
| 141 |
+
|
| 142 |
+
raise LLMUnavailable(last_error or "exhausted retries")
|
| 143 |
+
|
| 144 |
+
def chat(
|
| 145 |
+
self,
|
| 146 |
+
messages: list[dict],
|
| 147 |
+
*,
|
| 148 |
+
model: Optional[str] = None,
|
| 149 |
+
temperature: float = 0.2,
|
| 150 |
+
max_tokens: int = 1200,
|
| 151 |
+
) -> str:
|
| 152 |
+
payload = {
|
| 153 |
+
"model": model or self.settings.llm_model,
|
| 154 |
+
"messages": messages,
|
| 155 |
+
"temperature": temperature,
|
| 156 |
+
"max_tokens": max_tokens,
|
| 157 |
+
}
|
| 158 |
+
return self._post_chat(payload)
|
| 159 |
+
|
| 160 |
+
def chat_json(
|
| 161 |
+
self,
|
| 162 |
+
system: str,
|
| 163 |
+
user: str,
|
| 164 |
+
*,
|
| 165 |
+
model: Optional[str] = None,
|
| 166 |
+
temperature: float = 0.0,
|
| 167 |
+
max_tokens: int = 4000,
|
| 168 |
+
) -> Any:
|
| 169 |
+
"""Chat constrained to JSON, with a repair attempt on malformed output."""
|
| 170 |
+
messages = [
|
| 171 |
+
{"role": "system", "content": system},
|
| 172 |
+
{"role": "user", "content": user},
|
| 173 |
+
]
|
| 174 |
+
payload: dict[str, Any] = {
|
| 175 |
+
"model": model or self.settings.llm_model,
|
| 176 |
+
"messages": messages,
|
| 177 |
+
"temperature": temperature,
|
| 178 |
+
"max_tokens": max_tokens,
|
| 179 |
+
"response_format": {"type": "json_object"},
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
try:
|
| 183 |
+
raw = self._post_chat(payload)
|
| 184 |
+
except LLMUnavailable as exc:
|
| 185 |
+
# Not every provider on the router honours response_format. Retry
|
| 186 |
+
# once without it before giving up.
|
| 187 |
+
if "response_format" not in str(exc):
|
| 188 |
+
payload.pop("response_format", None)
|
| 189 |
+
raw = self._post_chat(payload)
|
| 190 |
+
else:
|
| 191 |
+
raise
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
return _extract_json_blob(raw)
|
| 195 |
+
except ValueError as exc:
|
| 196 |
+
log.warning("Malformed JSON from model, attempting repair: %s", exc)
|
| 197 |
+
repair = [
|
| 198 |
+
{"role": "system", "content": "Return only valid JSON. No prose, no code fences."},
|
| 199 |
+
{"role": "user", "content": f"Fix this into valid JSON:\n\n{raw[:6000]}"},
|
| 200 |
+
]
|
| 201 |
+
repaired = self._post_chat(
|
| 202 |
+
{
|
| 203 |
+
"model": model or self.settings.llm_model,
|
| 204 |
+
"messages": repair,
|
| 205 |
+
"temperature": 0.0,
|
| 206 |
+
"max_tokens": max_tokens,
|
| 207 |
+
}
|
| 208 |
+
)
|
| 209 |
+
return _extract_json_blob(repaired)
|
| 210 |
+
|
| 211 |
+
def vision_json(
|
| 212 |
+
self,
|
| 213 |
+
system: str,
|
| 214 |
+
user: str,
|
| 215 |
+
image_bytes: bytes,
|
| 216 |
+
mime_type: str = "image/jpeg",
|
| 217 |
+
*,
|
| 218 |
+
max_tokens: int = 1500,
|
| 219 |
+
) -> Any:
|
| 220 |
+
"""Send an image plus instructions and parse a JSON reply."""
|
| 221 |
+
if not self.enabled:
|
| 222 |
+
raise LLMUnavailable("HF_TOKEN is not set")
|
| 223 |
+
|
| 224 |
+
encoded = base64.b64encode(image_bytes).decode("ascii")
|
| 225 |
+
messages = [
|
| 226 |
+
{"role": "system", "content": system},
|
| 227 |
+
{
|
| 228 |
+
"role": "user",
|
| 229 |
+
"content": [
|
| 230 |
+
{"type": "text", "text": user},
|
| 231 |
+
{
|
| 232 |
+
"type": "image_url",
|
| 233 |
+
"image_url": {"url": f"data:{mime_type};base64,{encoded}"},
|
| 234 |
+
},
|
| 235 |
+
],
|
| 236 |
+
},
|
| 237 |
+
]
|
| 238 |
+
raw = self._post_chat(
|
| 239 |
+
{
|
| 240 |
+
"model": self.settings.llm_vision_model,
|
| 241 |
+
"messages": messages,
|
| 242 |
+
"temperature": 0.0,
|
| 243 |
+
"max_tokens": max_tokens,
|
| 244 |
+
}
|
| 245 |
+
)
|
| 246 |
+
return _extract_json_blob(raw)
|
finbot/models.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain records. Plain dataclasses -- the storage layer maps them to SQLite."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import re
|
| 7 |
+
import uuid
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from datetime import date, datetime, timezone
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from .money import format_money
|
| 13 |
+
|
| 14 |
+
#: Where a record came from. Kept on every expense so a bad import can be
|
| 15 |
+
#: found and reversed as a unit.
|
| 16 |
+
SOURCES = ("text", "photo", "pdf", "csv", "xlsx", "manual")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _now() -> datetime:
|
| 20 |
+
return datetime.now(timezone.utc)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _slug(text: str) -> str:
|
| 24 |
+
"""Loose normalisation for dedupe: case, punctuation and spacing-insensitive."""
|
| 25 |
+
return re.sub(r"[^a-z0-9]+", "", text.lower())
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class Expense:
|
| 30 |
+
user_id: int
|
| 31 |
+
amount_minor: int
|
| 32 |
+
currency: str
|
| 33 |
+
description: str
|
| 34 |
+
category: str
|
| 35 |
+
occurred_on: date
|
| 36 |
+
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
| 37 |
+
merchant: str = ""
|
| 38 |
+
source: str = "text"
|
| 39 |
+
#: Groups rows that arrived together, so /undo can drop a whole import.
|
| 40 |
+
batch_id: str = ""
|
| 41 |
+
note: str = ""
|
| 42 |
+
#: 1.0 for deterministic parses, lower for model-extracted rows.
|
| 43 |
+
confidence: float = 1.0
|
| 44 |
+
created_at: datetime = field(default_factory=_now)
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def dedupe_key(self) -> str:
|
| 48 |
+
"""Stable identity for 'the same transaction'.
|
| 49 |
+
|
| 50 |
+
Deliberately excludes id, source and confidence so that the same row
|
| 51 |
+
re-imported from a re-downloaded statement collapses onto the original.
|
| 52 |
+
"""
|
| 53 |
+
basis = "|".join(
|
| 54 |
+
[
|
| 55 |
+
str(self.user_id),
|
| 56 |
+
str(self.amount_minor),
|
| 57 |
+
self.currency.upper(),
|
| 58 |
+
self.occurred_on.isoformat(),
|
| 59 |
+
_slug(self.merchant or self.description),
|
| 60 |
+
]
|
| 61 |
+
)
|
| 62 |
+
return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:32]
|
| 63 |
+
|
| 64 |
+
def display(self) -> str:
|
| 65 |
+
amount = format_money(self.amount_minor, self.currency)
|
| 66 |
+
label = self.merchant or self.description or self.category
|
| 67 |
+
return f"{amount} · {label} · {self.category} · {self.occurred_on.isoformat()}"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class Budget:
|
| 72 |
+
user_id: int
|
| 73 |
+
category: str
|
| 74 |
+
amount_minor: int
|
| 75 |
+
currency: str
|
| 76 |
+
period: str = "monthly"
|
| 77 |
+
created_at: datetime = field(default_factory=_now)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@dataclass
|
| 81 |
+
class ExpenseDraft:
|
| 82 |
+
"""A candidate row awaiting confirmation.
|
| 83 |
+
|
| 84 |
+
Statement and receipt imports land here first. Nothing model-extracted is
|
| 85 |
+
written to the ledger without the user seeing the total and saying yes.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
amount_minor: int
|
| 89 |
+
currency: str
|
| 90 |
+
description: str
|
| 91 |
+
occurred_on: date
|
| 92 |
+
category: str = "other"
|
| 93 |
+
merchant: str = ""
|
| 94 |
+
confidence: float = 0.5
|
| 95 |
+
raw: str = ""
|
| 96 |
+
|
| 97 |
+
def to_expense(
|
| 98 |
+
self, user_id: int, source: str, batch_id: str, note: str = ""
|
| 99 |
+
) -> Expense:
|
| 100 |
+
return Expense(
|
| 101 |
+
user_id=user_id,
|
| 102 |
+
amount_minor=self.amount_minor,
|
| 103 |
+
currency=self.currency,
|
| 104 |
+
description=self.description,
|
| 105 |
+
category=self.category,
|
| 106 |
+
occurred_on=self.occurred_on,
|
| 107 |
+
merchant=self.merchant,
|
| 108 |
+
source=source,
|
| 109 |
+
batch_id=batch_id,
|
| 110 |
+
note=note,
|
| 111 |
+
confidence=self.confidence,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@dataclass
|
| 116 |
+
class PendingImport:
|
| 117 |
+
"""Drafts held between 'here is your statement' and '/confirm'."""
|
| 118 |
+
|
| 119 |
+
user_id: int
|
| 120 |
+
drafts: list[ExpenseDraft]
|
| 121 |
+
source: str
|
| 122 |
+
batch_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
| 123 |
+
created_at: datetime = field(default_factory=_now)
|
| 124 |
+
#: Rows dropped because they already exist in the ledger.
|
| 125 |
+
duplicates: int = 0
|
| 126 |
+
#: Rows the extractor emitted but could not be made sense of.
|
| 127 |
+
skipped: int = 0
|
| 128 |
+
label: str = ""
|
| 129 |
+
|
| 130 |
+
def total_by_currency(self) -> dict[str, int]:
|
| 131 |
+
totals: dict[str, int] = {}
|
| 132 |
+
for d in self.drafts:
|
| 133 |
+
totals[d.currency] = totals.get(d.currency, 0) + d.amount_minor
|
| 134 |
+
return totals
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@dataclass
|
| 138 |
+
class Summary:
|
| 139 |
+
"""Aggregated spend for a period, ready to render."""
|
| 140 |
+
|
| 141 |
+
label: str
|
| 142 |
+
start: date
|
| 143 |
+
end: date
|
| 144 |
+
currency: str
|
| 145 |
+
total_minor: int
|
| 146 |
+
count: int
|
| 147 |
+
by_category: list[tuple[str, int]]
|
| 148 |
+
previous_total_minor: Optional[int] = None
|
| 149 |
+
|
| 150 |
+
@property
|
| 151 |
+
def delta_pct(self) -> Optional[float]:
|
| 152 |
+
if self.previous_total_minor is None or self.previous_total_minor == 0:
|
| 153 |
+
return None
|
| 154 |
+
return (
|
| 155 |
+
(self.total_minor - self.previous_total_minor)
|
| 156 |
+
/ self.previous_total_minor
|
| 157 |
+
* 100.0
|
| 158 |
+
)
|
finbot/money.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-currency amounts as integer minor units.
|
| 2 |
+
|
| 3 |
+
Money is never a float here. 0.1 + 0.2 != 0.3 in binary floating point, and a
|
| 4 |
+
ledger that drifts by a paisa per transaction is worse than useless -- so every
|
| 5 |
+
amount is an int of the currency's smallest unit (paise, cents, fils) plus an
|
| 6 |
+
ISO 4217 code.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
from typing import NamedTuple, Optional
|
| 13 |
+
|
| 14 |
+
# Currencies whose minor unit is not 1/100. Anything absent is assumed to have
|
| 15 |
+
# two decimal places, which is true for the large majority of ISO 4217.
|
| 16 |
+
_EXPONENTS: dict[str, int] = {
|
| 17 |
+
"JPY": 0, "KRW": 0, "VND": 0, "CLP": 0, "ISK": 0, "UGX": 0,
|
| 18 |
+
"PYG": 0, "RWF": 0, "XAF": 0, "XOF": 0, "XPF": 0, "KMF": 0,
|
| 19 |
+
"DJF": 0, "GNF": 0, "BIF": 0, "VUV": 0,
|
| 20 |
+
"BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
# Symbols that map to exactly one currency. Deliberately excludes bare "$",
|
| 24 |
+
# which is ambiguous across USD/CAD/AUD/SGD/HKD and many more -- see
|
| 25 |
+
# _AMBIGUOUS_SYMBOLS.
|
| 26 |
+
_SYMBOLS: dict[str, str] = {
|
| 27 |
+
"₹": "INR", "€": "EUR", "£": "GBP", "¥": "JPY", "₽": "RUB",
|
| 28 |
+
"₩": "KRW", "₪": "ILS", "₫": "VND", "₺": "TRY", "₦": "NGN",
|
| 29 |
+
"₴": "UAH", "₸": "KZT", "฿": "THB", "₡": "CRC", "₱": "PHP",
|
| 30 |
+
"﷼": "SAR", "Rs": "INR", "R$": "BRL", "CHF": "CHF",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# Symbols that need the configured default to disambiguate.
|
| 34 |
+
_AMBIGUOUS_SYMBOLS = {"$"}
|
| 35 |
+
|
| 36 |
+
_ISO_RE = re.compile(r"\b([A-Z]{3})\b")
|
| 37 |
+
|
| 38 |
+
# A maximal run of digits and separators; _normalise_number then decides what
|
| 39 |
+
# each separator meant. Handles 1234 / 1,234.56 / 1.234,56 / 1,23,456.78
|
| 40 |
+
# (Indian lakh grouping) / 1\xa0234,56.
|
| 41 |
+
#
|
| 42 |
+
# A plain ASCII space is deliberately NOT a group separator: French-style
|
| 43 |
+
# "1 234" is indistinguishable from two separate numbers, and reading
|
| 44 |
+
# "250 300" as 250300 is a far worse failure than missing that one format.
|
| 45 |
+
_NUMBER_RE = re.compile(r"\d[\d,.\xa0\u202f]*\d|\d")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class ParsedAmount(NamedTuple):
|
| 49 |
+
minor: int
|
| 50 |
+
currency: Optional[str]
|
| 51 |
+
#: The exact substring that produced this amount, so callers can strip it
|
| 52 |
+
#: out of a description without re-guessing.
|
| 53 |
+
span: tuple[int, int]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def exponent(currency: str) -> int:
|
| 57 |
+
"""Number of decimal places for an ISO 4217 code."""
|
| 58 |
+
return _EXPONENTS.get(currency.upper(), 2)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def to_minor(value: float | str, currency: str) -> int:
|
| 62 |
+
"""Convert a major-unit value to integer minor units, half-up.
|
| 63 |
+
|
| 64 |
+
Uses string/Decimal arithmetic rather than float rounding so that values
|
| 65 |
+
like 8.345 land where a human would expect.
|
| 66 |
+
"""
|
| 67 |
+
from decimal import Decimal, ROUND_HALF_UP
|
| 68 |
+
|
| 69 |
+
exp = exponent(currency)
|
| 70 |
+
quantum = Decimal(1).scaleb(-exp)
|
| 71 |
+
dec = Decimal(str(value)).quantize(quantum, rounding=ROUND_HALF_UP)
|
| 72 |
+
return int(dec.scaleb(exp))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def from_minor(minor: int, currency: str) -> float:
|
| 76 |
+
"""Minor units back to a major-unit float. Display only -- never for maths."""
|
| 77 |
+
return minor / (10 ** exponent(currency))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def format_money(minor: int, currency: str) -> str:
|
| 81 |
+
"""Human-readable amount, e.g. 123456 INR -> '₹1,234.56'."""
|
| 82 |
+
exp = exponent(currency)
|
| 83 |
+
sign = "-" if minor < 0 else ""
|
| 84 |
+
whole, frac = divmod(abs(minor), 10 ** exp)
|
| 85 |
+
symbol = _symbol_for(currency)
|
| 86 |
+
body = f"{whole:,}" if exp == 0 else f"{whole:,}.{frac:0{exp}d}"
|
| 87 |
+
if symbol:
|
| 88 |
+
return f"{sign}{symbol}{body}"
|
| 89 |
+
return f"{sign}{body} {currency.upper()}"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _symbol_for(currency: str) -> str:
|
| 93 |
+
for sym, code in _SYMBOLS.items():
|
| 94 |
+
# Only single-glyph symbols read well as prefixes.
|
| 95 |
+
if code == currency.upper() and len(sym) == 1:
|
| 96 |
+
return sym
|
| 97 |
+
if currency.upper() == "USD":
|
| 98 |
+
return "$"
|
| 99 |
+
return ""
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _normalise_number(raw: str) -> Optional[str]:
|
| 103 |
+
"""Turn a grouped numeric string into a plain decimal string.
|
| 104 |
+
|
| 105 |
+
The hard case is telling a decimal separator from a thousands separator.
|
| 106 |
+
Rules, in order:
|
| 107 |
+
1. If both ',' and '.' appear, the rightmost one is the decimal point.
|
| 108 |
+
2. If one appears once and is followed by exactly 2 digits, it is decimal.
|
| 109 |
+
3. Otherwise it is grouping and gets stripped.
|
| 110 |
+
"""
|
| 111 |
+
text = re.sub(r"[\s\xa0\u202f]", "", raw)
|
| 112 |
+
if not text:
|
| 113 |
+
return None
|
| 114 |
+
|
| 115 |
+
has_comma, has_dot = "," in text, "." in text
|
| 116 |
+
|
| 117 |
+
if has_comma and has_dot:
|
| 118 |
+
dec_sep = "," if text.rfind(",") > text.rfind(".") else "."
|
| 119 |
+
grp_sep = "." if dec_sep == "," else ","
|
| 120 |
+
text = text.replace(grp_sep, "").replace(dec_sep, ".")
|
| 121 |
+
elif has_comma or has_dot:
|
| 122 |
+
sep = "," if has_comma else "."
|
| 123 |
+
head, _, tail = text.rpartition(sep)
|
| 124 |
+
if text.count(sep) == 1 and len(tail) in (1, 2):
|
| 125 |
+
text = f"{head}.{tail}"
|
| 126 |
+
else:
|
| 127 |
+
text = text.replace(sep, "")
|
| 128 |
+
|
| 129 |
+
if not re.fullmatch(r"\d+(?:\.\d+)?", text):
|
| 130 |
+
return None
|
| 131 |
+
return text
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def detect_currency(text: str, default: str) -> str:
|
| 135 |
+
"""Best-effort currency for a free-text fragment."""
|
| 136 |
+
upper = text.upper()
|
| 137 |
+
for sym, code in _SYMBOLS.items():
|
| 138 |
+
symbol = sym.upper()
|
| 139 |
+
if symbol.isalpha():
|
| 140 |
+
# Letter symbols ("Rs", "CHF") need boundaries, or "cars" reads as
|
| 141 |
+
# rupees and "chfoo" as francs.
|
| 142 |
+
if re.search(rf"(?<![A-Z]){re.escape(symbol)}(?![A-Z])", upper):
|
| 143 |
+
return code
|
| 144 |
+
elif symbol in upper:
|
| 145 |
+
return code
|
| 146 |
+
for match in _ISO_RE.finditer(upper):
|
| 147 |
+
code = match.group(1)
|
| 148 |
+
if code in _EXPONENTS or code in set(_SYMBOLS.values()) or code in _COMMON_ISO:
|
| 149 |
+
return code
|
| 150 |
+
if any(sym in text for sym in _AMBIGUOUS_SYMBOLS):
|
| 151 |
+
return default.upper()
|
| 152 |
+
return default.upper()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
_COMMON_ISO = {
|
| 156 |
+
"USD", "EUR", "GBP", "INR", "JPY", "CNY", "AUD", "CAD", "SGD", "CHF",
|
| 157 |
+
"HKD", "NZD", "SEK", "NOK", "DKK", "ZAR", "AED", "SAR", "MYR", "THB",
|
| 158 |
+
"IDR", "PHP", "VND", "KRW", "BRL", "MXN", "RUB", "TRY", "PLN", "CZK",
|
| 159 |
+
"ILS", "NGN", "KES", "EGP", "PKR", "BDT", "LKR", "NPR",
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def parse_amount(text: str, default_currency: str = "INR") -> Optional[ParsedAmount]:
|
| 164 |
+
"""Pull the most plausible monetary amount out of free text.
|
| 165 |
+
|
| 166 |
+
Returns None when nothing numeric is present. Currency is resolved from an
|
| 167 |
+
explicit symbol or ISO code when one is given, else the supplied default.
|
| 168 |
+
"""
|
| 169 |
+
best: Optional[ParsedAmount] = None
|
| 170 |
+
for match in _NUMBER_RE.finditer(text):
|
| 171 |
+
normalised = _normalise_number(match.group(0))
|
| 172 |
+
if normalised is None:
|
| 173 |
+
continue
|
| 174 |
+
currency = detect_currency(text, default_currency)
|
| 175 |
+
minor = to_minor(normalised, currency)
|
| 176 |
+
candidate = ParsedAmount(minor=minor, currency=currency, span=match.span())
|
| 177 |
+
# Prefer the largest magnitude: in "2 coffees 250" the spend is 250.
|
| 178 |
+
if best is None or candidate.minor > best.minor:
|
| 179 |
+
best = candidate
|
| 180 |
+
return best
|
finbot/parser.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic parsing of free-text expense entry.
|
| 2 |
+
|
| 3 |
+
This is the fast path: "coffee 250", "₹1,200 groceries at bigbasket",
|
| 4 |
+
"spent 45.50 on lunch yesterday". It runs before any model call, costs
|
| 5 |
+
nothing, and is reproducible. Anything it cannot make sense of is handed to
|
| 6 |
+
the LLM by :mod:`finbot.ingest`.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
from datetime import date, timedelta
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from .categories import categorise, coerce
|
| 16 |
+
from .models import ExpenseDraft
|
| 17 |
+
from .money import parse_amount
|
| 18 |
+
|
| 19 |
+
_WEEKDAYS = {
|
| 20 |
+
"monday": 0, "mon": 0,
|
| 21 |
+
"tuesday": 1, "tue": 1, "tues": 1,
|
| 22 |
+
"wednesday": 2, "wed": 2,
|
| 23 |
+
"thursday": 3, "thu": 3, "thurs": 3,
|
| 24 |
+
"friday": 4, "fri": 4,
|
| 25 |
+
"saturday": 5, "sat": 5,
|
| 26 |
+
"sunday": 6, "sun": 6,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
_MONTHS = {
|
| 30 |
+
"jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3,
|
| 31 |
+
"apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7,
|
| 32 |
+
"aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9,
|
| 33 |
+
"oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# Words that carry no meaning in a description once the amount is removed.
|
| 37 |
+
_FILLER = {
|
| 38 |
+
"spent", "spend", "paid", "pay", "bought", "buy", "for", "on", "at", "in",
|
| 39 |
+
"of", "the", "a", "an", "to", "rs", "rs.", "inr", "cost", "costs", "was",
|
| 40 |
+
"were", "i", "my", "me", "some", "and", "with", "got",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
_ISO_DATE_RE = re.compile(r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b")
|
| 44 |
+
_DMY_RE = re.compile(r"\b(\d{1,2})[/-](\d{1,2})(?:[/-](\d{2,4}))?\b")
|
| 45 |
+
_DAY_MONTH_RE = re.compile(
|
| 46 |
+
r"\b(\d{1,2})(?:st|nd|rd|th)?\s+(" + "|".join(_MONTHS) + r")\b", re.I
|
| 47 |
+
)
|
| 48 |
+
_MONTH_DAY_RE = re.compile(
|
| 49 |
+
r"\b(" + "|".join(_MONTHS) + r")\s+(\d{1,2})(?:st|nd|rd|th)?\b", re.I
|
| 50 |
+
)
|
| 51 |
+
_DAYS_AGO_RE = re.compile(r"\b(\d{1,3})\s+days?\s+ago\b", re.I)
|
| 52 |
+
_LAST_WEEKDAY_RE = re.compile(
|
| 53 |
+
r"\b(?:last\s+)?(" + "|".join(_WEEKDAYS) + r")\b", re.I
|
| 54 |
+
)
|
| 55 |
+
_TAG_RE = re.compile(r"#([a-z_][a-z0-9_]*)", re.I)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _clamp_year(year: int) -> int:
|
| 59 |
+
if year < 100:
|
| 60 |
+
return 2000 + year
|
| 61 |
+
return year
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def parse_date_hint(
|
| 65 |
+
text: str, today: Optional[date] = None
|
| 66 |
+
) -> tuple[date, Optional[tuple[int, int]]]:
|
| 67 |
+
"""Resolve a date reference in ``text``.
|
| 68 |
+
|
| 69 |
+
Returns (resolved_date, span_of_the_phrase). Span is None when no hint was
|
| 70 |
+
found, in which case the date defaults to today. Dates that would land in
|
| 71 |
+
the future are pulled back a year -- "12 dec" typed in August means last
|
| 72 |
+
December, not a spend that has not happened yet.
|
| 73 |
+
"""
|
| 74 |
+
today = today or date.today()
|
| 75 |
+
lowered = text.lower()
|
| 76 |
+
|
| 77 |
+
if m := re.search(r"\bday before yesterday\b", lowered):
|
| 78 |
+
return today - timedelta(days=2), m.span()
|
| 79 |
+
if m := re.search(r"\byesterday\b", lowered):
|
| 80 |
+
return today - timedelta(days=1), m.span()
|
| 81 |
+
if m := re.search(r"\btoday\b", lowered):
|
| 82 |
+
return today, m.span()
|
| 83 |
+
|
| 84 |
+
if m := _DAYS_AGO_RE.search(lowered):
|
| 85 |
+
return today - timedelta(days=int(m.group(1))), m.span()
|
| 86 |
+
|
| 87 |
+
if m := _ISO_DATE_RE.search(lowered):
|
| 88 |
+
try:
|
| 89 |
+
return date(int(m.group(1)), int(m.group(2)), int(m.group(3))), m.span()
|
| 90 |
+
except ValueError:
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
+
if m := _DAY_MONTH_RE.search(lowered):
|
| 94 |
+
day, month = int(m.group(1)), _MONTHS[m.group(2).lower()]
|
| 95 |
+
try:
|
| 96 |
+
candidate = date(today.year, month, day)
|
| 97 |
+
if candidate > today:
|
| 98 |
+
candidate = date(today.year - 1, month, day)
|
| 99 |
+
return candidate, m.span()
|
| 100 |
+
except ValueError:
|
| 101 |
+
pass
|
| 102 |
+
|
| 103 |
+
if m := _MONTH_DAY_RE.search(lowered):
|
| 104 |
+
month, day = _MONTHS[m.group(1).lower()], int(m.group(2))
|
| 105 |
+
try:
|
| 106 |
+
candidate = date(today.year, month, day)
|
| 107 |
+
if candidate > today:
|
| 108 |
+
candidate = date(today.year - 1, month, day)
|
| 109 |
+
return candidate, m.span()
|
| 110 |
+
except ValueError:
|
| 111 |
+
pass
|
| 112 |
+
|
| 113 |
+
if m := _DMY_RE.search(lowered):
|
| 114 |
+
first, second, year_s = int(m.group(1)), int(m.group(2)), m.group(3)
|
| 115 |
+
year = _clamp_year(int(year_s)) if year_s else today.year
|
| 116 |
+
# Ambiguous d/m vs m/d. Prefer day-first, which covers most of the
|
| 117 |
+
# world, and fall back to month-first when day-first is impossible.
|
| 118 |
+
for day, month in ((first, second), (second, first)):
|
| 119 |
+
try:
|
| 120 |
+
candidate = date(year, month, day)
|
| 121 |
+
except ValueError:
|
| 122 |
+
continue
|
| 123 |
+
if not year_s and candidate > today:
|
| 124 |
+
candidate = date(year - 1, month, day)
|
| 125 |
+
return candidate, m.span()
|
| 126 |
+
|
| 127 |
+
if m := _LAST_WEEKDAY_RE.search(lowered):
|
| 128 |
+
target = _WEEKDAYS[m.group(1).lower()]
|
| 129 |
+
delta = (today.weekday() - target) % 7
|
| 130 |
+
delta = delta or 7 # bare weekday name means the most recent past one
|
| 131 |
+
return today - timedelta(days=delta), m.span()
|
| 132 |
+
|
| 133 |
+
return today, None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _clean_description(text: str, cuts: list[tuple[int, int]]) -> str:
|
| 137 |
+
"""Remove consumed spans, currency noise and filler words."""
|
| 138 |
+
chars = list(text)
|
| 139 |
+
for start, end in cuts:
|
| 140 |
+
for i in range(start, min(end, len(chars))):
|
| 141 |
+
chars[i] = " "
|
| 142 |
+
remaining = "".join(chars)
|
| 143 |
+
|
| 144 |
+
remaining = re.sub(r"[₹$€£¥₽₩₪₫₺₦₴₸฿₡₱﷼]", " ", remaining)
|
| 145 |
+
remaining = re.sub(r"\b[A-Z]{3}\b", " ", remaining)
|
| 146 |
+
remaining = _TAG_RE.sub(" ", remaining)
|
| 147 |
+
remaining = re.sub(r"[^\w\s&'-]", " ", remaining)
|
| 148 |
+
|
| 149 |
+
words = [w for w in remaining.split() if w.lower() not in _FILLER]
|
| 150 |
+
return re.sub(r"\s+", " ", " ".join(words)).strip()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def parse_expense(
|
| 154 |
+
text: str, default_currency: str = "INR", today: Optional[date] = None
|
| 155 |
+
) -> Optional[ExpenseDraft]:
|
| 156 |
+
"""Parse one free-text line into a draft, or None if no amount is present."""
|
| 157 |
+
if not text or not text.strip():
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
amount = parse_amount(text, default_currency)
|
| 161 |
+
if amount is None:
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
occurred_on, date_span = parse_date_hint(text, today)
|
| 165 |
+
|
| 166 |
+
cuts = [amount.span]
|
| 167 |
+
if date_span:
|
| 168 |
+
cuts.append(date_span)
|
| 169 |
+
|
| 170 |
+
description = _clean_description(text, cuts)
|
| 171 |
+
|
| 172 |
+
# An explicit #tag overrides keyword matching.
|
| 173 |
+
tag_match = _TAG_RE.search(text)
|
| 174 |
+
if tag_match:
|
| 175 |
+
category = coerce(tag_match.group(1))
|
| 176 |
+
confidence = 1.0
|
| 177 |
+
else:
|
| 178 |
+
category, confidence = categorise(description)
|
| 179 |
+
|
| 180 |
+
return ExpenseDraft(
|
| 181 |
+
amount_minor=amount.minor,
|
| 182 |
+
currency=amount.currency or default_currency.upper(),
|
| 183 |
+
description=description or "unlabelled",
|
| 184 |
+
occurred_on=occurred_on,
|
| 185 |
+
category=category,
|
| 186 |
+
confidence=1.0 if confidence > 0 else 0.6,
|
| 187 |
+
raw=text.strip(),
|
| 188 |
+
)
|
finbot/registry.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feature registration and update routing.
|
| 2 |
+
|
| 3 |
+
Dispatch order for one incoming update:
|
| 4 |
+
|
| 5 |
+
1. If it carries a file -> ``handle_attachment`` on each feature by priority.
|
| 6 |
+
2. If it starts with a slash -> the feature that owns that command.
|
| 7 |
+
3. Otherwise -> ``handle_message`` on each feature by priority.
|
| 8 |
+
|
| 9 |
+
First non-``None`` reply wins. A feature that returns ``None`` is declining,
|
| 10 |
+
which lets several features coexist over the same input without knowing about
|
| 11 |
+
each other.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
from typing import Iterable, Optional
|
| 18 |
+
|
| 19 |
+
from .features.base import BotContext, CommandSpec, Feature, Reply
|
| 20 |
+
|
| 21 |
+
log = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class DuplicateCommand(ValueError):
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class FeatureRegistry:
|
| 29 |
+
def __init__(self) -> None:
|
| 30 |
+
self._features: list[Feature] = []
|
| 31 |
+
self._commands: dict[str, tuple[Feature, CommandSpec]] = {}
|
| 32 |
+
|
| 33 |
+
def register(self, feature: Feature) -> Feature:
|
| 34 |
+
for spec in feature.commands():
|
| 35 |
+
for name in (spec.name, *spec.aliases):
|
| 36 |
+
if name in self._commands:
|
| 37 |
+
owner = self._commands[name][0].name
|
| 38 |
+
raise DuplicateCommand(
|
| 39 |
+
f"/{name} is already provided by feature '{owner}'"
|
| 40 |
+
)
|
| 41 |
+
self._commands[name] = (feature, spec)
|
| 42 |
+
self._features.append(feature)
|
| 43 |
+
self._features.sort(key=lambda f: f.priority)
|
| 44 |
+
return feature
|
| 45 |
+
|
| 46 |
+
def register_all(self, features: Iterable[Feature]) -> None:
|
| 47 |
+
for feature in features:
|
| 48 |
+
self.register(feature)
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def features(self) -> list[Feature]:
|
| 52 |
+
return list(self._features)
|
| 53 |
+
|
| 54 |
+
def known_command(self, name: str) -> bool:
|
| 55 |
+
return name in self._commands
|
| 56 |
+
|
| 57 |
+
def dispatch(self, ctx: BotContext) -> Optional[Reply]:
|
| 58 |
+
if ctx.attachment is not None:
|
| 59 |
+
for feature in self._features:
|
| 60 |
+
try:
|
| 61 |
+
if reply := feature.handle_attachment(ctx):
|
| 62 |
+
return reply
|
| 63 |
+
except Exception:
|
| 64 |
+
log.exception("Feature %s failed on attachment", feature.name)
|
| 65 |
+
return Reply(
|
| 66 |
+
"Something went wrong reading that file. "
|
| 67 |
+
"The error is in the Space logs."
|
| 68 |
+
)
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
if ctx.command:
|
| 72 |
+
entry = self._commands.get(ctx.command)
|
| 73 |
+
if entry is None:
|
| 74 |
+
return Reply(
|
| 75 |
+
f"I don't know /{_escape(ctx.command)}. Try /help for what I can do."
|
| 76 |
+
)
|
| 77 |
+
feature, _spec = entry
|
| 78 |
+
try:
|
| 79 |
+
return feature.handle_command(ctx)
|
| 80 |
+
except Exception:
|
| 81 |
+
log.exception("Feature %s failed on /%s", feature.name, ctx.command)
|
| 82 |
+
return Reply(
|
| 83 |
+
"Something went wrong running that command. "
|
| 84 |
+
"The error is in the Space logs."
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
for feature in self._features:
|
| 88 |
+
try:
|
| 89 |
+
if reply := feature.handle_message(ctx):
|
| 90 |
+
return reply
|
| 91 |
+
except Exception:
|
| 92 |
+
log.exception("Feature %s failed on message", feature.name)
|
| 93 |
+
return Reply(
|
| 94 |
+
"Something went wrong handling that. The error is in the Space logs."
|
| 95 |
+
)
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
def help_text(self) -> str:
|
| 99 |
+
"""Grouped /help built from whatever is registered."""
|
| 100 |
+
blocks: list[str] = []
|
| 101 |
+
for feature in self._features:
|
| 102 |
+
visible = [s for s in feature.commands() if not s.hidden]
|
| 103 |
+
if not visible:
|
| 104 |
+
continue
|
| 105 |
+
lines = [f"<b>{_escape(feature.description or feature.name)}</b>"]
|
| 106 |
+
for spec in visible:
|
| 107 |
+
lines.append(f"<code>{_escape(spec.usage)}</code> — {_escape(spec.help)}")
|
| 108 |
+
blocks.append("\n".join(lines))
|
| 109 |
+
return "\n\n".join(blocks)
|
| 110 |
+
|
| 111 |
+
def telegram_commands(self) -> list[dict[str, str]]:
|
| 112 |
+
"""Payload for Telegram's setMyCommands."""
|
| 113 |
+
seen: set[str] = set()
|
| 114 |
+
out: list[dict[str, str]] = []
|
| 115 |
+
for feature in self._features:
|
| 116 |
+
for spec in feature.commands():
|
| 117 |
+
if spec.hidden or spec.name in seen:
|
| 118 |
+
continue
|
| 119 |
+
seen.add(spec.name)
|
| 120 |
+
# Telegram caps descriptions at 256 chars.
|
| 121 |
+
out.append({"command": spec.name, "description": spec.help[:256]})
|
| 122 |
+
return out
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _escape(text: str) -> str:
|
| 126 |
+
from .render import escape
|
| 127 |
+
|
| 128 |
+
return escape(text)
|
finbot/render.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Telegram message rendering.
|
| 2 |
+
|
| 3 |
+
Uses Telegram's **HTML** parse mode rather than MarkdownV2. MarkdownV2 requires
|
| 4 |
+
escaping ``. - ( ) ! +`` and more, which appear in virtually every line of
|
| 5 |
+
financial output (``₹1,234.56``, ``-12%``, ``(30%)``); one missed escape and
|
| 6 |
+
Telegram rejects the whole message. HTML needs only ``< > &`` escaped, so the
|
| 7 |
+
failure surface is far smaller.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import html
|
| 13 |
+
from datetime import date
|
| 14 |
+
from typing import Iterable
|
| 15 |
+
|
| 16 |
+
from .analytics import BudgetStatus, Recurring
|
| 17 |
+
from .categories import label as category_label
|
| 18 |
+
from .models import Expense, PendingImport, Summary
|
| 19 |
+
from .money import format_money
|
| 20 |
+
|
| 21 |
+
BAR_WIDTH = 10
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def escape(text: str) -> str:
|
| 25 |
+
return html.escape(str(text or ""), quote=False)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def bar(fraction: float, width: int = BAR_WIDTH) -> str:
|
| 29 |
+
filled = max(0, min(width, round(fraction * width)))
|
| 30 |
+
return "█" * filled + "░" * (width - filled)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def money(minor: int, currency: str) -> str:
|
| 34 |
+
return escape(format_money(minor, currency))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def expense_line(expense: Expense) -> str:
|
| 38 |
+
label = expense.merchant or expense.description or "unlabelled"
|
| 39 |
+
return (
|
| 40 |
+
f"{money(expense.amount_minor, expense.currency)} · {escape(label)} "
|
| 41 |
+
f"<i>{escape(category_label(expense.category))}</i> · "
|
| 42 |
+
f"{expense.occurred_on.isoformat()}"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def logged(expense: Expense, note: str = "") -> str:
|
| 47 |
+
parts = [
|
| 48 |
+
f"✅ Logged {money(expense.amount_minor, expense.currency)} — "
|
| 49 |
+
f"{escape(expense.description or expense.merchant or 'unlabelled')}",
|
| 50 |
+
f"<i>{escape(category_label(expense.category))}</i> · {expense.occurred_on.isoformat()}",
|
| 51 |
+
]
|
| 52 |
+
if note:
|
| 53 |
+
parts.append(escape(note))
|
| 54 |
+
parts.append("<i>/undo to remove</i>")
|
| 55 |
+
return "\n".join(parts)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def summary(summ: Summary) -> str:
|
| 59 |
+
if summ.count == 0:
|
| 60 |
+
return (
|
| 61 |
+
f"<b>{escape(summ.label)}</b>\nNothing logged "
|
| 62 |
+
f"({summ.start.isoformat()} → {summ.end.isoformat()})."
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
lines = [
|
| 66 |
+
f"<b>{escape(summ.label)}</b> · {money(summ.total_minor, summ.currency)} "
|
| 67 |
+
f"<i>({summ.count} txns)</i>"
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
if (delta := summ.delta_pct) is not None:
|
| 71 |
+
arrow = "▲" if delta > 0 else "▼"
|
| 72 |
+
lines.append(
|
| 73 |
+
f"{arrow} {abs(delta):.0f}% vs previous "
|
| 74 |
+
f"({money(summ.previous_total_minor or 0, summ.currency)})"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
lines.append("")
|
| 78 |
+
top = summ.by_category[:8]
|
| 79 |
+
for name, amount in top:
|
| 80 |
+
fraction = amount / summ.total_minor if summ.total_minor else 0
|
| 81 |
+
lines.append(
|
| 82 |
+
f"<code>{bar(fraction)}</code> {escape(category_label(name))} "
|
| 83 |
+
f"{money(amount, summ.currency)} <i>({fraction * 100:.0f}%)</i>"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
if len(summ.by_category) > len(top):
|
| 87 |
+
rest = sum(amount for _, amount in summ.by_category[len(top):])
|
| 88 |
+
lines.append(f"<i>+{len(summ.by_category) - len(top)} more · {money(rest, summ.currency)}</i>")
|
| 89 |
+
|
| 90 |
+
return "\n".join(lines)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def expense_list(expenses: Iterable[Expense], title: str, limit: int = 20) -> str:
|
| 94 |
+
items = list(expenses)[:limit]
|
| 95 |
+
if not items:
|
| 96 |
+
return f"<b>{escape(title)}</b>\nNothing to show."
|
| 97 |
+
lines = [f"<b>{escape(title)}</b>"]
|
| 98 |
+
lines.extend(f"• {expense_line(e)}" for e in items)
|
| 99 |
+
return "\n".join(lines)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def pending_import(pending: PendingImport) -> str:
|
| 103 |
+
totals = pending.total_by_currency()
|
| 104 |
+
total_str = " + ".join(money(v, k) for k, v in sorted(totals.items()))
|
| 105 |
+
|
| 106 |
+
lines = [
|
| 107 |
+
f"<b>Found {len(pending.drafts)} transactions</b> in that "
|
| 108 |
+
f"{escape(pending.source.upper())} · {total_str}",
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
detail = []
|
| 112 |
+
if pending.duplicates:
|
| 113 |
+
detail.append(f"{pending.duplicates} already logged (skipped)")
|
| 114 |
+
if pending.skipped:
|
| 115 |
+
detail.append(f"{pending.skipped} unreadable")
|
| 116 |
+
if pending.label:
|
| 117 |
+
detail.append(pending.label)
|
| 118 |
+
if detail:
|
| 119 |
+
lines.append(f"<i>{escape(' · '.join(detail))}</i>")
|
| 120 |
+
|
| 121 |
+
lines.append("")
|
| 122 |
+
preview = pending.drafts[:8]
|
| 123 |
+
for draft in preview:
|
| 124 |
+
lines.append(
|
| 125 |
+
f"• {money(draft.amount_minor, draft.currency)} · "
|
| 126 |
+
f"{escape(draft.description[:40])} · "
|
| 127 |
+
f"<i>{escape(category_label(draft.category))}</i> · "
|
| 128 |
+
f"{draft.occurred_on.isoformat()}"
|
| 129 |
+
)
|
| 130 |
+
if len(pending.drafts) > len(preview):
|
| 131 |
+
lines.append(f"<i>…and {len(pending.drafts) - len(preview)} more</i>")
|
| 132 |
+
|
| 133 |
+
lines.append("")
|
| 134 |
+
lines.append("These were read by a model and <b>not saved yet</b>.")
|
| 135 |
+
lines.append("/confirm to save · /cancel to discard")
|
| 136 |
+
return "\n".join(lines)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def recurring_list(items: list[Recurring], currency: str) -> str:
|
| 140 |
+
if not items:
|
| 141 |
+
return (
|
| 142 |
+
"<b>Recurring charges</b>\nNone detected yet — this needs a few "
|
| 143 |
+
"months of history to spot a pattern."
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
lines = ["<b>Recurring charges</b>"]
|
| 147 |
+
for item in items[:15]:
|
| 148 |
+
lines.append(
|
| 149 |
+
f"• {money(item.typical_minor, item.currency)}/mo · {escape(item.label)} "
|
| 150 |
+
f"<i>({item.months} months, last {item.last_seen.isoformat()})</i>"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
annual = sum(r.annualised_minor for r in items if r.currency == currency)
|
| 154 |
+
if annual:
|
| 155 |
+
lines.append("")
|
| 156 |
+
lines.append(f"<b>≈ {money(annual, currency)} a year</b> if all continue.")
|
| 157 |
+
return "\n".join(lines)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def budget_list(statuses: list[BudgetStatus]) -> str:
|
| 161 |
+
if not statuses:
|
| 162 |
+
return (
|
| 163 |
+
"<b>Budgets</b>\nNone set.\n"
|
| 164 |
+
"<code>/budget food_dining 8000</code> to set one."
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
lines = ["<b>Budgets this month</b>"]
|
| 168 |
+
for status in statuses:
|
| 169 |
+
mark = "⚠️" if status.over else ("🟡" if status.pct >= 80 else "🟢")
|
| 170 |
+
lines.append(
|
| 171 |
+
f"{mark} <code>{bar(status.pct / 100)}</code> "
|
| 172 |
+
f"{escape(category_label(status.category))} "
|
| 173 |
+
f"{money(status.spent_minor, status.currency)}/"
|
| 174 |
+
f"{money(status.limit_minor, status.currency)} "
|
| 175 |
+
f"<i>({status.pct:.0f}%)</i>"
|
| 176 |
+
)
|
| 177 |
+
return "\n".join(lines)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def csv_export(expenses: list[Expense]) -> str:
|
| 181 |
+
import csv
|
| 182 |
+
import io
|
| 183 |
+
|
| 184 |
+
buffer = io.StringIO()
|
| 185 |
+
writer = csv.writer(buffer)
|
| 186 |
+
writer.writerow(
|
| 187 |
+
["date", "amount", "currency", "category", "merchant", "description", "source"]
|
| 188 |
+
)
|
| 189 |
+
for expense in expenses:
|
| 190 |
+
from .money import from_minor
|
| 191 |
+
|
| 192 |
+
writer.writerow(
|
| 193 |
+
[
|
| 194 |
+
expense.occurred_on.isoformat(),
|
| 195 |
+
f"{from_minor(expense.amount_minor, expense.currency):.2f}",
|
| 196 |
+
expense.currency,
|
| 197 |
+
expense.category,
|
| 198 |
+
expense.merchant,
|
| 199 |
+
expense.description,
|
| 200 |
+
expense.source,
|
| 201 |
+
]
|
| 202 |
+
)
|
| 203 |
+
return buffer.getvalue()
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def period_footer(start: date, end: date) -> str:
|
| 207 |
+
return f"<i>{start.isoformat()} → {end.isoformat()}</i>"
|
finbot/store.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Durable storage: SQLite locally, mirrored to a private Hub dataset.
|
| 2 |
+
|
| 3 |
+
Hugging Face Space disk is ephemeral -- it is wiped on every restart, sleep and
|
| 4 |
+
rebuild. A finance bot that stored its ledger only on that disk would silently
|
| 5 |
+
lose everything the first time the Space went to sleep, which is the worst
|
| 6 |
+
possible failure for this application because it is invisible until you go
|
| 7 |
+
looking for old data.
|
| 8 |
+
|
| 9 |
+
So: SQLite is the working copy, and a ``CommitScheduler`` mirrors the database
|
| 10 |
+
file into a **private** dataset repo on a timer. On boot the newest copy is
|
| 11 |
+
pulled back down. Set ``HF_DATASET_REPO`` and this is handled; leave it unset
|
| 12 |
+
and the bot runs local-only, which is fine for development and lossy on a
|
| 13 |
+
Space -- ``/status`` says so out loud.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
import shutil
|
| 20 |
+
import sqlite3
|
| 21 |
+
import threading
|
| 22 |
+
from datetime import date, datetime, timezone
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Iterable, Optional, Sequence
|
| 25 |
+
|
| 26 |
+
from .config import Settings
|
| 27 |
+
from .models import Budget, Expense
|
| 28 |
+
|
| 29 |
+
log = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
DB_FILENAME = "finbot.db"
|
| 32 |
+
SCHEMA_VERSION = 1
|
| 33 |
+
|
| 34 |
+
_SCHEMA = """
|
| 35 |
+
CREATE TABLE IF NOT EXISTS expenses (
|
| 36 |
+
id TEXT PRIMARY KEY,
|
| 37 |
+
user_id INTEGER NOT NULL,
|
| 38 |
+
amount_minor INTEGER NOT NULL,
|
| 39 |
+
currency TEXT NOT NULL,
|
| 40 |
+
description TEXT NOT NULL DEFAULT '',
|
| 41 |
+
category TEXT NOT NULL DEFAULT 'other',
|
| 42 |
+
merchant TEXT NOT NULL DEFAULT '',
|
| 43 |
+
occurred_on TEXT NOT NULL,
|
| 44 |
+
source TEXT NOT NULL DEFAULT 'text',
|
| 45 |
+
batch_id TEXT NOT NULL DEFAULT '',
|
| 46 |
+
note TEXT NOT NULL DEFAULT '',
|
| 47 |
+
confidence REAL NOT NULL DEFAULT 1.0,
|
| 48 |
+
dedupe_key TEXT NOT NULL DEFAULT '',
|
| 49 |
+
created_at TEXT NOT NULL
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
CREATE INDEX IF NOT EXISTS idx_expenses_user_date
|
| 53 |
+
ON expenses(user_id, occurred_on);
|
| 54 |
+
CREATE INDEX IF NOT EXISTS idx_expenses_batch
|
| 55 |
+
ON expenses(batch_id);
|
| 56 |
+
-- Non-unique on purpose: two identical coffees on one day are both real. Import
|
| 57 |
+
-- dedupe is done explicitly against this index, so re-importing a statement
|
| 58 |
+
-- collapses while manual entry stays free to repeat.
|
| 59 |
+
CREATE INDEX IF NOT EXISTS idx_expenses_dedupe
|
| 60 |
+
ON expenses(user_id, dedupe_key);
|
| 61 |
+
|
| 62 |
+
CREATE TABLE IF NOT EXISTS budgets (
|
| 63 |
+
user_id INTEGER NOT NULL,
|
| 64 |
+
category TEXT NOT NULL,
|
| 65 |
+
amount_minor INTEGER NOT NULL,
|
| 66 |
+
currency TEXT NOT NULL,
|
| 67 |
+
period TEXT NOT NULL DEFAULT 'monthly',
|
| 68 |
+
created_at TEXT NOT NULL,
|
| 69 |
+
PRIMARY KEY (user_id, category, period)
|
| 70 |
+
);
|
| 71 |
+
|
| 72 |
+
CREATE TABLE IF NOT EXISTS meta (
|
| 73 |
+
key TEXT PRIMARY KEY,
|
| 74 |
+
value TEXT NOT NULL
|
| 75 |
+
);
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _row_to_expense(row: sqlite3.Row) -> Expense:
|
| 80 |
+
return Expense(
|
| 81 |
+
id=row["id"],
|
| 82 |
+
user_id=row["user_id"],
|
| 83 |
+
amount_minor=row["amount_minor"],
|
| 84 |
+
currency=row["currency"],
|
| 85 |
+
description=row["description"],
|
| 86 |
+
category=row["category"],
|
| 87 |
+
merchant=row["merchant"],
|
| 88 |
+
occurred_on=date.fromisoformat(row["occurred_on"]),
|
| 89 |
+
source=row["source"],
|
| 90 |
+
batch_id=row["batch_id"],
|
| 91 |
+
note=row["note"],
|
| 92 |
+
confidence=row["confidence"],
|
| 93 |
+
created_at=datetime.fromisoformat(row["created_at"]),
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class Store:
|
| 98 |
+
"""SQLite-backed ledger with optional Hub mirroring."""
|
| 99 |
+
|
| 100 |
+
def __init__(self, settings: Settings):
|
| 101 |
+
self.settings = settings
|
| 102 |
+
self.data_dir = Path(settings.data_dir)
|
| 103 |
+
self.db_path = self.data_dir / DB_FILENAME
|
| 104 |
+
self._lock = threading.RLock()
|
| 105 |
+
self._scheduler = None
|
| 106 |
+
self.sync_status = "disabled"
|
| 107 |
+
|
| 108 |
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
if settings.sync_enabled:
|
| 110 |
+
self._restore_from_hub()
|
| 111 |
+
|
| 112 |
+
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
| 113 |
+
self._conn.row_factory = sqlite3.Row
|
| 114 |
+
self._conn.execute("PRAGMA foreign_keys = ON")
|
| 115 |
+
self._migrate()
|
| 116 |
+
|
| 117 |
+
if settings.sync_enabled:
|
| 118 |
+
self._start_sync()
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------- schema
|
| 121 |
+
|
| 122 |
+
def _migrate(self) -> None:
|
| 123 |
+
with self._lock:
|
| 124 |
+
self._conn.executescript(_SCHEMA)
|
| 125 |
+
self._conn.execute(
|
| 126 |
+
"INSERT INTO meta(key, value) VALUES('schema_version', ?) "
|
| 127 |
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
| 128 |
+
(str(SCHEMA_VERSION),),
|
| 129 |
+
)
|
| 130 |
+
self._conn.commit()
|
| 131 |
+
|
| 132 |
+
# ------------------------------------------------------------------ sync
|
| 133 |
+
|
| 134 |
+
def _restore_from_hub(self) -> None:
|
| 135 |
+
"""Pull the last mirrored database down before opening it."""
|
| 136 |
+
from huggingface_hub import hf_hub_download
|
| 137 |
+
from huggingface_hub.utils import (
|
| 138 |
+
EntryNotFoundError,
|
| 139 |
+
RepositoryNotFoundError,
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
cached = hf_hub_download(
|
| 144 |
+
repo_id=self.settings.hf_dataset_repo,
|
| 145 |
+
filename=DB_FILENAME,
|
| 146 |
+
repo_type="dataset",
|
| 147 |
+
token=self.settings.hf_token,
|
| 148 |
+
)
|
| 149 |
+
except (EntryNotFoundError, RepositoryNotFoundError):
|
| 150 |
+
log.info("No existing ledger in the dataset repo; starting fresh.")
|
| 151 |
+
return
|
| 152 |
+
except Exception as exc: # network, auth, anything transient
|
| 153 |
+
# Do NOT clobber a local db we may already have.
|
| 154 |
+
log.warning("Could not restore ledger from Hub: %s", exc)
|
| 155 |
+
self.sync_status = f"restore failed: {exc}"
|
| 156 |
+
return
|
| 157 |
+
|
| 158 |
+
shutil.copyfile(cached, self.db_path)
|
| 159 |
+
log.info("Restored ledger from %s", self.settings.hf_dataset_repo)
|
| 160 |
+
|
| 161 |
+
def _start_sync(self) -> None:
|
| 162 |
+
from huggingface_hub import CommitScheduler, create_repo
|
| 163 |
+
|
| 164 |
+
lock = self._lock
|
| 165 |
+
|
| 166 |
+
class _LockedScheduler(CommitScheduler):
|
| 167 |
+
"""Snapshot the DB only while no write is in flight.
|
| 168 |
+
|
| 169 |
+
Without this the uploader can read the file mid-transaction and
|
| 170 |
+
mirror a torn database.
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
def push_to_hub(self, *args, **kwargs):
|
| 174 |
+
with lock:
|
| 175 |
+
return super().push_to_hub(*args, **kwargs)
|
| 176 |
+
|
| 177 |
+
try:
|
| 178 |
+
create_repo(
|
| 179 |
+
repo_id=self.settings.hf_dataset_repo,
|
| 180 |
+
repo_type="dataset",
|
| 181 |
+
private=True,
|
| 182 |
+
exist_ok=True,
|
| 183 |
+
token=self.settings.hf_token,
|
| 184 |
+
)
|
| 185 |
+
self._scheduler = _LockedScheduler(
|
| 186 |
+
repo_id=self.settings.hf_dataset_repo,
|
| 187 |
+
repo_type="dataset",
|
| 188 |
+
folder_path=self.data_dir,
|
| 189 |
+
path_in_repo="",
|
| 190 |
+
every=max(1, self.settings.sync_every_minutes),
|
| 191 |
+
allow_patterns=[DB_FILENAME],
|
| 192 |
+
private=True,
|
| 193 |
+
token=self.settings.hf_token,
|
| 194 |
+
squash_history=True,
|
| 195 |
+
)
|
| 196 |
+
self.sync_status = (
|
| 197 |
+
f"mirroring to {self.settings.hf_dataset_repo} "
|
| 198 |
+
f"every {self.settings.sync_every_minutes}m"
|
| 199 |
+
)
|
| 200 |
+
log.info("Ledger sync active: %s", self.sync_status)
|
| 201 |
+
except Exception as exc:
|
| 202 |
+
self.sync_status = f"failed: {exc}"
|
| 203 |
+
log.error("Could not start ledger sync: %s", exc)
|
| 204 |
+
|
| 205 |
+
def flush(self) -> None:
|
| 206 |
+
"""Force an immediate mirror. Called on shutdown and after imports."""
|
| 207 |
+
if self._scheduler is None:
|
| 208 |
+
return
|
| 209 |
+
try:
|
| 210 |
+
self._scheduler.push_to_hub()
|
| 211 |
+
except Exception as exc:
|
| 212 |
+
log.error("Ledger flush failed: %s", exc)
|
| 213 |
+
|
| 214 |
+
def close(self) -> None:
|
| 215 |
+
if self._scheduler is not None:
|
| 216 |
+
try:
|
| 217 |
+
self._scheduler.stop()
|
| 218 |
+
except Exception:
|
| 219 |
+
pass
|
| 220 |
+
self.flush()
|
| 221 |
+
with self._lock:
|
| 222 |
+
self._conn.close()
|
| 223 |
+
|
| 224 |
+
# -------------------------------------------------------------- expenses
|
| 225 |
+
|
| 226 |
+
def add_expense(self, expense: Expense) -> Expense:
|
| 227 |
+
with self._lock:
|
| 228 |
+
self._conn.execute(
|
| 229 |
+
"""INSERT INTO expenses
|
| 230 |
+
(id, user_id, amount_minor, currency, description, category,
|
| 231 |
+
merchant, occurred_on, source, batch_id, note, confidence,
|
| 232 |
+
dedupe_key, created_at)
|
| 233 |
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
| 234 |
+
(
|
| 235 |
+
expense.id,
|
| 236 |
+
expense.user_id,
|
| 237 |
+
expense.amount_minor,
|
| 238 |
+
expense.currency.upper(),
|
| 239 |
+
expense.description,
|
| 240 |
+
expense.category,
|
| 241 |
+
expense.merchant,
|
| 242 |
+
expense.occurred_on.isoformat(),
|
| 243 |
+
expense.source,
|
| 244 |
+
expense.batch_id,
|
| 245 |
+
expense.note,
|
| 246 |
+
expense.confidence,
|
| 247 |
+
expense.dedupe_key,
|
| 248 |
+
expense.created_at.isoformat(),
|
| 249 |
+
),
|
| 250 |
+
)
|
| 251 |
+
self._conn.commit()
|
| 252 |
+
return expense
|
| 253 |
+
|
| 254 |
+
def add_expenses(self, expenses: Sequence[Expense]) -> int:
|
| 255 |
+
for expense in expenses:
|
| 256 |
+
self.add_expense(expense)
|
| 257 |
+
return len(expenses)
|
| 258 |
+
|
| 259 |
+
def existing_dedupe_keys(self, user_id: int, keys: Iterable[str]) -> set[str]:
|
| 260 |
+
keys = [k for k in keys if k]
|
| 261 |
+
if not keys:
|
| 262 |
+
return set()
|
| 263 |
+
found: set[str] = set()
|
| 264 |
+
with self._lock:
|
| 265 |
+
# Chunked to stay under SQLite's variable limit on big imports.
|
| 266 |
+
for i in range(0, len(keys), 500):
|
| 267 |
+
chunk = keys[i : i + 500]
|
| 268 |
+
placeholders = ",".join("?" * len(chunk))
|
| 269 |
+
rows = self._conn.execute(
|
| 270 |
+
f"SELECT dedupe_key FROM expenses "
|
| 271 |
+
f"WHERE user_id = ? AND dedupe_key IN ({placeholders})",
|
| 272 |
+
(user_id, *chunk),
|
| 273 |
+
).fetchall()
|
| 274 |
+
found.update(r["dedupe_key"] for r in rows)
|
| 275 |
+
return found
|
| 276 |
+
|
| 277 |
+
def list_expenses(
|
| 278 |
+
self,
|
| 279 |
+
user_id: int,
|
| 280 |
+
start: Optional[date] = None,
|
| 281 |
+
end: Optional[date] = None,
|
| 282 |
+
category: Optional[str] = None,
|
| 283 |
+
limit: Optional[int] = None,
|
| 284 |
+
) -> list[Expense]:
|
| 285 |
+
clauses = ["user_id = ?"]
|
| 286 |
+
params: list[object] = [user_id]
|
| 287 |
+
if start:
|
| 288 |
+
clauses.append("occurred_on >= ?")
|
| 289 |
+
params.append(start.isoformat())
|
| 290 |
+
if end:
|
| 291 |
+
clauses.append("occurred_on <= ?")
|
| 292 |
+
params.append(end.isoformat())
|
| 293 |
+
if category:
|
| 294 |
+
clauses.append("category = ?")
|
| 295 |
+
params.append(category)
|
| 296 |
+
|
| 297 |
+
sql = (
|
| 298 |
+
f"SELECT * FROM expenses WHERE {' AND '.join(clauses)} "
|
| 299 |
+
f"ORDER BY occurred_on DESC, created_at DESC"
|
| 300 |
+
)
|
| 301 |
+
if limit:
|
| 302 |
+
sql += f" LIMIT {int(limit)}"
|
| 303 |
+
|
| 304 |
+
with self._lock:
|
| 305 |
+
rows = self._conn.execute(sql, params).fetchall()
|
| 306 |
+
return [_row_to_expense(r) for r in rows]
|
| 307 |
+
|
| 308 |
+
def last_expense(self, user_id: int) -> Optional[Expense]:
|
| 309 |
+
with self._lock:
|
| 310 |
+
row = self._conn.execute(
|
| 311 |
+
"SELECT * FROM expenses WHERE user_id = ? "
|
| 312 |
+
"ORDER BY created_at DESC LIMIT 1",
|
| 313 |
+
(user_id,),
|
| 314 |
+
).fetchone()
|
| 315 |
+
return _row_to_expense(row) if row else None
|
| 316 |
+
|
| 317 |
+
def delete_expense(self, user_id: int, expense_id: str) -> bool:
|
| 318 |
+
with self._lock:
|
| 319 |
+
cur = self._conn.execute(
|
| 320 |
+
"DELETE FROM expenses WHERE user_id = ? AND id = ?",
|
| 321 |
+
(user_id, expense_id),
|
| 322 |
+
)
|
| 323 |
+
self._conn.commit()
|
| 324 |
+
return cur.rowcount > 0
|
| 325 |
+
|
| 326 |
+
def delete_batch(self, user_id: int, batch_id: str) -> int:
|
| 327 |
+
if not batch_id:
|
| 328 |
+
return 0
|
| 329 |
+
with self._lock:
|
| 330 |
+
cur = self._conn.execute(
|
| 331 |
+
"DELETE FROM expenses WHERE user_id = ? AND batch_id = ?",
|
| 332 |
+
(user_id, batch_id),
|
| 333 |
+
)
|
| 334 |
+
self._conn.commit()
|
| 335 |
+
return cur.rowcount
|
| 336 |
+
|
| 337 |
+
def currencies_used(self, user_id: int) -> list[str]:
|
| 338 |
+
with self._lock:
|
| 339 |
+
rows = self._conn.execute(
|
| 340 |
+
"SELECT currency, COUNT(*) AS n FROM expenses WHERE user_id = ? "
|
| 341 |
+
"GROUP BY currency ORDER BY n DESC",
|
| 342 |
+
(user_id,),
|
| 343 |
+
).fetchall()
|
| 344 |
+
return [r["currency"] for r in rows]
|
| 345 |
+
|
| 346 |
+
def count(self, user_id: Optional[int] = None) -> int:
|
| 347 |
+
with self._lock:
|
| 348 |
+
if user_id is None:
|
| 349 |
+
row = self._conn.execute("SELECT COUNT(*) AS n FROM expenses").fetchone()
|
| 350 |
+
else:
|
| 351 |
+
row = self._conn.execute(
|
| 352 |
+
"SELECT COUNT(*) AS n FROM expenses WHERE user_id = ?", (user_id,)
|
| 353 |
+
).fetchone()
|
| 354 |
+
return int(row["n"])
|
| 355 |
+
|
| 356 |
+
# --------------------------------------------------------------- budgets
|
| 357 |
+
|
| 358 |
+
def set_budget(self, budget: Budget) -> Budget:
|
| 359 |
+
with self._lock:
|
| 360 |
+
self._conn.execute(
|
| 361 |
+
"""INSERT INTO budgets
|
| 362 |
+
(user_id, category, amount_minor, currency, period, created_at)
|
| 363 |
+
VALUES (?,?,?,?,?,?)
|
| 364 |
+
ON CONFLICT(user_id, category, period) DO UPDATE SET
|
| 365 |
+
amount_minor = excluded.amount_minor,
|
| 366 |
+
currency = excluded.currency""",
|
| 367 |
+
(
|
| 368 |
+
budget.user_id,
|
| 369 |
+
budget.category,
|
| 370 |
+
budget.amount_minor,
|
| 371 |
+
budget.currency.upper(),
|
| 372 |
+
budget.period,
|
| 373 |
+
budget.created_at.isoformat(),
|
| 374 |
+
),
|
| 375 |
+
)
|
| 376 |
+
self._conn.commit()
|
| 377 |
+
return budget
|
| 378 |
+
|
| 379 |
+
def get_budgets(self, user_id: int) -> list[Budget]:
|
| 380 |
+
with self._lock:
|
| 381 |
+
rows = self._conn.execute(
|
| 382 |
+
"SELECT * FROM budgets WHERE user_id = ? ORDER BY category",
|
| 383 |
+
(user_id,),
|
| 384 |
+
).fetchall()
|
| 385 |
+
return [
|
| 386 |
+
Budget(
|
| 387 |
+
user_id=r["user_id"],
|
| 388 |
+
category=r["category"],
|
| 389 |
+
amount_minor=r["amount_minor"],
|
| 390 |
+
currency=r["currency"],
|
| 391 |
+
period=r["period"],
|
| 392 |
+
created_at=datetime.fromisoformat(r["created_at"]),
|
| 393 |
+
)
|
| 394 |
+
for r in rows
|
| 395 |
+
]
|
| 396 |
+
|
| 397 |
+
def delete_budget(self, user_id: int, category: str, period: str = "monthly") -> bool:
|
| 398 |
+
with self._lock:
|
| 399 |
+
cur = self._conn.execute(
|
| 400 |
+
"DELETE FROM budgets WHERE user_id = ? AND category = ? AND period = ?",
|
| 401 |
+
(user_id, category, period),
|
| 402 |
+
)
|
| 403 |
+
self._conn.commit()
|
| 404 |
+
return cur.rowcount > 0
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def open_store(settings: Settings) -> Store:
|
| 408 |
+
return Store(settings)
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def utc_now() -> datetime:
|
| 412 |
+
return datetime.now(timezone.utc)
|
finbot/telegram.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Telegram Bot API client and update parsing.
|
| 2 |
+
|
| 3 |
+
Webhook rather than long polling, because free Spaces sleep after inactivity
|
| 4 |
+
and a sleeping container cannot poll. Telegram's POST wakes it instead.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from collections import OrderedDict
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from typing import Any, Optional
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
|
| 16 |
+
log = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
API_ROOT = "https://api.telegram.org"
|
| 19 |
+
|
| 20 |
+
#: getFile / downloads are capped at 20 MB by the Bot API itself.
|
| 21 |
+
MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024
|
| 22 |
+
|
| 23 |
+
#: Telegram rejects messages over 4096 characters.
|
| 24 |
+
MAX_MESSAGE_CHARS = 4096
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class ParsedUpdate:
|
| 29 |
+
update_id: int
|
| 30 |
+
user_id: int
|
| 31 |
+
chat_id: int
|
| 32 |
+
text: str = ""
|
| 33 |
+
command: str = ""
|
| 34 |
+
args: str = ""
|
| 35 |
+
caption: str = ""
|
| 36 |
+
file_id: str = ""
|
| 37 |
+
file_name: str = ""
|
| 38 |
+
mime_type: str = ""
|
| 39 |
+
file_kind: str = ""
|
| 40 |
+
file_size: int = 0
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def has_file(self) -> bool:
|
| 44 |
+
return bool(self.file_id)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def parse_update(update: dict[str, Any], bot_username: str = "") -> Optional[ParsedUpdate]:
|
| 48 |
+
"""Normalise a Telegram update. Returns None for anything we don't handle."""
|
| 49 |
+
message = update.get("message") or update.get("edited_message")
|
| 50 |
+
if not isinstance(message, dict):
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
sender = message.get("from") or {}
|
| 54 |
+
chat = message.get("chat") or {}
|
| 55 |
+
if not sender.get("id") or not chat.get("id"):
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
parsed = ParsedUpdate(
|
| 59 |
+
update_id=int(update.get("update_id", 0)),
|
| 60 |
+
user_id=int(sender["id"]),
|
| 61 |
+
chat_id=int(chat["id"]),
|
| 62 |
+
caption=(message.get("caption") or "").strip(),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
text = (message.get("text") or "").strip()
|
| 66 |
+
parsed.text = text
|
| 67 |
+
|
| 68 |
+
if text.startswith("/"):
|
| 69 |
+
head, _, rest = text.partition(" ")
|
| 70 |
+
command = head[1:]
|
| 71 |
+
# Group chats deliver "/month@MyBot"; strip the mention.
|
| 72 |
+
if "@" in command:
|
| 73 |
+
command, _, mentioned = command.partition("@")
|
| 74 |
+
if bot_username and mentioned.lower() != bot_username.lower():
|
| 75 |
+
return None
|
| 76 |
+
parsed.command = command.lower()
|
| 77 |
+
parsed.args = rest.strip()
|
| 78 |
+
|
| 79 |
+
if photos := message.get("photo"):
|
| 80 |
+
# PhotoSize list runs smallest to largest; the largest reads best.
|
| 81 |
+
largest = max(photos, key=lambda p: p.get("file_size") or 0)
|
| 82 |
+
parsed.file_id = largest.get("file_id", "")
|
| 83 |
+
parsed.file_kind = "photo"
|
| 84 |
+
parsed.mime_type = "image/jpeg"
|
| 85 |
+
parsed.file_size = int(largest.get("file_size") or 0)
|
| 86 |
+
elif document := message.get("document"):
|
| 87 |
+
parsed.file_id = document.get("file_id", "")
|
| 88 |
+
parsed.file_kind = "document"
|
| 89 |
+
parsed.file_name = document.get("file_name", "")
|
| 90 |
+
parsed.mime_type = document.get("mime_type", "")
|
| 91 |
+
parsed.file_size = int(document.get("file_size") or 0)
|
| 92 |
+
|
| 93 |
+
return parsed
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def chunk_message(text: str, limit: int = MAX_MESSAGE_CHARS) -> list[str]:
|
| 97 |
+
"""Split a long reply on line boundaries so HTML tags stay balanced."""
|
| 98 |
+
if len(text) <= limit:
|
| 99 |
+
return [text]
|
| 100 |
+
|
| 101 |
+
chunks: list[str] = []
|
| 102 |
+
current: list[str] = []
|
| 103 |
+
length = 0
|
| 104 |
+
for line in text.split("\n"):
|
| 105 |
+
# +1 for the newline we will rejoin with.
|
| 106 |
+
if length + len(line) + 1 > limit and current:
|
| 107 |
+
chunks.append("\n".join(current))
|
| 108 |
+
current, length = [], 0
|
| 109 |
+
current.append(line)
|
| 110 |
+
length += len(line) + 1
|
| 111 |
+
if current:
|
| 112 |
+
chunks.append("\n".join(current))
|
| 113 |
+
return chunks
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class SeenUpdates:
|
| 117 |
+
"""Bounded set of processed update ids.
|
| 118 |
+
|
| 119 |
+
We answer Telegram's webhook immediately and process in the background, so
|
| 120 |
+
a slow reply can cause Telegram to redeliver. Without this, one statement
|
| 121 |
+
could be imported twice.
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
def __init__(self, capacity: int = 512):
|
| 125 |
+
self.capacity = capacity
|
| 126 |
+
self._seen: OrderedDict[int, None] = OrderedDict()
|
| 127 |
+
|
| 128 |
+
def add_if_new(self, update_id: int) -> bool:
|
| 129 |
+
if update_id in self._seen:
|
| 130 |
+
return False
|
| 131 |
+
self._seen[update_id] = None
|
| 132 |
+
while len(self._seen) > self.capacity:
|
| 133 |
+
self._seen.popitem(last=False)
|
| 134 |
+
return True
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class TelegramClient:
|
| 138 |
+
def __init__(self, token: str):
|
| 139 |
+
self.token = token
|
| 140 |
+
self._client: Optional[httpx.AsyncClient] = None
|
| 141 |
+
self.username = ""
|
| 142 |
+
|
| 143 |
+
@property
|
| 144 |
+
def enabled(self) -> bool:
|
| 145 |
+
return bool(self.token)
|
| 146 |
+
|
| 147 |
+
def _http(self) -> httpx.AsyncClient:
|
| 148 |
+
if self._client is None:
|
| 149 |
+
self._client = httpx.AsyncClient(
|
| 150 |
+
base_url=f"{API_ROOT}/bot{self.token}",
|
| 151 |
+
timeout=httpx.Timeout(60.0, connect=10.0),
|
| 152 |
+
)
|
| 153 |
+
return self._client
|
| 154 |
+
|
| 155 |
+
async def close(self) -> None:
|
| 156 |
+
if self._client is not None:
|
| 157 |
+
await self._client.aclose()
|
| 158 |
+
self._client = None
|
| 159 |
+
|
| 160 |
+
async def _call(self, method: str, **payload: Any) -> dict:
|
| 161 |
+
response = await self._http().post(f"/{method}", json=payload)
|
| 162 |
+
data = response.json()
|
| 163 |
+
if not data.get("ok"):
|
| 164 |
+
raise RuntimeError(f"{method} failed: {data.get('description', data)}")
|
| 165 |
+
return data.get("result") or {}
|
| 166 |
+
|
| 167 |
+
async def get_me(self) -> dict:
|
| 168 |
+
result = await self._call("getMe")
|
| 169 |
+
self.username = result.get("username", "")
|
| 170 |
+
return result
|
| 171 |
+
|
| 172 |
+
async def send_message(
|
| 173 |
+
self, chat_id: int, text: str, parse_mode: Optional[str] = "HTML"
|
| 174 |
+
) -> None:
|
| 175 |
+
for chunk in chunk_message(text):
|
| 176 |
+
payload: dict[str, Any] = {
|
| 177 |
+
"chat_id": chat_id,
|
| 178 |
+
"text": chunk,
|
| 179 |
+
"link_preview_options": {"is_disabled": True},
|
| 180 |
+
}
|
| 181 |
+
if parse_mode:
|
| 182 |
+
payload["parse_mode"] = parse_mode
|
| 183 |
+
try:
|
| 184 |
+
await self._call("sendMessage", **payload)
|
| 185 |
+
except RuntimeError as exc:
|
| 186 |
+
# Malformed HTML from a merchant name shouldn't swallow the
|
| 187 |
+
# reply -- retry once as plain text so the user still sees it.
|
| 188 |
+
if parse_mode:
|
| 189 |
+
log.warning("HTML send failed (%s); retrying as plain text", exc)
|
| 190 |
+
await self._call(
|
| 191 |
+
"sendMessage",
|
| 192 |
+
chat_id=chat_id,
|
| 193 |
+
text=_strip_tags(chunk),
|
| 194 |
+
)
|
| 195 |
+
else:
|
| 196 |
+
raise
|
| 197 |
+
|
| 198 |
+
async def send_document(
|
| 199 |
+
self, chat_id: int, filename: str, data: bytes, caption: str = ""
|
| 200 |
+
) -> None:
|
| 201 |
+
files = {"document": (filename, data, "text/csv")}
|
| 202 |
+
form: dict[str, Any] = {"chat_id": str(chat_id)}
|
| 203 |
+
if caption:
|
| 204 |
+
form["caption"] = caption[:1024]
|
| 205 |
+
form["parse_mode"] = "HTML"
|
| 206 |
+
response = await self._http().post("/sendDocument", data=form, files=files)
|
| 207 |
+
payload = response.json()
|
| 208 |
+
if not payload.get("ok"):
|
| 209 |
+
raise RuntimeError(f"sendDocument failed: {payload.get('description')}")
|
| 210 |
+
|
| 211 |
+
async def send_typing(self, chat_id: int) -> None:
|
| 212 |
+
try:
|
| 213 |
+
await self._call("sendChatAction", chat_id=chat_id, action="typing")
|
| 214 |
+
except RuntimeError:
|
| 215 |
+
pass # cosmetic only
|
| 216 |
+
|
| 217 |
+
async def download(self, file_id: str) -> tuple[bytes, str]:
|
| 218 |
+
"""Fetch a file by id. Returns (bytes, telegram_file_path)."""
|
| 219 |
+
info = await self._call("getFile", file_id=file_id)
|
| 220 |
+
file_path = info.get("file_path")
|
| 221 |
+
if not file_path:
|
| 222 |
+
raise RuntimeError("Telegram did not return a file path")
|
| 223 |
+
|
| 224 |
+
url = f"{API_ROOT}/file/bot{self.token}/{file_path}"
|
| 225 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
|
| 226 |
+
response = await client.get(url)
|
| 227 |
+
response.raise_for_status()
|
| 228 |
+
return response.content, file_path
|
| 229 |
+
|
| 230 |
+
async def set_webhook(self, url: str, secret: str) -> dict:
|
| 231 |
+
return await self._call(
|
| 232 |
+
"setWebhook",
|
| 233 |
+
url=url,
|
| 234 |
+
secret_token=secret,
|
| 235 |
+
allowed_updates=["message", "edited_message"],
|
| 236 |
+
drop_pending_updates=True,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
async def delete_webhook(self) -> dict:
|
| 240 |
+
return await self._call("deleteWebhook", drop_pending_updates=True)
|
| 241 |
+
|
| 242 |
+
async def get_webhook_info(self) -> dict:
|
| 243 |
+
return await self._call("getWebhookInfo")
|
| 244 |
+
|
| 245 |
+
async def set_my_commands(self, commands: list[dict[str, str]]) -> dict:
|
| 246 |
+
return await self._call("setMyCommands", commands=commands)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _strip_tags(text: str) -> str:
|
| 250 |
+
import re
|
| 251 |
+
|
| 252 |
+
return re.sub(r"<[^>]+>", "", text)
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115,<0.121
|
| 2 |
+
uvicorn[standard]>=0.32,<0.40
|
| 3 |
+
httpx>=0.27,<0.29
|
| 4 |
+
pydantic>=2.9,<3
|
| 5 |
+
pydantic-settings>=2.6,<3
|
| 6 |
+
huggingface_hub>=0.34,<1.0
|
| 7 |
+
pdfplumber>=0.11,<0.12
|
| 8 |
+
openpyxl>=3.1,<4
|
| 9 |
+
python-dateutil>=2.9,<3
|
tests/test_analytics.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from finbot.analytics import (
|
| 6 |
+
by_category,
|
| 7 |
+
budget_status,
|
| 8 |
+
detect_recurring,
|
| 9 |
+
dominant_currency,
|
| 10 |
+
month_bounds,
|
| 11 |
+
period_bounds,
|
| 12 |
+
previous_month_bounds,
|
| 13 |
+
project_month_end,
|
| 14 |
+
split_by_currency,
|
| 15 |
+
summarise,
|
| 16 |
+
top_merchants,
|
| 17 |
+
total_minor,
|
| 18 |
+
)
|
| 19 |
+
from finbot.models import Budget, Expense
|
| 20 |
+
|
| 21 |
+
TODAY = date(2026, 8, 15)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def make(amount, category="other", currency="INR", day=TODAY, merchant="", desc="x"):
|
| 25 |
+
return Expense(
|
| 26 |
+
user_id=1,
|
| 27 |
+
amount_minor=amount,
|
| 28 |
+
currency=currency,
|
| 29 |
+
description=desc,
|
| 30 |
+
category=category,
|
| 31 |
+
occurred_on=day,
|
| 32 |
+
merchant=merchant,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class TestPeriods:
|
| 37 |
+
@pytest.mark.parametrize(
|
| 38 |
+
"name,start,end",
|
| 39 |
+
[
|
| 40 |
+
("today", TODAY, TODAY),
|
| 41 |
+
("yesterday", date(2026, 8, 14), date(2026, 8, 14)),
|
| 42 |
+
("week", date(2026, 8, 10), TODAY), # Monday of that week
|
| 43 |
+
("month", date(2026, 8, 1), TODAY),
|
| 44 |
+
("last_month", date(2026, 7, 1), date(2026, 7, 31)),
|
| 45 |
+
("year", date(2026, 1, 1), TODAY),
|
| 46 |
+
("30d", date(2026, 7, 17), TODAY),
|
| 47 |
+
],
|
| 48 |
+
)
|
| 49 |
+
def test_bounds(self, name, start, end):
|
| 50 |
+
got_start, got_end, _label = period_bounds(name, TODAY)
|
| 51 |
+
assert (got_start, got_end) == (start, end)
|
| 52 |
+
|
| 53 |
+
def test_unknown_period_defaults_to_month(self):
|
| 54 |
+
start, end, _ = period_bounds("nonsense", TODAY)
|
| 55 |
+
assert (start, end) == (date(2026, 8, 1), TODAY)
|
| 56 |
+
|
| 57 |
+
def test_month_bounds_handles_month_length(self):
|
| 58 |
+
assert month_bounds(date(2026, 2, 10))[1] == date(2026, 2, 28)
|
| 59 |
+
|
| 60 |
+
def test_previous_month_across_a_year_boundary(self):
|
| 61 |
+
assert previous_month_bounds(date(2026, 1, 5)) == (
|
| 62 |
+
date(2025, 12, 1),
|
| 63 |
+
date(2025, 12, 31),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class TestAggregation:
|
| 68 |
+
def test_total_and_by_category(self):
|
| 69 |
+
expenses = [
|
| 70 |
+
make(10000, "food_dining"),
|
| 71 |
+
make(5000, "food_dining"),
|
| 72 |
+
make(20000, "rent"),
|
| 73 |
+
]
|
| 74 |
+
assert total_minor(expenses) == 35000
|
| 75 |
+
assert by_category(expenses) == [("rent", 20000), ("food_dining", 15000)]
|
| 76 |
+
|
| 77 |
+
def test_split_by_currency(self):
|
| 78 |
+
expenses = [make(100, currency="INR"), make(200, currency="USD")]
|
| 79 |
+
buckets = split_by_currency(expenses)
|
| 80 |
+
assert set(buckets) == {"INR", "USD"}
|
| 81 |
+
|
| 82 |
+
def test_dominant_currency_by_count(self):
|
| 83 |
+
expenses = [make(100, currency="INR"), make(100, currency="INR"), make(999999, currency="USD")]
|
| 84 |
+
assert dominant_currency(expenses, "EUR") == "INR"
|
| 85 |
+
|
| 86 |
+
def test_dominant_currency_falls_back_when_empty(self):
|
| 87 |
+
assert dominant_currency([], "SGD") == "SGD"
|
| 88 |
+
|
| 89 |
+
def test_summarise_only_counts_the_named_currency(self):
|
| 90 |
+
expenses = [make(10000, currency="INR"), make(50000, currency="USD")]
|
| 91 |
+
summary = summarise(expenses, "INR", "Aug", date(2026, 8, 1), TODAY)
|
| 92 |
+
assert summary.total_minor == 10000
|
| 93 |
+
assert summary.count == 1
|
| 94 |
+
|
| 95 |
+
def test_delta_pct(self):
|
| 96 |
+
current = [make(15000)]
|
| 97 |
+
previous = [make(10000)]
|
| 98 |
+
summary = summarise(current, "INR", "Aug", date(2026, 8, 1), TODAY, previous)
|
| 99 |
+
assert summary.delta_pct == pytest.approx(50.0)
|
| 100 |
+
|
| 101 |
+
def test_delta_pct_is_none_without_a_baseline(self):
|
| 102 |
+
summary = summarise([make(100)], "INR", "Aug", date(2026, 8, 1), TODAY, [])
|
| 103 |
+
assert summary.delta_pct is None
|
| 104 |
+
|
| 105 |
+
def test_top_merchants(self):
|
| 106 |
+
expenses = [
|
| 107 |
+
make(100, merchant="Swiggy"),
|
| 108 |
+
make(300, merchant="Swiggy"),
|
| 109 |
+
make(200, merchant="Uber"),
|
| 110 |
+
]
|
| 111 |
+
assert top_merchants(expenses)[0] == ("Swiggy", 400)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class TestProjection:
|
| 115 |
+
def test_projects_to_month_end(self):
|
| 116 |
+
# 15 days elapsed of a 31-day August, ₹150 spent -> ~₹310.
|
| 117 |
+
expenses = [make(15000, day=date(2026, 8, 5))]
|
| 118 |
+
projected = project_month_end(expenses, TODAY)
|
| 119 |
+
assert projected == pytest.approx(31000, rel=0.01)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class TestRecurring:
|
| 123 |
+
def _monthly(self, merchant, amount, months):
|
| 124 |
+
return [
|
| 125 |
+
make(amount, day=date(2026, m, 5), merchant=merchant, desc=merchant)
|
| 126 |
+
for m in months
|
| 127 |
+
]
|
| 128 |
+
|
| 129 |
+
def test_detects_a_monthly_subscription(self):
|
| 130 |
+
expenses = self._monthly("Netflix", 64900, [5, 6, 7])
|
| 131 |
+
found = detect_recurring(expenses)
|
| 132 |
+
assert len(found) == 1
|
| 133 |
+
assert found[0].months == 3
|
| 134 |
+
assert found[0].typical_minor == 64900
|
| 135 |
+
|
| 136 |
+
def test_ignores_charges_in_too_few_months(self):
|
| 137 |
+
expenses = self._monthly("Netflix", 64900, [6, 7])
|
| 138 |
+
assert detect_recurring(expenses) == []
|
| 139 |
+
|
| 140 |
+
def test_ignores_repeat_spending_with_erratic_amounts(self):
|
| 141 |
+
# Same shop every month, wildly different amounts -- not a subscription.
|
| 142 |
+
expenses = [
|
| 143 |
+
make(10000, day=date(2026, 5, 5), merchant="Bigbasket", desc="Bigbasket"),
|
| 144 |
+
make(90000, day=date(2026, 6, 5), merchant="Bigbasket", desc="Bigbasket"),
|
| 145 |
+
make(250000, day=date(2026, 7, 5), merchant="Bigbasket", desc="Bigbasket"),
|
| 146 |
+
]
|
| 147 |
+
assert detect_recurring(expenses) == []
|
| 148 |
+
|
| 149 |
+
def test_three_charges_in_one_month_is_not_recurring(self):
|
| 150 |
+
expenses = [
|
| 151 |
+
make(64900, day=date(2026, 7, d), merchant="Netflix", desc="Netflix")
|
| 152 |
+
for d in (1, 10, 20)
|
| 153 |
+
]
|
| 154 |
+
assert detect_recurring(expenses) == []
|
| 155 |
+
|
| 156 |
+
def test_normalises_noisy_merchant_strings(self):
|
| 157 |
+
expenses = [
|
| 158 |
+
make(64900, day=date(2026, 5, 5), desc="NETFLIX.COM 447192"),
|
| 159 |
+
make(64900, day=date(2026, 6, 5), desc="NETFLIX.COM 883021"),
|
| 160 |
+
make(64900, day=date(2026, 7, 5), desc="Netflix"),
|
| 161 |
+
]
|
| 162 |
+
found = detect_recurring(expenses)
|
| 163 |
+
assert len(found) == 1
|
| 164 |
+
assert found[0].months == 3
|
| 165 |
+
|
| 166 |
+
def test_annualised(self):
|
| 167 |
+
found = detect_recurring(self._monthly("Netflix", 50000, [5, 6, 7]))
|
| 168 |
+
assert found[0].annualised_minor == 600000
|
| 169 |
+
|
| 170 |
+
def test_separates_currencies(self):
|
| 171 |
+
expenses = self._monthly("Netflix", 64900, [5, 6, 7]) + [
|
| 172 |
+
make(1000, day=date(2026, m, 5), merchant="Netflix", desc="Netflix", currency="USD")
|
| 173 |
+
for m in (5, 6, 7)
|
| 174 |
+
]
|
| 175 |
+
found = detect_recurring(expenses)
|
| 176 |
+
assert {f.currency for f in found} == {"INR", "USD"}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class TestBudgets:
|
| 180 |
+
def test_status_calculation(self):
|
| 181 |
+
budgets = [Budget(user_id=1, category="food_dining", amount_minor=100000, currency="INR")]
|
| 182 |
+
expenses = [make(60000, "food_dining")]
|
| 183 |
+
status = budget_status(budgets, expenses)[0]
|
| 184 |
+
assert status.spent_minor == 60000
|
| 185 |
+
assert status.pct == pytest.approx(60.0)
|
| 186 |
+
assert status.remaining_minor == 40000
|
| 187 |
+
assert not status.over
|
| 188 |
+
|
| 189 |
+
def test_over_budget(self):
|
| 190 |
+
budgets = [Budget(user_id=1, category="rent", amount_minor=10000, currency="INR")]
|
| 191 |
+
status = budget_status(budgets, [make(15000, "rent")])[0]
|
| 192 |
+
assert status.over
|
| 193 |
+
assert status.remaining_minor == -5000
|
| 194 |
+
|
| 195 |
+
def test_ignores_other_currencies(self):
|
| 196 |
+
budgets = [Budget(user_id=1, category="rent", amount_minor=10000, currency="INR")]
|
| 197 |
+
status = budget_status(budgets, [make(99999, "rent", currency="USD")])[0]
|
| 198 |
+
assert status.spent_minor == 0
|
| 199 |
+
|
| 200 |
+
def test_no_budgets_yields_no_statuses(self):
|
| 201 |
+
assert budget_status([], [make(100)]) == []
|
tests/test_app.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end webhook tests with a stubbed Telegram transport.
|
| 2 |
+
|
| 3 |
+
Environment is configured at import time, before the app's lifespan calls
|
| 4 |
+
``get_settings()`` (which is lru_cached, so the first call wins).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import tempfile
|
| 9 |
+
|
| 10 |
+
os.environ.update(
|
| 11 |
+
{
|
| 12 |
+
"DATA_DIR": tempfile.mkdtemp(prefix="finbot-test-"),
|
| 13 |
+
"TELEGRAM_BOT_TOKEN": "", # keeps lifespan from calling Telegram
|
| 14 |
+
"TELEGRAM_WEBHOOK_SECRET": "test-secret",
|
| 15 |
+
"ALLOWED_TELEGRAM_USER_IDS": "42",
|
| 16 |
+
"HF_TOKEN": "", # deterministic paths only; no network
|
| 17 |
+
"HF_DATASET_REPO": "",
|
| 18 |
+
"DEFAULT_CURRENCY": "INR",
|
| 19 |
+
"TIMEZONE": "Asia/Kolkata",
|
| 20 |
+
}
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
import pytest # noqa: E402
|
| 24 |
+
from fastapi.testclient import TestClient # noqa: E402
|
| 25 |
+
|
| 26 |
+
from app import app # noqa: E402
|
| 27 |
+
|
| 28 |
+
SECRET = {"X-Telegram-Bot-Api-Secret-Token": "test-secret"}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class FakeTelegram:
|
| 32 |
+
"""Records what would have been sent instead of calling the Bot API."""
|
| 33 |
+
|
| 34 |
+
def __init__(self):
|
| 35 |
+
self.username = "FinBotTest"
|
| 36 |
+
self.messages: list[tuple[int, str]] = []
|
| 37 |
+
self.documents: list[tuple[int, str, bytes]] = []
|
| 38 |
+
self.enabled = True
|
| 39 |
+
|
| 40 |
+
async def send_message(self, chat_id, text, parse_mode="HTML"):
|
| 41 |
+
self.messages.append((chat_id, text))
|
| 42 |
+
|
| 43 |
+
async def send_document(self, chat_id, filename, data, caption=""):
|
| 44 |
+
self.documents.append((chat_id, filename, data))
|
| 45 |
+
|
| 46 |
+
async def send_typing(self, chat_id):
|
| 47 |
+
pass
|
| 48 |
+
|
| 49 |
+
async def download(self, file_id):
|
| 50 |
+
return b"", "file.bin"
|
| 51 |
+
|
| 52 |
+
async def close(self):
|
| 53 |
+
pass
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def last(self) -> str:
|
| 57 |
+
return self.messages[-1][1] if self.messages else ""
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@pytest.fixture
|
| 61 |
+
def client():
|
| 62 |
+
with TestClient(app) as test_client:
|
| 63 |
+
fake = FakeTelegram()
|
| 64 |
+
app.state.telegram = fake
|
| 65 |
+
test_client.fake = fake
|
| 66 |
+
# Isolate each test: empty the ledger and any pending imports.
|
| 67 |
+
store = app.state.services.store
|
| 68 |
+
for expense in store.list_expenses(42):
|
| 69 |
+
store.delete_expense(42, expense.id)
|
| 70 |
+
app.state.services.pending.clear()
|
| 71 |
+
yield test_client
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def send(client, text, user_id=42, update_id=None):
|
| 75 |
+
import random
|
| 76 |
+
|
| 77 |
+
payload = {
|
| 78 |
+
"update_id": update_id if update_id is not None else random.randint(1, 10**9),
|
| 79 |
+
"message": {
|
| 80 |
+
"from": {"id": user_id},
|
| 81 |
+
"chat": {"id": user_id},
|
| 82 |
+
"text": text,
|
| 83 |
+
},
|
| 84 |
+
}
|
| 85 |
+
return client.post("/telegram/webhook", json=payload, headers=SECRET)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TestEndpoints:
|
| 89 |
+
def test_health(self, client):
|
| 90 |
+
response = client.get("/health")
|
| 91 |
+
assert response.status_code == 200
|
| 92 |
+
assert response.json()["status"] == "ok"
|
| 93 |
+
|
| 94 |
+
def test_health_leaks_no_ledger_detail(self, client):
|
| 95 |
+
# The Space may be public; liveness must not publish personal metrics.
|
| 96 |
+
body = client.get("/health").json()
|
| 97 |
+
assert set(body) == {"status", "version"}
|
| 98 |
+
|
| 99 |
+
def test_index_renders(self, client):
|
| 100 |
+
response = client.get("/")
|
| 101 |
+
assert response.status_code == 200
|
| 102 |
+
assert "Oracle" in response.text
|
| 103 |
+
|
| 104 |
+
def test_index_leaks_no_ledger_detail(self, client):
|
| 105 |
+
send(client, "coffee 250")
|
| 106 |
+
text = client.get("/").text.lower()
|
| 107 |
+
for leak in ("rows", "ledger", "mirroring", "not configured", "hf_dataset_repo"):
|
| 108 |
+
assert leak not in text
|
| 109 |
+
|
| 110 |
+
def test_diagnostics_requires_the_secret(self, client):
|
| 111 |
+
assert client.get("/admin/diagnostics").status_code == 403
|
| 112 |
+
assert client.get(
|
| 113 |
+
"/admin/diagnostics", headers={"X-Admin-Secret": "wrong"}
|
| 114 |
+
).status_code == 403
|
| 115 |
+
|
| 116 |
+
def test_diagnostics_with_the_secret(self, client):
|
| 117 |
+
response = client.get("/admin/diagnostics", headers={"X-Admin-Secret": "test-secret"})
|
| 118 |
+
assert response.status_code == 200
|
| 119 |
+
assert response.json()["ok"] is True
|
| 120 |
+
assert "rows" in response.json()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class TestWebhookSecurity:
|
| 124 |
+
def test_rejects_a_missing_secret(self, client):
|
| 125 |
+
response = client.post("/telegram/webhook", json={})
|
| 126 |
+
assert response.status_code == 403
|
| 127 |
+
|
| 128 |
+
def test_rejects_a_wrong_secret(self, client):
|
| 129 |
+
response = client.post(
|
| 130 |
+
"/telegram/webhook",
|
| 131 |
+
json={},
|
| 132 |
+
headers={"X-Telegram-Bot-Api-Secret-Token": "wrong"},
|
| 133 |
+
)
|
| 134 |
+
assert response.status_code == 403
|
| 135 |
+
|
| 136 |
+
def test_rejects_malformed_json(self, client):
|
| 137 |
+
response = client.post("/telegram/webhook", content=b"not json", headers=SECRET)
|
| 138 |
+
assert response.status_code == 400
|
| 139 |
+
|
| 140 |
+
def test_unauthorised_user_is_refused_and_told_their_id(self, client):
|
| 141 |
+
send(client, "coffee 250", user_id=9999)
|
| 142 |
+
assert "9999" in client.fake.last
|
| 143 |
+
assert "allowlist" in client.fake.last
|
| 144 |
+
|
| 145 |
+
def test_unauthorised_user_writes_nothing(self, client):
|
| 146 |
+
send(client, "coffee 250", user_id=9999)
|
| 147 |
+
assert app.state.services.store.count(9999) == 0
|
| 148 |
+
|
| 149 |
+
def test_duplicate_update_is_processed_once(self, client):
|
| 150 |
+
send(client, "coffee 250", update_id=555)
|
| 151 |
+
send(client, "coffee 250", update_id=555)
|
| 152 |
+
assert app.state.services.store.count(42) == 1
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class TestLoggingFlow:
|
| 156 |
+
def test_plain_text_is_logged(self, client):
|
| 157 |
+
response = send(client, "coffee 250")
|
| 158 |
+
assert response.status_code == 200
|
| 159 |
+
assert "Logged" in client.fake.last
|
| 160 |
+
assert "₹250.00" in client.fake.last
|
| 161 |
+
assert app.state.services.store.count(42) == 1
|
| 162 |
+
|
| 163 |
+
def test_text_without_an_amount_gets_guidance(self, client):
|
| 164 |
+
send(client, "hello there")
|
| 165 |
+
assert "couldn't find an amount" in client.fake.last
|
| 166 |
+
assert app.state.services.store.count(42) == 0
|
| 167 |
+
|
| 168 |
+
def test_spend_command(self, client):
|
| 169 |
+
send(client, "/spend 450 swiggy dinner")
|
| 170 |
+
assert "Logged" in client.fake.last
|
| 171 |
+
assert app.state.services.store.count(42) == 1
|
| 172 |
+
|
| 173 |
+
def test_spend_without_args_explains(self, client):
|
| 174 |
+
send(client, "/spend")
|
| 175 |
+
assert "/spend 250 coffee" in client.fake.last
|
| 176 |
+
|
| 177 |
+
def test_undo_removes_the_last_entry(self, client):
|
| 178 |
+
send(client, "coffee 250")
|
| 179 |
+
send(client, "/undo")
|
| 180 |
+
assert "Removed" in client.fake.last
|
| 181 |
+
assert app.state.services.store.count(42) == 0
|
| 182 |
+
|
| 183 |
+
def test_undo_with_nothing_to_undo(self, client):
|
| 184 |
+
send(client, "/undo")
|
| 185 |
+
assert "Nothing to undo" in client.fake.last
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class TestSummaries:
|
| 189 |
+
def test_today(self, client):
|
| 190 |
+
send(client, "coffee 250")
|
| 191 |
+
send(client, "300 uber")
|
| 192 |
+
send(client, "/today")
|
| 193 |
+
assert "₹550.00" in client.fake.last
|
| 194 |
+
|
| 195 |
+
def test_month(self, client):
|
| 196 |
+
send(client, "1000 rent")
|
| 197 |
+
send(client, "/month")
|
| 198 |
+
assert "₹1,000.00" in client.fake.last
|
| 199 |
+
|
| 200 |
+
def test_empty_period(self, client):
|
| 201 |
+
send(client, "/today")
|
| 202 |
+
assert "Nothing logged" in client.fake.last
|
| 203 |
+
|
| 204 |
+
def test_multi_currency_is_reported_separately(self, client):
|
| 205 |
+
send(client, "coffee 250")
|
| 206 |
+
send(client, "€40 dinner")
|
| 207 |
+
send(client, "/today")
|
| 208 |
+
# Two currencies must never be summed into one figure.
|
| 209 |
+
assert "Also:" in client.fake.last
|
| 210 |
+
|
| 211 |
+
def test_list(self, client):
|
| 212 |
+
send(client, "coffee 250")
|
| 213 |
+
send(client, "/list")
|
| 214 |
+
assert "coffee" in client.fake.last
|
| 215 |
+
|
| 216 |
+
def test_report_accepts_a_period(self, client):
|
| 217 |
+
send(client, "/report last_month")
|
| 218 |
+
assert client.fake.last
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class TestBudgets:
|
| 222 |
+
def test_set_and_view(self, client):
|
| 223 |
+
send(client, "/budget food_dining 8000")
|
| 224 |
+
assert "Budget set" in client.fake.last
|
| 225 |
+
send(client, "/budgets")
|
| 226 |
+
assert "Food & Dining" in client.fake.last
|
| 227 |
+
|
| 228 |
+
def test_budget_tracks_spending(self, client):
|
| 229 |
+
send(client, "/budget food_dining 1000")
|
| 230 |
+
send(client, "500 lunch")
|
| 231 |
+
send(client, "/budgets")
|
| 232 |
+
assert "50%" in client.fake.last
|
| 233 |
+
|
| 234 |
+
def test_unknown_category_is_rejected(self, client):
|
| 235 |
+
send(client, "/budget nonsense 500")
|
| 236 |
+
assert "don't recognise" in client.fake.last
|
| 237 |
+
|
| 238 |
+
def test_budget_without_amount(self, client):
|
| 239 |
+
send(client, "/budget groceries")
|
| 240 |
+
assert "need an amount" in client.fake.last
|
| 241 |
+
|
| 242 |
+
def test_categories_listing(self, client):
|
| 243 |
+
send(client, "/categories")
|
| 244 |
+
assert "food_dining" in client.fake.last
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
class TestCoreCommands:
|
| 248 |
+
def test_start(self, client):
|
| 249 |
+
send(client, "/start")
|
| 250 |
+
assert "Hello" in client.fake.last
|
| 251 |
+
|
| 252 |
+
def test_help_lists_registered_features(self, client):
|
| 253 |
+
send(client, "/help")
|
| 254 |
+
text = client.fake.last
|
| 255 |
+
assert "/spend" in text
|
| 256 |
+
assert "/budget" in text
|
| 257 |
+
assert "/advice" in text
|
| 258 |
+
|
| 259 |
+
def test_unknown_command(self, client):
|
| 260 |
+
send(client, "/nonsense")
|
| 261 |
+
assert "don't know" in client.fake.last
|
| 262 |
+
|
| 263 |
+
def test_status_warns_about_ephemeral_storage(self, client):
|
| 264 |
+
send(client, "/status")
|
| 265 |
+
# HF_DATASET_REPO is unset in this environment, so it must say so.
|
| 266 |
+
assert "ephemeral" in client.fake.last.lower()
|
| 267 |
+
|
| 268 |
+
def test_whoami(self, client):
|
| 269 |
+
send(client, "/whoami")
|
| 270 |
+
assert "42" in client.fake.last
|
| 271 |
+
|
| 272 |
+
def test_advice_works_without_inference(self, client):
|
| 273 |
+
send(client, "500 lunch")
|
| 274 |
+
send(client, "/advice")
|
| 275 |
+
assert client.fake.last # deterministic observations still fire
|
| 276 |
+
|
| 277 |
+
def test_export_sends_a_csv(self, client):
|
| 278 |
+
send(client, "coffee 250")
|
| 279 |
+
send(client, "/export")
|
| 280 |
+
assert len(client.fake.documents) == 1
|
| 281 |
+
_chat, filename, payload = client.fake.documents[0]
|
| 282 |
+
assert filename.endswith(".csv")
|
| 283 |
+
assert b"coffee" in payload
|
| 284 |
+
|
| 285 |
+
def test_export_with_nothing_logged(self, client):
|
| 286 |
+
send(client, "/export")
|
| 287 |
+
assert "Nothing to export" in client.fake.last
|
tests/test_categories.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from finbot.categories import CATEGORIES, categorise, coerce, is_valid, label
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TestCategorise:
|
| 7 |
+
@pytest.mark.parametrize(
|
| 8 |
+
"text,expected",
|
| 9 |
+
[
|
| 10 |
+
("uber ride home", "transport"),
|
| 11 |
+
("netflix monthly", "subscriptions"),
|
| 12 |
+
("bigbasket order", "groceries"),
|
| 13 |
+
("swiggy dinner", "food_dining"),
|
| 14 |
+
("petrol pump", "fuel"),
|
| 15 |
+
("electricity bill", "utilities"),
|
| 16 |
+
("apollo pharmacy", "health"),
|
| 17 |
+
("bookmyshow tickets", "entertainment"),
|
| 18 |
+
("amazon order", "shopping"),
|
| 19 |
+
("atm withdrawal", "cash"),
|
| 20 |
+
("salary credited", "income"),
|
| 21 |
+
],
|
| 22 |
+
)
|
| 23 |
+
def test_keyword_hits(self, text, expected):
|
| 24 |
+
category, confidence = categorise(text)
|
| 25 |
+
assert category == expected
|
| 26 |
+
assert confidence == 1.0
|
| 27 |
+
|
| 28 |
+
def test_unknown_returns_other_with_zero_confidence(self):
|
| 29 |
+
category, confidence = categorise("blorptastic widget")
|
| 30 |
+
assert category == "other"
|
| 31 |
+
assert confidence == 0.0
|
| 32 |
+
|
| 33 |
+
def test_empty_input(self):
|
| 34 |
+
assert categorise("") == ("other", 0.0)
|
| 35 |
+
|
| 36 |
+
def test_specificity_order_fuel_beats_transport(self):
|
| 37 |
+
# A petrol pump is fuel, even though it is transport-adjacent.
|
| 38 |
+
assert categorise("indian oil petrol")[0] == "fuel"
|
| 39 |
+
|
| 40 |
+
def test_specificity_order_subscription_beats_entertainment(self):
|
| 41 |
+
assert categorise("netflix")[0] == "subscriptions"
|
| 42 |
+
|
| 43 |
+
def test_word_boundaries_prevent_false_hits(self):
|
| 44 |
+
# "cashew" must not match the "cash" keyword.
|
| 45 |
+
assert categorise("cashew nuts")[0] != "cash"
|
| 46 |
+
# "gasket" must not match "gas station".
|
| 47 |
+
assert categorise("engine gasket")[0] != "fuel"
|
| 48 |
+
|
| 49 |
+
def test_merchant_is_considered(self):
|
| 50 |
+
category, _ = categorise("monthly charge", merchant="Spotify")
|
| 51 |
+
assert category == "subscriptions"
|
| 52 |
+
|
| 53 |
+
def test_case_insensitive(self):
|
| 54 |
+
assert categorise("UBER RIDE")[0] == "transport"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class TestCoerce:
|
| 58 |
+
@pytest.mark.parametrize(
|
| 59 |
+
"raw,expected",
|
| 60 |
+
[
|
| 61 |
+
("food_dining", "food_dining"),
|
| 62 |
+
("Food", "food_dining"),
|
| 63 |
+
("dining", "food_dining"),
|
| 64 |
+
("grocery", "groceries"),
|
| 65 |
+
("healthcare", "health"),
|
| 66 |
+
("Subscription", "subscriptions"),
|
| 67 |
+
("misc", "other"),
|
| 68 |
+
("", "other"),
|
| 69 |
+
("total nonsense", "other"),
|
| 70 |
+
],
|
| 71 |
+
)
|
| 72 |
+
def test_coerce(self, raw, expected):
|
| 73 |
+
assert coerce(raw) == expected
|
| 74 |
+
|
| 75 |
+
def test_every_result_is_a_valid_category(self):
|
| 76 |
+
# A stray label from a model must never reach the ledger, or totals
|
| 77 |
+
# fragment across categories that do not exist.
|
| 78 |
+
for raw in ["", "???", "Investments", "food", "TRANSPORT", "x" * 200]:
|
| 79 |
+
assert is_valid(coerce(raw))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class TestLabels:
|
| 83 |
+
def test_every_category_has_a_label(self):
|
| 84 |
+
for category in CATEGORIES:
|
| 85 |
+
assert label(category)
|
| 86 |
+
assert label(category) != category or "_" not in category
|
tests/test_extract.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
|
| 3 |
+
from finbot.extract import extract, extract_csv, extract_xlsx, rows_to_text
|
| 4 |
+
|
| 5 |
+
CSV_BYTES = (
|
| 6 |
+
b"Date,Narration,Withdrawal,Deposit\n"
|
| 7 |
+
b"01/08/2026,SWIGGY BANGALORE,450.00,\n"
|
| 8 |
+
b"03/08/2026,SALARY AUG,,85000.00\n"
|
| 9 |
+
b"05/08/2026,NETFLIX.COM,649.00,\n"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def make_xlsx() -> bytes:
|
| 14 |
+
from openpyxl import Workbook
|
| 15 |
+
|
| 16 |
+
workbook = Workbook()
|
| 17 |
+
sheet = workbook.active
|
| 18 |
+
sheet.append(["Date", "Description", "Amount"])
|
| 19 |
+
sheet.append(["2026-08-01", "Swiggy", 450])
|
| 20 |
+
sheet.append([None, None, None]) # blank row must be dropped
|
| 21 |
+
sheet.append(["2026-08-05", "Netflix", 649])
|
| 22 |
+
buffer = io.BytesIO()
|
| 23 |
+
workbook.save(buffer)
|
| 24 |
+
return buffer.getvalue()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TestCSV:
|
| 28 |
+
def test_parses_rows(self):
|
| 29 |
+
doc = extract_csv(CSV_BYTES)
|
| 30 |
+
assert doc.kind == "csv"
|
| 31 |
+
assert len(doc.rows) == 4 # header + 3
|
| 32 |
+
assert doc.rows[1][1] == "SWIGGY BANGALORE"
|
| 33 |
+
|
| 34 |
+
def test_semicolon_delimiter(self):
|
| 35 |
+
doc = extract_csv(b"a;b;c\n1;2;3\n4;5;6\n")
|
| 36 |
+
assert doc.rows[0] == ["a", "b", "c"]
|
| 37 |
+
|
| 38 |
+
def test_skips_blank_rows(self):
|
| 39 |
+
doc = extract_csv(b"a,b\n\n1,2\n")
|
| 40 |
+
assert len(doc.rows) == 2
|
| 41 |
+
|
| 42 |
+
def test_handles_bom(self):
|
| 43 |
+
doc = extract_csv(b"\xef\xbb\xbfDate,Amount\n2026-08-01,10\n")
|
| 44 |
+
assert doc.rows[0][0] == "Date"
|
| 45 |
+
|
| 46 |
+
def test_handles_non_utf8(self):
|
| 47 |
+
doc = extract_csv("Caf\xe9,10\n".encode("latin-1"))
|
| 48 |
+
assert "Caf" in doc.rows[0][0]
|
| 49 |
+
|
| 50 |
+
def test_single_column_does_not_crash(self):
|
| 51 |
+
doc = extract_csv(b"justonecolumn\nvalue\n")
|
| 52 |
+
assert len(doc.rows) == 2
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class TestXLSX:
|
| 56 |
+
def test_parses_rows(self):
|
| 57 |
+
doc = extract_xlsx(make_xlsx())
|
| 58 |
+
assert doc.kind == "xlsx"
|
| 59 |
+
assert doc.rows[0] == ["Date", "Description", "Amount"]
|
| 60 |
+
assert ["2026-08-05", "Netflix", "649"] in doc.rows
|
| 61 |
+
|
| 62 |
+
def test_drops_blank_rows(self):
|
| 63 |
+
doc = extract_xlsx(make_xlsx())
|
| 64 |
+
assert all(any(cell for cell in row) for row in doc.rows)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class TestDispatch:
|
| 68 |
+
def test_by_extension(self):
|
| 69 |
+
assert extract(CSV_BYTES, "statement.csv").kind == "csv"
|
| 70 |
+
assert extract(make_xlsx(), "book.xlsx").kind == "xlsx"
|
| 71 |
+
|
| 72 |
+
def test_by_mime_when_no_extension(self):
|
| 73 |
+
assert extract(CSV_BYTES, "", "text/csv").kind == "csv"
|
| 74 |
+
|
| 75 |
+
def test_plain_text(self):
|
| 76 |
+
doc = extract(b"hello", "notes.txt")
|
| 77 |
+
assert doc.kind == "text"
|
| 78 |
+
assert doc.text == "hello"
|
| 79 |
+
|
| 80 |
+
def test_legacy_xls_is_reported_not_crashed(self):
|
| 81 |
+
doc = extract(b"\xd0\xcf\x11\xe0", "old.xls")
|
| 82 |
+
assert doc.kind == "unsupported"
|
| 83 |
+
assert ".xlsx" in doc.note
|
| 84 |
+
|
| 85 |
+
def test_unknown_type_explains_itself(self):
|
| 86 |
+
doc = extract(b"\x00\x01", "thing.bin")
|
| 87 |
+
assert doc.kind == "unsupported"
|
| 88 |
+
assert doc.note
|
| 89 |
+
|
| 90 |
+
def test_is_empty(self):
|
| 91 |
+
assert extract(b"", "empty.txt").is_empty
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class TestRowsToText:
|
| 95 |
+
def test_pipe_delimited(self):
|
| 96 |
+
text = rows_to_text([["a", "b"], ["1", "2"]])
|
| 97 |
+
assert text == "a | b\n1 | 2"
|
| 98 |
+
|
| 99 |
+
def test_respects_limit(self):
|
| 100 |
+
text = rows_to_text([["x"]] * 100, limit=3)
|
| 101 |
+
assert len(text.splitlines()) == 3
|
tests/test_ingest.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from finbot.extract import ExtractedDocument
|
| 6 |
+
from finbot.ingest import Ingestor, _coerce_amount, _draft_from_obj, _parse_any_date
|
| 7 |
+
from finbot.llm import LLMUnavailable
|
| 8 |
+
|
| 9 |
+
TODAY = date(2026, 8, 15)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class StubLLM:
|
| 13 |
+
"""Stands in for LLMClient without touching the network."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, payload=None, enabled=True, raises=None):
|
| 16 |
+
self.payload = payload
|
| 17 |
+
self.enabled = enabled
|
| 18 |
+
self.raises = raises
|
| 19 |
+
self.calls = []
|
| 20 |
+
|
| 21 |
+
def chat_json(self, system, user, **kwargs):
|
| 22 |
+
self.calls.append(("chat_json", system, user))
|
| 23 |
+
if self.raises:
|
| 24 |
+
raise self.raises
|
| 25 |
+
return self.payload
|
| 26 |
+
|
| 27 |
+
def vision_json(self, system, user, image_bytes, mime_type, **kwargs):
|
| 28 |
+
self.calls.append(("vision_json", system, user))
|
| 29 |
+
if self.raises:
|
| 30 |
+
raise self.raises
|
| 31 |
+
return self.payload
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class TestHelpers:
|
| 35 |
+
@pytest.mark.parametrize(
|
| 36 |
+
"raw,expected",
|
| 37 |
+
[(10, 10.0), (-10, 10.0), ("1,234.56", 1234.56), ("₹450", 450.0), ("", None), (None, None)],
|
| 38 |
+
)
|
| 39 |
+
def test_coerce_amount(self, raw, expected):
|
| 40 |
+
assert _coerce_amount(raw) == expected
|
| 41 |
+
|
| 42 |
+
@pytest.mark.parametrize(
|
| 43 |
+
"raw,expected",
|
| 44 |
+
[
|
| 45 |
+
("2026-08-01", date(2026, 8, 1)),
|
| 46 |
+
("01/08/2026", date(2026, 8, 1)), # day-first
|
| 47 |
+
("", TODAY),
|
| 48 |
+
("garbage", TODAY),
|
| 49 |
+
(None, TODAY),
|
| 50 |
+
],
|
| 51 |
+
)
|
| 52 |
+
def test_parse_any_date(self, raw, expected):
|
| 53 |
+
assert _parse_any_date(raw, TODAY) == expected
|
| 54 |
+
|
| 55 |
+
def test_draft_from_obj(self):
|
| 56 |
+
draft = _draft_from_obj(
|
| 57 |
+
{
|
| 58 |
+
"amount": 450,
|
| 59 |
+
"currency": "inr",
|
| 60 |
+
"description": "Swiggy order",
|
| 61 |
+
"date": "2026-08-01",
|
| 62 |
+
"category": "food_dining",
|
| 63 |
+
},
|
| 64 |
+
"USD",
|
| 65 |
+
TODAY,
|
| 66 |
+
)
|
| 67 |
+
assert draft.amount_minor == 45000
|
| 68 |
+
assert draft.currency == "INR"
|
| 69 |
+
assert draft.category == "food_dining"
|
| 70 |
+
|
| 71 |
+
def test_draft_rejects_zero_and_missing_amounts(self):
|
| 72 |
+
assert _draft_from_obj({"amount": 0}, "INR", TODAY) is None
|
| 73 |
+
assert _draft_from_obj({}, "INR", TODAY) is None
|
| 74 |
+
assert _draft_from_obj("not a dict", "INR", TODAY) is None
|
| 75 |
+
|
| 76 |
+
def test_draft_falls_back_to_keyword_category(self):
|
| 77 |
+
# Model gave an unusable category, but the text is obviously transport.
|
| 78 |
+
draft = _draft_from_obj(
|
| 79 |
+
{"amount": 100, "description": "uber ride", "category": "???"}, "INR", TODAY
|
| 80 |
+
)
|
| 81 |
+
assert draft.category == "transport"
|
| 82 |
+
|
| 83 |
+
def test_draft_defaults_currency(self):
|
| 84 |
+
draft = _draft_from_obj({"amount": 10}, "SGD", TODAY)
|
| 85 |
+
assert draft.currency == "SGD"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TestFromText:
|
| 89 |
+
def test_deterministic_path_does_not_call_the_model(self):
|
| 90 |
+
llm = StubLLM()
|
| 91 |
+
draft = Ingestor(llm, "INR").from_text("coffee 250", TODAY)
|
| 92 |
+
assert draft.amount_minor == 25000
|
| 93 |
+
assert llm.calls == [] # the whole point of the fast path
|
| 94 |
+
|
| 95 |
+
def test_falls_back_to_the_model_without_an_amount(self):
|
| 96 |
+
llm = StubLLM(payload={"amount": 12, "currency": "USD", "description": "a coffee"})
|
| 97 |
+
draft = Ingestor(llm, "INR").from_text("bought a coffee for twelve dollars", TODAY)
|
| 98 |
+
assert draft is not None
|
| 99 |
+
assert draft.amount_minor == 1200
|
| 100 |
+
assert len(llm.calls) == 1
|
| 101 |
+
|
| 102 |
+
def test_returns_none_when_the_model_is_disabled(self):
|
| 103 |
+
llm = StubLLM(enabled=False)
|
| 104 |
+
assert Ingestor(llm, "INR").from_text("no numbers here", TODAY) is None
|
| 105 |
+
|
| 106 |
+
def test_model_failure_degrades_quietly(self):
|
| 107 |
+
llm = StubLLM(raises=LLMUnavailable("down"))
|
| 108 |
+
assert Ingestor(llm, "INR").from_text("no numbers here", TODAY) is None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class TestFromReceipt:
|
| 112 |
+
def test_reads_a_total(self):
|
| 113 |
+
llm = StubLLM(
|
| 114 |
+
payload={
|
| 115 |
+
"merchant": "Blue Tokai",
|
| 116 |
+
"amount": 480,
|
| 117 |
+
"currency": "INR",
|
| 118 |
+
"date": "2026-08-14",
|
| 119 |
+
"category": "food_dining",
|
| 120 |
+
"confidence": 0.9,
|
| 121 |
+
}
|
| 122 |
+
)
|
| 123 |
+
draft, error = Ingestor(llm, "INR").from_receipt(b"jpeg", "image/jpeg", TODAY)
|
| 124 |
+
assert error == ""
|
| 125 |
+
assert draft.amount_minor == 48000
|
| 126 |
+
assert draft.merchant == "Blue Tokai"
|
| 127 |
+
|
| 128 |
+
def test_explains_when_disabled(self):
|
| 129 |
+
llm = StubLLM(enabled=False)
|
| 130 |
+
draft, error = Ingestor(llm, "INR").from_receipt(b"x", "image/jpeg", TODAY)
|
| 131 |
+
assert draft is None
|
| 132 |
+
assert "HF_TOKEN" in error
|
| 133 |
+
|
| 134 |
+
def test_explains_an_unreadable_image(self):
|
| 135 |
+
llm = StubLLM(payload={"amount": 0})
|
| 136 |
+
draft, error = Ingestor(llm, "INR").from_receipt(b"x", "image/jpeg", TODAY)
|
| 137 |
+
assert draft is None
|
| 138 |
+
assert "couldn't find a total" in error
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class TestFromDocument:
|
| 142 |
+
def _doc(self):
|
| 143 |
+
return ExtractedDocument(kind="csv", rows=[["Date", "Desc", "Amt"], ["01/08/2026", "Swiggy", "450"]])
|
| 144 |
+
|
| 145 |
+
def test_extracts_debits(self):
|
| 146 |
+
llm = StubLLM(
|
| 147 |
+
payload={
|
| 148 |
+
"transactions": [
|
| 149 |
+
{
|
| 150 |
+
"date": "2026-08-01",
|
| 151 |
+
"description": "Swiggy",
|
| 152 |
+
"amount": 450,
|
| 153 |
+
"currency": "INR",
|
| 154 |
+
"direction": "debit",
|
| 155 |
+
"category": "food_dining",
|
| 156 |
+
}
|
| 157 |
+
]
|
| 158 |
+
}
|
| 159 |
+
)
|
| 160 |
+
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
|
| 161 |
+
assert error == ""
|
| 162 |
+
assert len(pending.drafts) == 1
|
| 163 |
+
assert pending.total_by_currency() == {"INR": 45000}
|
| 164 |
+
|
| 165 |
+
def test_credits_are_counted_not_stored(self):
|
| 166 |
+
# Storing income alongside spend would corrupt every total.
|
| 167 |
+
llm = StubLLM(
|
| 168 |
+
payload={
|
| 169 |
+
"transactions": [
|
| 170 |
+
{"date": "2026-08-01", "description": "Swiggy", "amount": 450,
|
| 171 |
+
"currency": "INR", "direction": "debit"},
|
| 172 |
+
{"date": "2026-08-03", "description": "Salary", "amount": 85000,
|
| 173 |
+
"currency": "INR", "direction": "credit"},
|
| 174 |
+
]
|
| 175 |
+
}
|
| 176 |
+
)
|
| 177 |
+
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
|
| 178 |
+
assert len(pending.drafts) == 1
|
| 179 |
+
assert "credits skipped" in pending.label
|
| 180 |
+
|
| 181 |
+
def test_counts_unreadable_rows(self):
|
| 182 |
+
llm = StubLLM(
|
| 183 |
+
payload={
|
| 184 |
+
"transactions": [
|
| 185 |
+
{"date": "2026-08-01", "description": "Swiggy", "amount": 450,
|
| 186 |
+
"currency": "INR", "direction": "debit"},
|
| 187 |
+
{"description": "junk"}, # no amount
|
| 188 |
+
]
|
| 189 |
+
}
|
| 190 |
+
)
|
| 191 |
+
pending, _ = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
|
| 192 |
+
assert len(pending.drafts) == 1
|
| 193 |
+
assert pending.skipped == 1
|
| 194 |
+
|
| 195 |
+
def test_accepts_a_bare_array(self):
|
| 196 |
+
llm = StubLLM(
|
| 197 |
+
payload=[{"date": "2026-08-01", "description": "Swiggy", "amount": 450,
|
| 198 |
+
"currency": "INR", "direction": "debit"}]
|
| 199 |
+
)
|
| 200 |
+
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
|
| 201 |
+
assert error == ""
|
| 202 |
+
assert len(pending.drafts) == 1
|
| 203 |
+
|
| 204 |
+
def test_reports_when_nothing_spendable_is_found(self):
|
| 205 |
+
llm = StubLLM(payload={"transactions": []})
|
| 206 |
+
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
|
| 207 |
+
assert pending is None
|
| 208 |
+
assert "no spending transactions" in error
|
| 209 |
+
|
| 210 |
+
def test_unsupported_document_short_circuits(self):
|
| 211 |
+
llm = StubLLM()
|
| 212 |
+
doc = ExtractedDocument(kind="unsupported", note="nope")
|
| 213 |
+
pending, error = Ingestor(llm, "INR").from_document(doc, user_id=1, today=TODAY)
|
| 214 |
+
assert pending is None
|
| 215 |
+
assert error == "nope"
|
| 216 |
+
assert llm.calls == [] # never spend a call on a file we cannot read
|
| 217 |
+
|
| 218 |
+
def test_flags_truncation(self):
|
| 219 |
+
llm = StubLLM(
|
| 220 |
+
payload={"transactions": [{"date": "2026-08-01", "description": "x",
|
| 221 |
+
"amount": 1, "currency": "INR", "direction": "debit"}]}
|
| 222 |
+
)
|
| 223 |
+
doc = self._doc()
|
| 224 |
+
doc.truncated = True
|
| 225 |
+
pending, _ = Ingestor(llm, "INR").from_document(doc, user_id=1, today=TODAY)
|
| 226 |
+
assert "truncated" in pending.label
|
| 227 |
+
|
| 228 |
+
def test_disabled_model_explains_itself(self):
|
| 229 |
+
llm = StubLLM(enabled=False)
|
| 230 |
+
pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
|
| 231 |
+
assert pending is None
|
| 232 |
+
assert "HF_TOKEN" in error
|
tests/test_limits.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from finbot.config import Settings
|
| 4 |
+
from finbot.limits import WINDOW_SECONDS, RateLimiter
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def settings(**overrides):
|
| 8 |
+
base = dict(
|
| 9 |
+
telegram_bot_token="",
|
| 10 |
+
telegram_webhook_secret="",
|
| 11 |
+
allowed_telegram_user_ids="",
|
| 12 |
+
hf_token="",
|
| 13 |
+
hf_dataset_repo="",
|
| 14 |
+
open_access=False,
|
| 15 |
+
)
|
| 16 |
+
base.update(overrides)
|
| 17 |
+
return Settings(**base)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TestAuthorisation:
|
| 21 |
+
def test_empty_allowlist_authorises_nobody(self):
|
| 22 |
+
# The safe default: a forgotten allowlist must not mean "open to all".
|
| 23 |
+
assert settings().is_authorised(42) is False
|
| 24 |
+
|
| 25 |
+
def test_allowlist_match(self):
|
| 26 |
+
s = settings(allowed_telegram_user_ids="42, 99")
|
| 27 |
+
assert s.is_authorised(42)
|
| 28 |
+
assert s.is_authorised(99)
|
| 29 |
+
assert not s.is_authorised(7)
|
| 30 |
+
|
| 31 |
+
def test_open_access_authorises_anyone(self):
|
| 32 |
+
s = settings(open_access=True)
|
| 33 |
+
assert s.is_authorised(1)
|
| 34 |
+
assert s.is_authorised(123456789)
|
| 35 |
+
|
| 36 |
+
def test_open_access_ignores_the_allowlist(self):
|
| 37 |
+
s = settings(open_access=True, allowed_telegram_user_ids="42")
|
| 38 |
+
assert s.is_authorised(7)
|
| 39 |
+
|
| 40 |
+
def test_malformed_ids_are_dropped_not_fatal(self):
|
| 41 |
+
s = settings(allowed_telegram_user_ids="42,,abc, 99 ,")
|
| 42 |
+
assert s.allowed_user_ids == frozenset({42, 99})
|
| 43 |
+
|
| 44 |
+
def test_open_access_removes_the_allowlist_warning(self):
|
| 45 |
+
assert any("ALLOWED" in g for g in settings().missing_required())
|
| 46 |
+
assert not any(
|
| 47 |
+
"ALLOWED" in g for g in settings(open_access=True).missing_required()
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class TestRateLimiter:
|
| 52 |
+
def test_allows_up_to_the_cap(self):
|
| 53 |
+
limiter = RateLimiter(3)
|
| 54 |
+
assert [limiter.check(1) for _ in range(3)] == [True, True, True]
|
| 55 |
+
|
| 56 |
+
def test_blocks_past_the_cap(self):
|
| 57 |
+
limiter = RateLimiter(2)
|
| 58 |
+
limiter.check(1)
|
| 59 |
+
limiter.check(1)
|
| 60 |
+
assert limiter.check(1) is False
|
| 61 |
+
|
| 62 |
+
def test_users_are_independent(self):
|
| 63 |
+
limiter = RateLimiter(1)
|
| 64 |
+
assert limiter.check(1) is True
|
| 65 |
+
assert limiter.check(2) is True
|
| 66 |
+
assert limiter.check(1) is False
|
| 67 |
+
|
| 68 |
+
def test_zero_disables_the_cap(self):
|
| 69 |
+
limiter = RateLimiter(0)
|
| 70 |
+
assert not limiter.enabled
|
| 71 |
+
assert all(limiter.check(1) for _ in range(500))
|
| 72 |
+
|
| 73 |
+
def test_negative_disables_the_cap(self):
|
| 74 |
+
assert not RateLimiter(-1).enabled
|
| 75 |
+
|
| 76 |
+
def test_window_expires(self):
|
| 77 |
+
limiter = RateLimiter(1)
|
| 78 |
+
assert limiter.check(1, now=1000.0) is True
|
| 79 |
+
assert limiter.check(1, now=1000.0) is False
|
| 80 |
+
# Just past the 24h window, the old hit no longer counts.
|
| 81 |
+
assert limiter.check(1, now=1000.0 + WINDOW_SECONDS + 1) is True
|
| 82 |
+
|
| 83 |
+
def test_remaining(self):
|
| 84 |
+
limiter = RateLimiter(3)
|
| 85 |
+
assert limiter.remaining(1) == 3
|
| 86 |
+
limiter.check(1)
|
| 87 |
+
assert limiter.remaining(1) == 2
|
| 88 |
+
|
| 89 |
+
def test_remaining_is_unlimited_when_disabled(self):
|
| 90 |
+
assert RateLimiter(0).remaining(1) == -1
|
| 91 |
+
|
| 92 |
+
@pytest.mark.parametrize("cap", [1, 5, 20])
|
| 93 |
+
def test_never_exceeds_cap(self, cap):
|
| 94 |
+
limiter = RateLimiter(cap)
|
| 95 |
+
granted = sum(1 for _ in range(cap * 3) if limiter.check(1, now=500.0))
|
| 96 |
+
assert granted == cap
|
tests/test_money.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from finbot.money import (
|
| 4 |
+
detect_currency,
|
| 5 |
+
exponent,
|
| 6 |
+
format_money,
|
| 7 |
+
from_minor,
|
| 8 |
+
parse_amount,
|
| 9 |
+
to_minor,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestExponent:
|
| 14 |
+
@pytest.mark.parametrize(
|
| 15 |
+
"currency,expected",
|
| 16 |
+
[("INR", 2), ("USD", 2), ("EUR", 2), ("JPY", 0), ("KRW", 0), ("KWD", 3), ("xyz", 2)],
|
| 17 |
+
)
|
| 18 |
+
def test_exponent(self, currency, expected):
|
| 19 |
+
assert exponent(currency) == expected
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TestToMinor:
|
| 23 |
+
@pytest.mark.parametrize(
|
| 24 |
+
"value,currency,expected",
|
| 25 |
+
[
|
| 26 |
+
(250, "INR", 25000),
|
| 27 |
+
("250.50", "INR", 25050),
|
| 28 |
+
(1234.56, "USD", 123456),
|
| 29 |
+
(1000, "JPY", 1000), # zero-decimal currency
|
| 30 |
+
(1.5, "KWD", 1500), # three-decimal currency
|
| 31 |
+
("0.01", "USD", 1),
|
| 32 |
+
],
|
| 33 |
+
)
|
| 34 |
+
def test_to_minor(self, value, currency, expected):
|
| 35 |
+
assert to_minor(value, currency) == expected
|
| 36 |
+
|
| 37 |
+
def test_rounds_half_up_not_bankers(self):
|
| 38 |
+
# Python's round() would give 8.34 here; money should round half up.
|
| 39 |
+
assert to_minor("8.345", "USD") == 835
|
| 40 |
+
|
| 41 |
+
def test_no_float_drift(self):
|
| 42 |
+
# The classic 0.1 + 0.2 problem must not reach the ledger.
|
| 43 |
+
total = to_minor("0.1", "USD") + to_minor("0.2", "USD")
|
| 44 |
+
assert total == 30
|
| 45 |
+
assert from_minor(total, "USD") == 0.3
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class TestFormatMoney:
|
| 49 |
+
@pytest.mark.parametrize(
|
| 50 |
+
"minor,currency,expected",
|
| 51 |
+
[
|
| 52 |
+
(25000, "INR", "₹250.00"),
|
| 53 |
+
(123456, "USD", "$1,234.56"),
|
| 54 |
+
(1000, "JPY", "¥1,000"),
|
| 55 |
+
(-5000, "INR", "-₹50.00"),
|
| 56 |
+
(99, "USD", "$0.99"),
|
| 57 |
+
],
|
| 58 |
+
)
|
| 59 |
+
def test_format(self, minor, currency, expected):
|
| 60 |
+
assert format_money(minor, currency) == expected
|
| 61 |
+
|
| 62 |
+
def test_unknown_currency_uses_suffix(self):
|
| 63 |
+
assert format_money(150000, "AED") == "1,500.00 AED"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class TestDetectCurrency:
|
| 67 |
+
@pytest.mark.parametrize(
|
| 68 |
+
"text,expected",
|
| 69 |
+
[
|
| 70 |
+
("₹250 coffee", "INR"),
|
| 71 |
+
("€40 dinner", "EUR"),
|
| 72 |
+
("£12 book", "GBP"),
|
| 73 |
+
("paid 50 USD", "USD"),
|
| 74 |
+
("Rs 300 chai", "INR"),
|
| 75 |
+
],
|
| 76 |
+
)
|
| 77 |
+
def test_explicit(self, text, expected):
|
| 78 |
+
assert detect_currency(text, "SGD") == expected
|
| 79 |
+
|
| 80 |
+
def test_falls_back_to_default(self):
|
| 81 |
+
assert detect_currency("coffee 250", "SGD") == "SGD"
|
| 82 |
+
|
| 83 |
+
def test_letter_symbols_need_word_boundaries(self):
|
| 84 |
+
# "cars" contains "rs" -- it must not be read as rupees.
|
| 85 |
+
assert detect_currency("cars 250", "USD") == "USD"
|
| 86 |
+
assert detect_currency("chfoo 12", "USD") == "USD"
|
| 87 |
+
|
| 88 |
+
def test_ambiguous_dollar_uses_default(self):
|
| 89 |
+
assert detect_currency("$40 lunch", "SGD") == "SGD"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class TestParseAmount:
|
| 93 |
+
@pytest.mark.parametrize(
|
| 94 |
+
"text,expected_minor",
|
| 95 |
+
[
|
| 96 |
+
("coffee 250", 25000),
|
| 97 |
+
("₹1,200 groceries", 120000),
|
| 98 |
+
("45.50 lunch", 4550),
|
| 99 |
+
("1,234.56 spent", 123456),
|
| 100 |
+
("1.234,56 gekocht", 123456), # European separators
|
| 101 |
+
("1,23,456.78 rent", 12345678), # Indian lakh grouping
|
| 102 |
+
],
|
| 103 |
+
)
|
| 104 |
+
def test_amounts(self, text, expected_minor):
|
| 105 |
+
parsed = parse_amount(text, "INR")
|
| 106 |
+
assert parsed is not None
|
| 107 |
+
assert parsed.minor == expected_minor
|
| 108 |
+
|
| 109 |
+
def test_returns_none_without_a_number(self):
|
| 110 |
+
assert parse_amount("just some words", "INR") is None
|
| 111 |
+
|
| 112 |
+
def test_prefers_the_largest_number(self):
|
| 113 |
+
# "2 coffees 250" -- the spend is 250, not 2.
|
| 114 |
+
parsed = parse_amount("2 coffees 250", "INR")
|
| 115 |
+
assert parsed is not None
|
| 116 |
+
assert parsed.minor == 25000
|
| 117 |
+
|
| 118 |
+
def test_reports_the_span_it_consumed(self):
|
| 119 |
+
parsed = parse_amount("coffee 250", "INR")
|
| 120 |
+
assert parsed is not None
|
| 121 |
+
start, end = parsed.span
|
| 122 |
+
assert "coffee 250"[start:end] == "250"
|
| 123 |
+
|
| 124 |
+
def test_thousands_vs_decimal_disambiguation(self):
|
| 125 |
+
# 3 digits after a single comma is grouping, 2 digits is decimal.
|
| 126 |
+
assert parse_amount("1,500", "INR").minor == 150000
|
| 127 |
+
assert parse_amount("1,50", "INR").minor == 150
|
| 128 |
+
|
| 129 |
+
@pytest.mark.parametrize(
|
| 130 |
+
"text,expected_minor",
|
| 131 |
+
[
|
| 132 |
+
("1000", 100_000),
|
| 133 |
+
("rent 25000", 2_500_000),
|
| 134 |
+
("123456", 12_345_600),
|
| 135 |
+
("9999999 wire", 999_999_900),
|
| 136 |
+
],
|
| 137 |
+
)
|
| 138 |
+
def test_unseparated_large_numbers(self, text, expected_minor):
|
| 139 |
+
# Regression: the old regex capped the integer part at three digits,
|
| 140 |
+
# so "1000" silently became 100 and every large amount was wrong.
|
| 141 |
+
parsed = parse_amount(text, "INR")
|
| 142 |
+
assert parsed is not None
|
| 143 |
+
assert parsed.minor == expected_minor
|
| 144 |
+
|
| 145 |
+
def test_space_separated_numbers_are_not_merged(self):
|
| 146 |
+
# "250 300" must read as two numbers (largest wins), never as 250300.
|
| 147 |
+
parsed = parse_amount("250 300", "INR")
|
| 148 |
+
assert parsed is not None
|
| 149 |
+
assert parsed.minor == 30000
|
| 150 |
+
|
| 151 |
+
def test_non_breaking_space_is_a_group_separator(self):
|
| 152 |
+
assert parse_amount("1 234,56", "EUR").minor == 123456
|
| 153 |
+
|
| 154 |
+
def test_trailing_punctuation_is_not_consumed(self):
|
| 155 |
+
parsed = parse_amount("spent 250.", "INR")
|
| 156 |
+
assert parsed is not None
|
| 157 |
+
assert parsed.minor == 25000
|
tests/test_parser.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from finbot.parser import parse_date_hint, parse_expense
|
| 6 |
+
|
| 7 |
+
TODAY = date(2026, 8, 15) # a Saturday
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestParseDateHint:
|
| 11 |
+
def test_defaults_to_today(self):
|
| 12 |
+
resolved, span = parse_date_hint("coffee 250", TODAY)
|
| 13 |
+
assert resolved == TODAY
|
| 14 |
+
assert span is None
|
| 15 |
+
|
| 16 |
+
@pytest.mark.parametrize(
|
| 17 |
+
"text,expected",
|
| 18 |
+
[
|
| 19 |
+
("lunch yesterday", date(2026, 8, 14)),
|
| 20 |
+
("coffee today", TODAY),
|
| 21 |
+
("dinner day before yesterday", date(2026, 8, 13)),
|
| 22 |
+
("paid 3 days ago", date(2026, 8, 12)),
|
| 23 |
+
("rent on 2026-08-01", date(2026, 8, 1)),
|
| 24 |
+
("bought 12 aug", date(2026, 8, 12)),
|
| 25 |
+
("bought aug 12", date(2026, 8, 12)),
|
| 26 |
+
],
|
| 27 |
+
)
|
| 28 |
+
def test_resolves(self, text, expected):
|
| 29 |
+
resolved, span = parse_date_hint(text, TODAY)
|
| 30 |
+
assert resolved == expected
|
| 31 |
+
assert span is not None
|
| 32 |
+
|
| 33 |
+
def test_future_dates_roll_back_a_year(self):
|
| 34 |
+
# "12 dec" typed in August means last December, not a future spend.
|
| 35 |
+
resolved, _ = parse_date_hint("gift 12 dec", TODAY)
|
| 36 |
+
assert resolved == date(2025, 12, 12)
|
| 37 |
+
|
| 38 |
+
def test_bare_weekday_means_the_most_recent_past_one(self):
|
| 39 |
+
# TODAY is a Saturday; "friday" is yesterday.
|
| 40 |
+
resolved, _ = parse_date_hint("drinks friday", TODAY)
|
| 41 |
+
assert resolved == date(2026, 8, 14)
|
| 42 |
+
|
| 43 |
+
def test_same_weekday_goes_back_a_full_week(self):
|
| 44 |
+
resolved, _ = parse_date_hint("shopping saturday", TODAY)
|
| 45 |
+
assert resolved == date(2026, 8, 8)
|
| 46 |
+
|
| 47 |
+
def test_day_first_preferred_for_ambiguous_slashes(self):
|
| 48 |
+
resolved, _ = parse_date_hint("paid 03/04", TODAY)
|
| 49 |
+
assert resolved == date(2026, 4, 3)
|
| 50 |
+
|
| 51 |
+
def test_falls_back_to_month_first_when_day_first_impossible(self):
|
| 52 |
+
# 25 is not a month, so 12/25 can only be December 25th.
|
| 53 |
+
resolved, _ = parse_date_hint("gift 12/25", TODAY)
|
| 54 |
+
assert resolved == date(2025, 12, 25)
|
| 55 |
+
|
| 56 |
+
def test_invalid_date_does_not_crash(self):
|
| 57 |
+
resolved, _ = parse_date_hint("paid 2026-02-30", TODAY)
|
| 58 |
+
assert resolved == TODAY
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class TestParseExpense:
|
| 62 |
+
def test_returns_none_without_an_amount(self):
|
| 63 |
+
assert parse_expense("hello there", "INR", TODAY) is None
|
| 64 |
+
|
| 65 |
+
def test_returns_none_for_empty(self):
|
| 66 |
+
assert parse_expense("", "INR", TODAY) is None
|
| 67 |
+
assert parse_expense(" ", "INR", TODAY) is None
|
| 68 |
+
|
| 69 |
+
def test_simple_entry(self):
|
| 70 |
+
draft = parse_expense("coffee 250", "INR", TODAY)
|
| 71 |
+
assert draft is not None
|
| 72 |
+
assert draft.amount_minor == 25000
|
| 73 |
+
assert draft.currency == "INR"
|
| 74 |
+
assert "coffee" in draft.description
|
| 75 |
+
assert draft.category == "food_dining"
|
| 76 |
+
assert draft.occurred_on == TODAY
|
| 77 |
+
|
| 78 |
+
def test_strips_the_amount_from_the_description(self):
|
| 79 |
+
draft = parse_expense("₹1,200 groceries at bigbasket", "INR", TODAY)
|
| 80 |
+
assert draft is not None
|
| 81 |
+
assert "1" not in draft.description
|
| 82 |
+
assert "₹" not in draft.description
|
| 83 |
+
assert draft.category == "groceries"
|
| 84 |
+
|
| 85 |
+
def test_strips_filler_words(self):
|
| 86 |
+
draft = parse_expense("spent 500 on dinner", "INR", TODAY)
|
| 87 |
+
assert draft is not None
|
| 88 |
+
assert draft.description == "dinner"
|
| 89 |
+
|
| 90 |
+
def test_date_phrase_leaves_the_description(self):
|
| 91 |
+
draft = parse_expense("45.50 lunch yesterday", "INR", TODAY)
|
| 92 |
+
assert draft is not None
|
| 93 |
+
assert draft.occurred_on == date(2026, 8, 14)
|
| 94 |
+
assert "yesterday" not in draft.description
|
| 95 |
+
assert draft.description == "lunch"
|
| 96 |
+
|
| 97 |
+
def test_hashtag_overrides_the_keyword_guess(self):
|
| 98 |
+
draft = parse_expense("320 lunch #transport", "INR", TODAY)
|
| 99 |
+
assert draft is not None
|
| 100 |
+
# "lunch" would normally be food_dining; the tag wins.
|
| 101 |
+
assert draft.category == "transport"
|
| 102 |
+
assert "#" not in draft.description
|
| 103 |
+
|
| 104 |
+
def test_unknown_hashtag_falls_back_to_other(self):
|
| 105 |
+
draft = parse_expense("100 thing #nonsense", "INR", TODAY)
|
| 106 |
+
assert draft is not None
|
| 107 |
+
assert draft.category == "other"
|
| 108 |
+
|
| 109 |
+
def test_explicit_currency_beats_the_default(self):
|
| 110 |
+
draft = parse_expense("€40 dinner", "INR", TODAY)
|
| 111 |
+
assert draft is not None
|
| 112 |
+
assert draft.currency == "EUR"
|
| 113 |
+
assert draft.amount_minor == 4000
|
| 114 |
+
|
| 115 |
+
def test_description_never_empty(self):
|
| 116 |
+
draft = parse_expense("250", "INR", TODAY)
|
| 117 |
+
assert draft is not None
|
| 118 |
+
assert draft.description == "unlabelled"
|
| 119 |
+
|
| 120 |
+
def test_keeps_the_raw_input(self):
|
| 121 |
+
draft = parse_expense(" coffee 250 ", "INR", TODAY)
|
| 122 |
+
assert draft is not None
|
| 123 |
+
assert draft.raw == "coffee 250"
|
tests/test_registry.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The registry is the extension point, so its contract is worth pinning down."""
|
| 2 |
+
|
| 3 |
+
from datetime import date
|
| 4 |
+
from typing import Optional, Sequence
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from finbot.features.base import (
|
| 9 |
+
Attachment,
|
| 10 |
+
BotContext,
|
| 11 |
+
CommandSpec,
|
| 12 |
+
Feature,
|
| 13 |
+
Reply,
|
| 14 |
+
Services,
|
| 15 |
+
SimpleFeature,
|
| 16 |
+
)
|
| 17 |
+
from finbot.registry import DuplicateCommand, FeatureRegistry
|
| 18 |
+
|
| 19 |
+
TODAY = date(2026, 8, 15)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Greeter(SimpleFeature):
|
| 23 |
+
name = "greeter"
|
| 24 |
+
description = "Greetings"
|
| 25 |
+
priority = 10
|
| 26 |
+
|
| 27 |
+
def commands(self) -> Sequence[CommandSpec]:
|
| 28 |
+
return (
|
| 29 |
+
CommandSpec("hello", "Say hello", aliases=("hi",)),
|
| 30 |
+
CommandSpec("secret", "Hidden thing", hidden=True),
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
def cmd_hello(self, ctx: BotContext) -> Reply:
|
| 34 |
+
return Reply(f"hello {ctx.args or 'world'}")
|
| 35 |
+
|
| 36 |
+
def cmd_secret(self, ctx: BotContext) -> Reply:
|
| 37 |
+
return Reply("shh")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class Echo(Feature):
|
| 41 |
+
name = "echo"
|
| 42 |
+
description = "Echo"
|
| 43 |
+
priority = 50
|
| 44 |
+
|
| 45 |
+
def handle_message(self, ctx: BotContext) -> Optional[Reply]:
|
| 46 |
+
if ctx.text.startswith("say "):
|
| 47 |
+
return Reply(ctx.text[4:])
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class Filer(Feature):
|
| 52 |
+
name = "filer"
|
| 53 |
+
description = "Files"
|
| 54 |
+
priority = 5
|
| 55 |
+
|
| 56 |
+
def handle_attachment(self, ctx: BotContext) -> Optional[Reply]:
|
| 57 |
+
return Reply(f"got {ctx.attachment.filename}")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class Exploder(Feature):
|
| 61 |
+
name = "exploder"
|
| 62 |
+
priority = 1
|
| 63 |
+
|
| 64 |
+
def commands(self) -> Sequence[CommandSpec]:
|
| 65 |
+
return (CommandSpec("boom", "Raises"),)
|
| 66 |
+
|
| 67 |
+
def handle_command(self, ctx: BotContext) -> Reply:
|
| 68 |
+
raise RuntimeError("intentional")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def make_ctx(registry, text="", command="", args="", attachment=None) -> BotContext:
|
| 72 |
+
services = Services(
|
| 73 |
+
settings=None, store=None, llm=None, ingestor=None, advisor=None, registry=registry
|
| 74 |
+
)
|
| 75 |
+
return BotContext(
|
| 76 |
+
user_id=1,
|
| 77 |
+
chat_id=1,
|
| 78 |
+
services=services,
|
| 79 |
+
today=TODAY,
|
| 80 |
+
text=text,
|
| 81 |
+
command=command,
|
| 82 |
+
args=args,
|
| 83 |
+
attachment=attachment,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@pytest.fixture
|
| 88 |
+
def registry():
|
| 89 |
+
reg = FeatureRegistry()
|
| 90 |
+
reg.register_all([Greeter(), Echo(), Filer()])
|
| 91 |
+
return reg
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class TestRegistration:
|
| 95 |
+
def test_features_sorted_by_priority(self, registry):
|
| 96 |
+
assert [f.name for f in registry.features] == ["filer", "greeter", "echo"]
|
| 97 |
+
|
| 98 |
+
def test_duplicate_command_is_rejected(self, registry):
|
| 99 |
+
class Clash(SimpleFeature):
|
| 100 |
+
name = "clash"
|
| 101 |
+
|
| 102 |
+
def commands(self):
|
| 103 |
+
return (CommandSpec("hello", "clashing"),)
|
| 104 |
+
|
| 105 |
+
with pytest.raises(DuplicateCommand, match="greeter"):
|
| 106 |
+
registry.register(Clash())
|
| 107 |
+
|
| 108 |
+
def test_duplicate_alias_is_rejected(self, registry):
|
| 109 |
+
class Clash(SimpleFeature):
|
| 110 |
+
name = "clash"
|
| 111 |
+
|
| 112 |
+
def commands(self):
|
| 113 |
+
return (CommandSpec("other", "x", aliases=("hi",)),)
|
| 114 |
+
|
| 115 |
+
with pytest.raises(DuplicateCommand):
|
| 116 |
+
registry.register(Clash())
|
| 117 |
+
|
| 118 |
+
def test_known_command(self, registry):
|
| 119 |
+
assert registry.known_command("hello")
|
| 120 |
+
assert registry.known_command("hi")
|
| 121 |
+
assert not registry.known_command("nope")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class TestDispatch:
|
| 125 |
+
def test_command(self, registry):
|
| 126 |
+
reply = registry.dispatch(make_ctx(registry, command="hello", args="there"))
|
| 127 |
+
assert reply.text == "hello there"
|
| 128 |
+
|
| 129 |
+
def test_alias(self, registry):
|
| 130 |
+
reply = registry.dispatch(make_ctx(registry, command="hi"))
|
| 131 |
+
assert reply.text == "hello world"
|
| 132 |
+
|
| 133 |
+
def test_unknown_command_is_reported(self, registry):
|
| 134 |
+
reply = registry.dispatch(make_ctx(registry, command="nonsense"))
|
| 135 |
+
assert "don't know" in reply.text
|
| 136 |
+
|
| 137 |
+
def test_plain_message_routes_to_a_message_handler(self, registry):
|
| 138 |
+
reply = registry.dispatch(make_ctx(registry, text="say cheese"))
|
| 139 |
+
assert reply.text == "cheese"
|
| 140 |
+
|
| 141 |
+
def test_declined_message_returns_none(self, registry):
|
| 142 |
+
assert registry.dispatch(make_ctx(registry, text="unhandled")) is None
|
| 143 |
+
|
| 144 |
+
def test_attachment_routes_to_attachment_handler(self, registry):
|
| 145 |
+
attachment = Attachment(data=b"x", filename="a.csv")
|
| 146 |
+
reply = registry.dispatch(make_ctx(registry, attachment=attachment))
|
| 147 |
+
assert reply.text == "got a.csv"
|
| 148 |
+
|
| 149 |
+
def test_a_failing_feature_does_not_crash_the_bot(self, registry):
|
| 150 |
+
registry.register(Exploder())
|
| 151 |
+
reply = registry.dispatch(make_ctx(registry, command="boom"))
|
| 152 |
+
assert "went wrong" in reply.text
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class TestHelpGeneration:
|
| 156 |
+
def test_help_lists_visible_commands(self, registry):
|
| 157 |
+
text = registry.help_text()
|
| 158 |
+
assert "/hello" in text
|
| 159 |
+
assert "Greetings" in text
|
| 160 |
+
|
| 161 |
+
def test_help_omits_hidden_commands(self, registry):
|
| 162 |
+
assert "/secret" not in registry.help_text()
|
| 163 |
+
|
| 164 |
+
def test_telegram_commands_payload(self, registry):
|
| 165 |
+
payload = registry.telegram_commands()
|
| 166 |
+
names = {c["command"] for c in payload}
|
| 167 |
+
assert "hello" in names
|
| 168 |
+
assert "secret" not in names
|
| 169 |
+
assert all(len(c["description"]) <= 256 for c in payload)
|
| 170 |
+
|
| 171 |
+
def test_help_escapes_html(self):
|
| 172 |
+
class Sneaky(SimpleFeature):
|
| 173 |
+
name = "sneaky"
|
| 174 |
+
description = "<b>bold</b>"
|
| 175 |
+
|
| 176 |
+
def commands(self):
|
| 177 |
+
return (CommandSpec("x", "a & b"),)
|
| 178 |
+
|
| 179 |
+
reg = FeatureRegistry()
|
| 180 |
+
reg.register(Sneaky())
|
| 181 |
+
text = reg.help_text()
|
| 182 |
+
assert "<b>bold</b>" in text
|
| 183 |
+
assert "a & b" in text
|
tests/test_store.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from finbot.config import Settings
|
| 6 |
+
from finbot.models import Budget, Expense
|
| 7 |
+
from finbot.store import Store
|
| 8 |
+
|
| 9 |
+
TODAY = date(2026, 8, 15)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def store(tmp_path):
|
| 14 |
+
settings = Settings(
|
| 15 |
+
data_dir=str(tmp_path),
|
| 16 |
+
hf_dataset_repo="", # sync off: no network in tests
|
| 17 |
+
hf_token="",
|
| 18 |
+
telegram_bot_token="",
|
| 19 |
+
telegram_webhook_secret="",
|
| 20 |
+
allowed_telegram_user_ids="",
|
| 21 |
+
)
|
| 22 |
+
store = Store(settings)
|
| 23 |
+
yield store
|
| 24 |
+
store.close()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def make(amount=25000, category="food_dining", day=TODAY, merchant="", batch=""):
|
| 28 |
+
return Expense(
|
| 29 |
+
user_id=1,
|
| 30 |
+
amount_minor=amount,
|
| 31 |
+
currency="INR",
|
| 32 |
+
description="coffee",
|
| 33 |
+
category=category,
|
| 34 |
+
occurred_on=day,
|
| 35 |
+
merchant=merchant,
|
| 36 |
+
batch_id=batch,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class TestExpenses:
|
| 41 |
+
def test_add_and_read_back(self, store):
|
| 42 |
+
expense = store.add_expense(make())
|
| 43 |
+
rows = store.list_expenses(1)
|
| 44 |
+
assert len(rows) == 1
|
| 45 |
+
assert rows[0].id == expense.id
|
| 46 |
+
assert rows[0].amount_minor == 25000
|
| 47 |
+
assert rows[0].occurred_on == TODAY
|
| 48 |
+
|
| 49 |
+
def test_isolated_per_user(self, store):
|
| 50 |
+
store.add_expense(make())
|
| 51 |
+
other = make()
|
| 52 |
+
other.user_id = 2
|
| 53 |
+
store.add_expense(other)
|
| 54 |
+
assert len(store.list_expenses(1)) == 1
|
| 55 |
+
assert len(store.list_expenses(2)) == 1
|
| 56 |
+
|
| 57 |
+
def test_date_filtering(self, store):
|
| 58 |
+
store.add_expense(make(day=date(2026, 8, 1)))
|
| 59 |
+
store.add_expense(make(day=date(2026, 7, 1)))
|
| 60 |
+
rows = store.list_expenses(1, date(2026, 8, 1), date(2026, 8, 31))
|
| 61 |
+
assert len(rows) == 1
|
| 62 |
+
|
| 63 |
+
def test_category_filtering(self, store):
|
| 64 |
+
store.add_expense(make(category="rent"))
|
| 65 |
+
store.add_expense(make(category="food_dining"))
|
| 66 |
+
assert len(store.list_expenses(1, category="rent")) == 1
|
| 67 |
+
|
| 68 |
+
def test_limit(self, store):
|
| 69 |
+
for _ in range(5):
|
| 70 |
+
store.add_expense(make())
|
| 71 |
+
assert len(store.list_expenses(1, limit=3)) == 3
|
| 72 |
+
|
| 73 |
+
def test_last_expense(self, store):
|
| 74 |
+
store.add_expense(make(amount=100))
|
| 75 |
+
latest = store.add_expense(make(amount=999))
|
| 76 |
+
assert store.last_expense(1).id == latest.id
|
| 77 |
+
|
| 78 |
+
def test_last_expense_none_when_empty(self, store):
|
| 79 |
+
assert store.last_expense(1) is None
|
| 80 |
+
|
| 81 |
+
def test_delete(self, store):
|
| 82 |
+
expense = store.add_expense(make())
|
| 83 |
+
assert store.delete_expense(1, expense.id) is True
|
| 84 |
+
assert store.list_expenses(1) == []
|
| 85 |
+
|
| 86 |
+
def test_delete_wrong_user_is_a_no_op(self, store):
|
| 87 |
+
expense = store.add_expense(make())
|
| 88 |
+
assert store.delete_expense(999, expense.id) is False
|
| 89 |
+
assert len(store.list_expenses(1)) == 1
|
| 90 |
+
|
| 91 |
+
def test_delete_batch(self, store):
|
| 92 |
+
for _ in range(3):
|
| 93 |
+
store.add_expense(make(batch="batch-1"))
|
| 94 |
+
store.add_expense(make(batch=""))
|
| 95 |
+
assert store.delete_batch(1, "batch-1") == 3
|
| 96 |
+
assert len(store.list_expenses(1)) == 1
|
| 97 |
+
|
| 98 |
+
def test_delete_batch_ignores_empty_id(self, store):
|
| 99 |
+
store.add_expense(make(batch=""))
|
| 100 |
+
assert store.delete_batch(1, "") == 0
|
| 101 |
+
|
| 102 |
+
def test_count(self, store):
|
| 103 |
+
store.add_expense(make())
|
| 104 |
+
store.add_expense(make())
|
| 105 |
+
assert store.count(1) == 2
|
| 106 |
+
assert store.count() == 2
|
| 107 |
+
|
| 108 |
+
def test_currencies_used(self, store):
|
| 109 |
+
store.add_expense(make())
|
| 110 |
+
usd = make()
|
| 111 |
+
usd.currency = "USD"
|
| 112 |
+
store.add_expense(usd)
|
| 113 |
+
assert set(store.currencies_used(1)) == {"INR", "USD"}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class TestDedupe:
|
| 117 |
+
def test_matching_transactions_share_a_key(self, store):
|
| 118 |
+
a = make(merchant="Swiggy")
|
| 119 |
+
b = make(merchant="swiggy!") # different case and punctuation
|
| 120 |
+
assert a.dedupe_key == b.dedupe_key
|
| 121 |
+
|
| 122 |
+
def test_key_changes_with_amount(self, store):
|
| 123 |
+
assert make(amount=100).dedupe_key != make(amount=200).dedupe_key
|
| 124 |
+
|
| 125 |
+
def test_key_changes_with_date(self, store):
|
| 126 |
+
assert make(day=date(2026, 8, 1)).dedupe_key != make(day=date(2026, 8, 2)).dedupe_key
|
| 127 |
+
|
| 128 |
+
def test_existing_keys_lookup(self, store):
|
| 129 |
+
expense = store.add_expense(make(merchant="Swiggy"))
|
| 130 |
+
found = store.existing_dedupe_keys(1, [expense.dedupe_key, "nope"])
|
| 131 |
+
assert found == {expense.dedupe_key}
|
| 132 |
+
|
| 133 |
+
def test_existing_keys_empty_input(self, store):
|
| 134 |
+
assert store.existing_dedupe_keys(1, []) == set()
|
| 135 |
+
|
| 136 |
+
def test_duplicates_are_still_insertable(self, store):
|
| 137 |
+
# Two identical coffees on one day are both real -- dedupe is advisory
|
| 138 |
+
# at import time, not a database constraint.
|
| 139 |
+
store.add_expense(make())
|
| 140 |
+
store.add_expense(make())
|
| 141 |
+
assert store.count(1) == 2
|
| 142 |
+
|
| 143 |
+
def test_chunks_large_key_lists(self, store):
|
| 144 |
+
expense = store.add_expense(make())
|
| 145 |
+
keys = [f"k{i}" for i in range(1200)] + [expense.dedupe_key]
|
| 146 |
+
assert store.existing_dedupe_keys(1, keys) == {expense.dedupe_key}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class TestBudgets:
|
| 150 |
+
def test_set_and_get(self, store):
|
| 151 |
+
store.set_budget(Budget(1, "food_dining", 100000, "INR"))
|
| 152 |
+
budgets = store.get_budgets(1)
|
| 153 |
+
assert len(budgets) == 1
|
| 154 |
+
assert budgets[0].amount_minor == 100000
|
| 155 |
+
|
| 156 |
+
def test_upsert_replaces(self, store):
|
| 157 |
+
store.set_budget(Budget(1, "food_dining", 100000, "INR"))
|
| 158 |
+
store.set_budget(Budget(1, "food_dining", 250000, "INR"))
|
| 159 |
+
budgets = store.get_budgets(1)
|
| 160 |
+
assert len(budgets) == 1
|
| 161 |
+
assert budgets[0].amount_minor == 250000
|
| 162 |
+
|
| 163 |
+
def test_delete(self, store):
|
| 164 |
+
store.set_budget(Budget(1, "rent", 100, "INR"))
|
| 165 |
+
assert store.delete_budget(1, "rent") is True
|
| 166 |
+
assert store.get_budgets(1) == []
|
| 167 |
+
|
| 168 |
+
def test_delete_missing(self, store):
|
| 169 |
+
assert store.delete_budget(1, "rent") is False
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class TestPersistence:
|
| 173 |
+
def test_survives_reopen(self, tmp_path):
|
| 174 |
+
settings = Settings(
|
| 175 |
+
data_dir=str(tmp_path),
|
| 176 |
+
hf_dataset_repo="",
|
| 177 |
+
hf_token="",
|
| 178 |
+
telegram_bot_token="",
|
| 179 |
+
telegram_webhook_secret="",
|
| 180 |
+
)
|
| 181 |
+
first = Store(settings)
|
| 182 |
+
first.add_expense(make())
|
| 183 |
+
first.close()
|
| 184 |
+
|
| 185 |
+
second = Store(settings)
|
| 186 |
+
assert second.count(1) == 1
|
| 187 |
+
second.close()
|
| 188 |
+
|
| 189 |
+
def test_sync_disabled_without_config(self, store):
|
| 190 |
+
assert store.sync_status == "disabled"
|
tests/test_telegram.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from finbot.telegram import SeenUpdates, chunk_message, parse_update
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def message_update(update_id=1, **message):
|
| 5 |
+
base = {"from": {"id": 42}, "chat": {"id": 99}}
|
| 6 |
+
base.update(message)
|
| 7 |
+
return {"update_id": update_id, "message": base}
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestParseUpdate:
|
| 11 |
+
def test_plain_text(self):
|
| 12 |
+
parsed = parse_update(message_update(text="coffee 250"))
|
| 13 |
+
assert parsed.user_id == 42
|
| 14 |
+
assert parsed.chat_id == 99
|
| 15 |
+
assert parsed.text == "coffee 250"
|
| 16 |
+
assert parsed.command == ""
|
| 17 |
+
|
| 18 |
+
def test_command_with_args(self):
|
| 19 |
+
parsed = parse_update(message_update(text="/report last_month"))
|
| 20 |
+
assert parsed.command == "report"
|
| 21 |
+
assert parsed.args == "last_month"
|
| 22 |
+
|
| 23 |
+
def test_bare_command(self):
|
| 24 |
+
parsed = parse_update(message_update(text="/month"))
|
| 25 |
+
assert parsed.command == "month"
|
| 26 |
+
assert parsed.args == ""
|
| 27 |
+
|
| 28 |
+
def test_command_is_lowercased(self):
|
| 29 |
+
assert parse_update(message_update(text="/MONTH")).command == "month"
|
| 30 |
+
|
| 31 |
+
def test_group_mention_is_stripped(self):
|
| 32 |
+
parsed = parse_update(message_update(text="/month@FinBot"), bot_username="FinBot")
|
| 33 |
+
assert parsed.command == "month"
|
| 34 |
+
|
| 35 |
+
def test_mention_of_another_bot_is_ignored(self):
|
| 36 |
+
assert parse_update(message_update(text="/month@OtherBot"), bot_username="FinBot") is None
|
| 37 |
+
|
| 38 |
+
def test_photo_picks_the_largest_size(self):
|
| 39 |
+
parsed = parse_update(
|
| 40 |
+
message_update(
|
| 41 |
+
photo=[
|
| 42 |
+
{"file_id": "small", "file_size": 100},
|
| 43 |
+
{"file_id": "large", "file_size": 9000},
|
| 44 |
+
],
|
| 45 |
+
caption="lunch",
|
| 46 |
+
)
|
| 47 |
+
)
|
| 48 |
+
assert parsed.file_id == "large"
|
| 49 |
+
assert parsed.file_kind == "photo"
|
| 50 |
+
assert parsed.mime_type == "image/jpeg"
|
| 51 |
+
assert parsed.caption == "lunch"
|
| 52 |
+
assert parsed.has_file
|
| 53 |
+
|
| 54 |
+
def test_document(self):
|
| 55 |
+
parsed = parse_update(
|
| 56 |
+
message_update(
|
| 57 |
+
document={
|
| 58 |
+
"file_id": "doc1",
|
| 59 |
+
"file_name": "statement.pdf",
|
| 60 |
+
"mime_type": "application/pdf",
|
| 61 |
+
"file_size": 2048,
|
| 62 |
+
}
|
| 63 |
+
)
|
| 64 |
+
)
|
| 65 |
+
assert parsed.file_kind == "document"
|
| 66 |
+
assert parsed.file_name == "statement.pdf"
|
| 67 |
+
assert parsed.file_size == 2048
|
| 68 |
+
|
| 69 |
+
def test_edited_message_is_handled(self):
|
| 70 |
+
update = {"update_id": 5, "edited_message": {"from": {"id": 42}, "chat": {"id": 99}, "text": "x"}}
|
| 71 |
+
assert parse_update(update).text == "x"
|
| 72 |
+
|
| 73 |
+
def test_non_message_updates_are_skipped(self):
|
| 74 |
+
assert parse_update({"update_id": 1, "poll": {}}) is None
|
| 75 |
+
|
| 76 |
+
def test_malformed_update_is_skipped(self):
|
| 77 |
+
assert parse_update({"update_id": 1, "message": {"text": "hi"}}) is None
|
| 78 |
+
assert parse_update({}) is None
|
| 79 |
+
|
| 80 |
+
def test_no_file_means_no_file(self):
|
| 81 |
+
assert not parse_update(message_update(text="hi")).has_file
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class TestChunkMessage:
|
| 85 |
+
def test_short_message_is_untouched(self):
|
| 86 |
+
assert chunk_message("hello") == ["hello"]
|
| 87 |
+
|
| 88 |
+
def test_splits_on_line_boundaries(self):
|
| 89 |
+
text = "\n".join(f"line {i}" for i in range(500))
|
| 90 |
+
chunks = chunk_message(text, limit=200)
|
| 91 |
+
assert len(chunks) > 1
|
| 92 |
+
assert all(len(c) <= 200 for c in chunks)
|
| 93 |
+
# No content lost.
|
| 94 |
+
assert "\n".join(chunks) == text
|
| 95 |
+
|
| 96 |
+
def test_never_splits_mid_line(self):
|
| 97 |
+
chunks = chunk_message("aaa\nbbb\nccc", limit=8)
|
| 98 |
+
for chunk in chunks:
|
| 99 |
+
for line in chunk.split("\n"):
|
| 100 |
+
assert line in ("aaa", "bbb", "ccc")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class TestSeenUpdates:
|
| 104 |
+
def test_first_time_is_new(self):
|
| 105 |
+
seen = SeenUpdates()
|
| 106 |
+
assert seen.add_if_new(1) is True
|
| 107 |
+
|
| 108 |
+
def test_redelivery_is_rejected(self):
|
| 109 |
+
seen = SeenUpdates()
|
| 110 |
+
seen.add_if_new(1)
|
| 111 |
+
assert seen.add_if_new(1) is False
|
| 112 |
+
|
| 113 |
+
def test_evicts_oldest_beyond_capacity(self):
|
| 114 |
+
seen = SeenUpdates(capacity=3)
|
| 115 |
+
for i in range(5):
|
| 116 |
+
seen.add_if_new(i)
|
| 117 |
+
# 0 and 1 were evicted, so they read as new again.
|
| 118 |
+
assert seen.add_if_new(0) is True
|
| 119 |
+
assert seen.add_if_new(4) is False
|