BudgetBuddy / core /categorize.py
KrishnaGarg's picture
Deploy BudgetBuddy update
7d7d34f verified
Raw
History Blame Contribute Delete
5.89 kB
"""Understanding agent — the text model reasons over an extracted bill (Phase v2).
After the vision model extracts a bill, this step (MiniCPM4.1-8B via core.inference)
*understands* it: classifies the vendor, assigns a category to each line item and
an overall category, and writes a one-line summary. Deterministic repair fills a
missing total. Returns a clean, save-ready transaction + `_uncertain` flags so the
UI highlights only what (if anything) needs a human glance.
"""
from __future__ import annotations
import json
from typing import Any
from core import inference
from core.extract import reconcile, _coerce_number, _extract_json_object, _strip_fences
MAX_NEW_TOKENS = 384
CATEGORIES = [
"Groceries", "Dining", "Cafe", "Transport", "Fuel", "Utilities", "Rent",
"Shopping", "Clothing", "Electronics", "Health", "Pharmacy", "Personal Care",
"Entertainment", "Subscriptions", "Education", "Travel", "Telecom",
"Insurance", "Household", "Fees & Charges", "Gifts", "Other",
]
DEFAULT_CATEGORY = "Other"
_LOOKUP = {c.lower(): c for c in CATEGORIES}
SYSTEM_PROMPT = (
"You are a meticulous bookkeeping assistant for a personal budget tracker. "
"You are given a bill (vendor, line items, charges, total). Understand it and "
"categorise it. Use ONLY these categories:\n"
f"{', '.join(CATEGORIES)}.\n"
"Guidance: a restaurant / dhaba / food court bill is Dining; a coffee/tea shop "
"is Cafe; a supermarket / kirana / grocery store is Groceries; a chemist is "
"Pharmacy; petrol/diesel is Fuel; cab/bus/metro is Transport; a tailor/clothes "
"shop is Clothing. IMPORTANT: judge each item by the VENDOR's nature — at a "
"restaurant, dishes/drinks like 'Misal Pav', '2 Course', 'House Wine' are ALL "
"Dining (never Utilities/Bills/Health). Only use Utilities/Bills/Telecom/Rent "
"for actual utility, telecom, rent or bill-payment vendors. When unsure, match "
"the item to the overall category. Return ONLY one JSON object, no prose:\n"
"{\n"
' "category": "<overall category for the whole bill>",\n'
' "item_categories": ["<one category per line item, same order>"],\n'
' "summary": "<one short sentence, <=14 words, describing the bill>"\n'
"}\n"
"item_categories MUST have exactly one entry per line item, in order."
)
RETRY_SUFFIX = (
"\n\nReturn ONLY the JSON object: {\"category\": ..., \"item_categories\": [...], "
"\"summary\": ...} with one item category per line item, chosen from the allowed list."
)
def _normalize(value: Any) -> str:
if not isinstance(value, str):
return DEFAULT_CATEGORY
return _LOOKUP.get(value.strip().lower(), DEFAULT_CATEGORY)
def _build_prompt(record: dict[str, Any], items: list[dict[str, Any]]) -> str:
cur = record.get("currency", "") or ""
lines = [f"Vendor: {record.get('vendor','') or '(unknown)'}",
f"Total: {record.get('total', 0)} {cur}".strip(), ""]
lines.append("Line items:")
if items:
for i, it in enumerate(items, 1):
qty = it.get("qty", 1)
lines.append(f"{i}. {it.get('name','')} x{qty} = {it.get('amount',0)}")
else:
lines.append("(none — single payment)")
charges = record.get("charges") or []
if charges:
lines.append("Charges: " + ", ".join(
f"{c.get('label','')} {c.get('amount',0)}" for c in charges))
lines.append("")
lines.append(f"Give category + {len(items)} item_categories (in order) + summary.")
return "\n".join(lines)
def _run_model(prompt: str) -> str:
return inference.text_generate(
[{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt}],
max_new_tokens=MAX_NEW_TOKENS,
)
def _parse(text: str, n_items: int) -> dict[str, Any]:
data = json.loads(_extract_json_object(_strip_fences(text)))
if not isinstance(data, dict):
raise ValueError("not an object")
cats = [_normalize(c) for c in (data.get("item_categories") or [])]
if len(cats) < n_items:
cats += [DEFAULT_CATEGORY] * (n_items - len(cats))
cats = cats[:n_items]
return {
"category": _normalize(data.get("category")),
"item_categories": cats,
"summary": str(data.get("summary", "") or "").strip()[:160],
}
def understand(record: dict[str, Any]) -> dict[str, Any]:
"""Reason over an extracted bill → categorised, summarised, repaired transaction."""
rec = dict(record)
rec.setdefault("charges", [])
items = rec.get("line_items") or []
# Deterministic repair: compute a missing total from items + charges.
if items and _coerce_number(rec.get("total", 0)) == 0:
rec["total"] = reconcile(rec)["expected_total"]
parsed = None
base = _build_prompt(rec, items)
for prompt in (base, base + RETRY_SUFFIX):
try:
parsed = _parse(_run_model(prompt), len(items))
break
except Exception as e: # pragma: no cover - model dependent
print(f"[understand] parse failed: {e}")
if parsed is None:
parsed = {"category": DEFAULT_CATEGORY,
"item_categories": [DEFAULT_CATEGORY] * len(items), "summary": ""}
rec["category"] = parsed["category"]
rec["receipt_category"] = parsed["category"] # back-compat for storage/analytics
rec["line_items"] = [dict(it, category=c) for it, c in zip(items, parsed["item_categories"])]
rec["understanding"] = parsed["summary"]
recon = reconcile(rec)
unc: list[str] = []
if not str(rec.get("vendor", "")).strip():
unc.append("vendor")
if not str(rec.get("date", "")).strip():
unc.append("date")
if _coerce_number(rec.get("total", 0)) == 0:
unc.append("total")
if items and not recon["ok"]:
unc.append("total")
rec["_uncertain"] = sorted(set(unc))
return rec