Spaces:
Running on Zero
Running on Zero
| """BudgetBuddy — gradio.Server backend (v2). | |
| Custom dark frontend (frontend/) over JSON API endpoints that wrap the on-device | |
| small models: MiniCPM-V-4.6 vision on the Space's ZeroGPU, MiniCPM4.1-8B text on | |
| Modal (core.inference routes each). Flexible transaction model, an understanding | |
| agent, per-item analytics, budgets, and a tool-using chat agent. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import io | |
| import json | |
| from datetime import date | |
| from pathlib import Path | |
| import gradio as gr | |
| from fastapi.responses import HTMLResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from core.extract import extract_receipt, extract_payment, reconcile | |
| from core.categorize import CATEGORIES, understand | |
| from core import storage, analytics, auth, agent | |
| FRONTEND = Path(__file__).parent / "frontend" | |
| app = gr.Server() | |
| _PERSIST_KEYS = ["vendor", "date", "currency", "line_items", "charges", "total", | |
| "category", "understanding", "note", "source"] | |
| # --------------------------------------------------------------------------- # | |
| # Helpers | |
| # --------------------------------------------------------------------------- # | |
| def _open_image(image_b64: str): | |
| from PIL import Image | |
| if not image_b64: | |
| return None | |
| if "," in image_b64 and image_b64.strip().lower().startswith("data:"): | |
| image_b64 = image_b64.split(",", 1)[1] | |
| return Image.open(io.BytesIO(base64.b64decode(image_b64))).convert("RGB") | |
| def _loads(s, default): | |
| if isinstance(s, (dict, list)): | |
| return s | |
| try: | |
| return json.loads(s) if s else default | |
| except Exception: | |
| return default | |
| def _clean(record: dict) -> dict: | |
| out = {k: record.get(k) for k in _PERSIST_KEYS if k in record} | |
| out.setdefault("line_items", record.get("line_items") or []) | |
| out.setdefault("charges", record.get("charges") or []) | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # Auth | |
| # --------------------------------------------------------------------------- # | |
| def signup(username: str, pin: str) -> dict: | |
| ok, msg = auth.signup(username, pin) | |
| return {"ok": ok, "message": msg, | |
| "token": auth.issue_token(username) if ok else "", | |
| "user": auth.normalize_username(username) if ok else ""} | |
| def login(username: str, pin: str) -> dict: | |
| ok, msg = auth.login(username, pin) | |
| return {"ok": ok, "message": msg, | |
| "token": auth.issue_token(username) if ok else "", | |
| "user": auth.normalize_username(username) if ok else ""} | |
| # --------------------------------------------------------------------------- # | |
| # Capture | |
| # --------------------------------------------------------------------------- # | |
| def api_extract_receipt(image_b64: str) -> dict: | |
| rec = extract_receipt(_open_image(image_b64)) | |
| rec["reconcile"] = reconcile(rec) | |
| return rec | |
| def api_extract_payment(image_b64: str) -> dict: | |
| return extract_payment(_open_image(image_b64)) | |
| def api_understand(record_json: str) -> dict: | |
| """The 8B reasons over the bill: per-item + overall categories + summary.""" | |
| record = _loads(record_json, {}) | |
| record.setdefault("source", "receipt") | |
| try: | |
| record = understand(record) | |
| except Exception as e: # pragma: no cover | |
| print(f"[app] understand failed: {e}") | |
| record.setdefault("category", "Other") | |
| return {"record": record, "reconcile": reconcile(record), | |
| "uncertain": record.get("_uncertain") or [], | |
| "understanding": record.get("understanding", "")} | |
| # --------------------------------------------------------------------------- # | |
| # Data (token-scoped) | |
| # --------------------------------------------------------------------------- # | |
| def api_save(token: str, record_json: str) -> dict: | |
| user = auth.verify_token(token) | |
| if not user: | |
| return {"ok": False, "message": "Please sign in again."} | |
| record = _clean(_loads(record_json, {})) | |
| if analytics._num(record.get("total", 0)) <= 0 and not record.get("line_items"): | |
| return {"ok": False, "message": "Nothing to save — add an amount first."} | |
| try: | |
| storage.save(user, record) | |
| return {"ok": True, "message": "Saved.", | |
| "count": len(storage.load(user, force=True))} | |
| except Exception as e: | |
| return {"ok": False, "message": f"Save failed: {e}"} | |
| def _txn_dict(r: dict) -> dict: | |
| return {"date": r.get("date", ""), "vendor": r.get("vendor", ""), | |
| "total": analytics._num(r.get("total")), "category": analytics._category(r), | |
| "source": r.get("source", "receipt"), "understanding": r.get("understanding", ""), | |
| "currency": r.get("currency", ""), | |
| "items": [{"name": str(it.get("name", "")), "amount": analytics._num(it.get("amount")), | |
| "qty": analytics._num(it.get("qty", 1)) or 1, | |
| "category": str(it.get("category") or analytics._category(r))} | |
| for it in (r.get("line_items") or [])], | |
| "charges": [{"label": str(ch.get("label", "")), "amount": analytics._num(ch.get("amount"))} | |
| for ch in (r.get("charges") or [])]} | |
| def _sort_recent(records): | |
| return sorted(records, key=lambda r: (analytics.parse_date(r.get("date")) or date.min, | |
| str(r.get("saved_at", ""))), reverse=True) | |
| def api_dashboard(token: str, start: str = "", end: str = "", category: str = "All", | |
| granularity: str = "Monthly", cal_month: str = "") -> dict: | |
| user = auth.verify_token(token) | |
| if not user: | |
| return {"ok": False, "error": "auth"} | |
| try: | |
| records = storage.load(user, force=True) | |
| summ = analytics.summary(records) | |
| cat = None if category in ("", "All", None) else category | |
| filt = analytics.filter_records( | |
| records, analytics.parse_date(start), analytics.parse_date(end), cat) | |
| today = date.today() | |
| try: | |
| cy, cm = (int(x) for x in cal_month.split("-")) if cal_month else (today.year, today.month) | |
| except Exception: | |
| cy, cm = today.year, today.month | |
| return { | |
| "ok": True, | |
| "summary": summ, | |
| "budget": storage.get_budget(user), | |
| "by_category": analytics.spend_by_category(filt), | |
| "over_time": analytics.spend_over_time(filt, granularity or "Monthly"), | |
| "comparison": analytics.category_comparison(records), | |
| "calendar": analytics.calendar_data(records, cy, cm), | |
| "calendar_month": f"{cy}-{cm:02d}", | |
| "transactions": [_txn_dict(r) for r in _sort_recent(filt)], | |
| "recent": [_txn_dict(r) for r in _sort_recent(records)[:8]], | |
| "has_any": len(records) > 0, | |
| } | |
| except Exception as e: | |
| import traceback | |
| print(f"[app] dashboard failed: {e}\n{traceback.format_exc()[-800:]}") | |
| return {"ok": False, "error": str(e)} | |
| def api_set_budget(token: str, amount: float) -> dict: | |
| user = auth.verify_token(token) | |
| if not user: | |
| return {"ok": False} | |
| try: | |
| return {"ok": True, "budget": storage.set_budget(user, amount)} | |
| except Exception as e: | |
| return {"ok": False, "message": str(e)} | |
| def api_chat(token: str, message: str, history_json: str = "") -> dict: | |
| user = auth.verify_token(token) | |
| if not user: | |
| return {"reply": "Please sign in again.", "trace": []} | |
| records = storage.load(user, force=True) | |
| try: | |
| budget = storage.get_budget(user) | |
| except Exception: | |
| budget = 0.0 | |
| result = agent.run(message, records, budget=budget) | |
| return {"reply": result["reply"], "trace": result.get("trace", [])} | |
| def api_categories() -> dict: | |
| return {"categories": CATEGORIES} | |
| # --------------------------------------------------------------------------- # | |
| # Static custom frontend | |
| # --------------------------------------------------------------------------- # | |
| if (FRONTEND / "assets").exists(): | |
| app.mount("/assets", StaticFiles(directory=str(FRONTEND / "assets")), name="assets") | |
| async def home(): | |
| html = (FRONTEND / "index.html").read_text(encoding="utf-8") | |
| try: | |
| v = int((FRONTEND / "assets" / "app.js").stat().st_mtime) | |
| except Exception: | |
| v = 1 | |
| return html.replace("/assets/app.js", f"/assets/app.js?v={v}") | |
| if __name__ == "__main__": | |
| app.launch(show_error=True) | |