BudgetBuddy / core /extract.py
KrishnaGarg's picture
Deploy BudgetBuddy update
9732d5f verified
Raw
History Blame Contribute Delete
15.2 kB
"""Receipt + payment extraction (MiniCPM-V-4.6, via core.inference).
v2 data model — a transaction is flexible, so real bills fit without hardcoding:
{
"vendor": str, "date": "YYYY-MM-DD"|"", "currency": str,
"line_items": [ {"name": str, "qty": number, "amount": number, "category": str} ],
"charges": [ {"label": str, "amount": number} ], # ANY taxes/fees/discount/round-off
"total": number, "category": str, "note": str, "source": str
}
Reconciliation: sum(line_items.amount) + sum(charges.amount) ≈ total.
(Discounts and round-downs are negative charge amounts.)
"""
from __future__ import annotations
import json
import re
from typing import Any
from core.analytics import parse_date as _parse_date
from core import inference
gpu_decorator = inference.gpu_decorator
MAX_NEW_TOKENS = 1024
# --------------------------------------------------------------------------- #
# Prompts
# --------------------------------------------------------------------------- #
SYSTEM_PROMPT = (
"You are an expert receipt and bill reader for a budgeting app. Real bills "
"are messy and varied: missing totals, several tax lines (e.g. SGST and CGST "
"separately), service charges, tips, discounts, manual round-off, and mixed "
"items. Read EVERYTHING carefully and miss nothing.\n"
"Return ONLY one valid JSON object — no prose, no markdown — EXACTLY:\n"
"{\n"
' "vendor": string, // merchant/store name, "" if unknown\n'
' "date": string, // bill date as YYYY-MM-DD (convert any format), "" if none\n'
' "currency": string, // code or symbol seen, e.g. "INR","₹","USD","$"\n'
' "line_items": [ // EVERY purchased item row, in order\n'
' { "name": string, "qty": number, "amount": number } // amount = line total\n'
" ],\n"
' "charges": [ // EVERY non-item money line, each separately\n'
' { "label": string, "amount": number } // e.g. {"label":"SGST 9%","amount":31.95}\n'
" ],\n"
' "total": number // grand total actually payable\n'
"}\n"
"Rules:\n"
"- line_items are the REAL things bought. Do NOT put subtotal, total, grand "
"total, net amount, amount payable, balance, change/tendered, or 'amount in "
"words' in line_items — those summarise the bill; the payable one is `total`.\n"
"- Never repeat the same row twice. Each item appears once.\n"
"- Capture EACH tax line as its OWN charges entry (do NOT merge SGST + CGST).\n"
"- Service charge, tip → positive charges. Discount, round-down → NEGATIVE amount.\n"
"- A round-off that lowers the total is a charge like {\"label\":\"Round off\",\"amount\":-0.40}.\n"
"- Utility/telecom bills: the CONSUMPTION lines (energy charges, fixed/demand "
"charges, fuel surcharge, data/talktime) ARE line_items. Only government taxes/"
"duties (electricity duty, GST) go in charges.\n"
"- Numbers are plain (no symbols/commas), dot decimal. qty defaults to 1.\n"
"- If no total is printed, COMPUTE it = sum(line_items) + sum(charges).\n"
"- Check: sum(line_items) + sum(charges) must equal total. If not, re-examine "
"ONCE for missed items, missed tax lines, or misplaced decimals, then fix.\n"
"Output the JSON object and nothing else."
)
USER_PROMPT = "Extract this receipt/bill into the required JSON. Return ONLY the JSON object."
RETRY_PROMPT = (
"Your previous answer was not valid JSON in the required schema. Return ONLY a "
"single JSON object with keys: vendor (string), date (string), currency (string), "
"line_items (array of {name, qty, amount}), charges (array of {label, amount}), "
"total (number). Capture every item and every tax line separately. No markdown."
)
PAYMENT_SYSTEM_PROMPT = (
"You read a screenshot of one digital payment (UPI, GPay, PhonePe, card, or a "
"bank app). Extract that single transaction. Return ONLY one JSON object — no "
"prose, no markdown — EXACTLY:\n"
"{\n"
' "vendor": string, // payee / merchant / person paid, "" if unknown\n'
' "date": string, // payment date as YYYY-MM-DD, "" if none\n'
' "currency": string, // code or symbol, e.g. "INR","₹","$"\n'
' "amount": number, // amount paid (positive plain number)\n'
' "note": string // any reference/description shown, "" if none\n'
"}\n"
"Numbers are plain (no symbols/commas), dot decimal. Output only the JSON."
)
PAYMENT_USER_PROMPT = "Extract this payment screenshot into the required JSON. Return ONLY the JSON object."
EMPTY_RESULT: dict[str, Any] = {
"vendor": "", "date": "", "currency": "",
"line_items": [], "charges": [], "total": 0,
}
RECON_ABS_TOL = 0.5
RECON_PCT_TOL = 0.01
# --------------------------------------------------------------------------- #
# Parsing helpers
# --------------------------------------------------------------------------- #
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE)
def _strip_fences(text: str) -> str:
m = _FENCE_RE.search(text)
return m.group(1).strip() if m else text.strip()
def _extract_json_object(text: str) -> str:
start = text.find("{")
if start == -1:
return text
depth = 0
for i in range(start, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return text[start:]
def _coerce_number(value: Any) -> float:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
cleaned = re.sub(r"[^0-9.\-]", "", value.replace(",", ""))
try:
return float(cleaned) if cleaned not in ("", "-", ".") else 0.0
except ValueError:
return 0.0
return 0.0
def _normalize_date(value: Any) -> str:
d = _parse_date(value)
return d.isoformat() if d else ""
def _coerce_charge(ch: Any) -> dict[str, Any] | None:
"""Accept {'label','amount'} or {'<label>': <amount>}; return {label, amount}."""
if not isinstance(ch, dict):
return None
if "amount" in ch or "label" in ch:
return {"label": str(ch.get("label", "") or "Charge"),
"amount": _coerce_number(ch.get("amount", 0))}
# single-pair form
for k, v in ch.items():
return {"label": str(k), "amount": _coerce_number(v)}
return None
def _validate(data: Any) -> dict[str, Any]:
if not isinstance(data, dict):
raise ValueError("Top-level JSON is not an object")
result: dict[str, Any] = {
"vendor": str(data.get("vendor", "") or ""),
"date": _normalize_date(data.get("date", "")),
"currency": str(data.get("currency", "") or ""),
"line_items": [],
"charges": [],
"total": _coerce_number(data.get("total", 0)),
}
for it in data.get("line_items", []) or []:
if isinstance(it, dict):
result["line_items"].append({
"name": str(it.get("name", "") or ""),
"qty": _coerce_number(it.get("qty", 1)) or 1,
"amount": _coerce_number(it.get("amount", 0)),
})
for ch in data.get("charges", []) or []:
c = _coerce_charge(ch)
if c and (c["label"] or c["amount"]):
result["charges"].append(c)
return result
# --------------------------------------------------------------------------- #
# Deterministic cleanup — understand a literal extraction (no model, instant)
# --------------------------------------------------------------------------- #
# A row whose NAME is a bill summary, not a purchased item (drop from line_items;
# its value is a candidate grand total). Matches names made up ENTIRELY of summary
# words, so multi-word labels ("Total Amount Payable") are caught while brand/item
# names with other words ("Total Wireless Recharge") are NOT.
_SUMMARY_RE = re.compile(
r"^\s*(?:(?:sub|grand|final|net|nett|gross|total|amount|payable|balance|due|"
r"value|bill|invoice|qty|items?|to|pay|in|words|tendered|change|cash|paid)"
r"\b[\s:./\-]*)+$", re.I)
# A 'charge'-named line that is actually a real consumption ITEM (utility/telecom).
# Checked BEFORE the tax/fee test so "energy charges" stays an item.
_ITEM_KEEP_RE = re.compile(
r"\b(energy|fixed|demand|wheeling|meter|consumption|sanction|connection|"
r"fuel\s*(?:adj\w*|surcharge|charge)|fppca|fpppa|\bfac\b|"
r"data|talktime|talk\s*time|usage|rental|plan|pack|recharge)\b", re.I)
# A line that belongs in `charges` (tax / statutory / service fee), never an item.
_TAXFEE_RE = re.compile(
r"\b(c?gst|sgst|igst|utgst|\bvat\b|sales\s*tax|service\s*tax|service\s*charge|"
r"svc\s*charge|\bcess\b|\btcs\b|\btds\b|octroi|electricity\s*duty|\bduty\b|"
r"delivery|packing|packag\w*|conveni\w*|handling|platform\s*fee|"
r"gratuity|\btip\b|round[\s-]*off|rounding|\bdiscount\b|cashback|coupon|"
r"\bsavings?\b|\bsurcharge\b|\bfee\b|\btax\b)\b", re.I)
def _row_kind(name: str) -> str:
n = name or ""
if _SUMMARY_RE.match(n):
return "summary"
if _ITEM_KEEP_RE.search(n):
return "item"
if _TAXFEE_RE.search(n):
return "charge"
return "item"
def clean_extraction(record: dict[str, Any]) -> dict[str, Any]:
"""Turn a literal OCR extraction into a coherent transaction — instantly, with
no model call. Drops summary/total rows mis-read as items, moves tax/fee rows
out of line_items into charges (while keeping utility consumption rows as
items), de-duplicates, and recovers a sensible total. Idempotent."""
rec = dict(record)
items_in = rec.get("line_items") or []
charges_in = rec.get("charges") or []
kept_items: list[dict[str, Any]] = []
moved_charges: list[dict[str, Any]] = []
clean_charges: list[dict[str, Any]] = []
total_candidates: list[float] = []
seen: set[tuple[str, float]] = set()
for it in items_in:
name = str(it.get("name", "") or "").strip()
amount = _coerce_number(it.get("amount", 0))
kind = _row_kind(name)
if kind == "summary":
if amount:
total_candidates.append(amount)
continue
if kind == "charge":
moved_charges.append({"label": name or "Charge", "amount": amount})
continue
key = (name.lower(), round(amount, 2))
if name and key in seen: # drop an exact duplicate item row
continue
seen.add(key)
kept_items.append(it)
# Remove summary rows that landed in charges; keep genuine charges.
for c in charges_in:
label = str(c.get("label", "") or "").strip()
amount = _coerce_number(c.get("amount", 0))
if _SUMMARY_RE.match(label):
if amount:
total_candidates.append(amount)
continue
clean_charges.append({"label": label or "Charge", "amount": amount})
clean_charges.extend(moved_charges)
rec["line_items"] = kept_items
rec["charges"] = clean_charges
printed = _coerce_number(rec.get("total", 0))
computed = round(sum(_coerce_number(i.get("amount", 0)) for i in kept_items)
+ sum(_coerce_number(c.get("amount", 0)) for c in clean_charges), 2)
if printed <= 0:
# Prefer a printed grand-total we pulled off a summary row; else compute.
rec["total"] = max(total_candidates) if total_candidates else computed
return rec
# --------------------------------------------------------------------------- #
# Reconciliation
# --------------------------------------------------------------------------- #
def reconcile(record: dict[str, Any]) -> dict[str, Any]:
items = record.get("line_items") or []
total = _coerce_number(record.get("total", 0))
if not items:
return {"items_sum": 0.0, "charges_sum": 0.0, "expected_total": round(total, 2),
"total": round(total, 2), "gap": 0.0, "tolerance": 0.0, "ok": True,
"message": "No line items to reconcile."}
items_sum = sum(_coerce_number(it.get("amount", 0)) for it in items)
charges_sum = sum(_coerce_number(c.get("amount", 0)) for c in (record.get("charges") or []))
expected = round(items_sum + charges_sum, 2)
gap = round(total - expected, 2)
tolerance = max(RECON_ABS_TOL, RECON_PCT_TOL * abs(total))
ok = abs(gap) <= tolerance
message = (f"Items + charges = {expected:.2f} ≈ total {total:.2f}." if ok
else f"Items + charges = {expected:.2f}, but total reads {total:.2f} "
f"(off by {gap:+.2f}).")
return {"items_sum": round(items_sum, 2), "charges_sum": round(charges_sum, 2),
"expected_total": expected, "total": total, "gap": gap,
"tolerance": round(tolerance, 2), "ok": ok, "message": message}
def parse_receipt_json(text: str) -> dict[str, Any]:
return _validate(json.loads(_extract_json_object(_strip_fences(text))))
def parse_payment_json(text: str) -> dict[str, Any]:
data = json.loads(_extract_json_object(_strip_fences(text)))
if not isinstance(data, dict):
raise ValueError("Top-level JSON is not an object")
rec = dict(EMPTY_RESULT)
rec["line_items"], rec["charges"] = [], []
rec["vendor"] = str(data.get("vendor", "") or "")
rec["date"] = _normalize_date(data.get("date", ""))
rec["currency"] = str(data.get("currency", "") or "")
rec["total"] = _coerce_number(data.get("amount", 0))
rec["note"] = str(data.get("note", "") or "")
rec["source"] = "payment"
return rec
# --------------------------------------------------------------------------- #
# Inference (vision, via core.inference)
# --------------------------------------------------------------------------- #
def _run_model(image, prompt: str, system: str = SYSTEM_PROMPT) -> str:
return inference.vision_generate(image, system, prompt, MAX_NEW_TOKENS)
def extract_receipt(image) -> dict[str, Any]:
if image is None:
return {**EMPTY_RESULT, "line_items": [], "charges": [], "_error": "No image provided."}
for prompt in (USER_PROMPT, RETRY_PROMPT):
try:
return clean_extraction(parse_receipt_json(_run_model(image, prompt)))
except Exception as e:
print(f"[extract] receipt parse failed: {e}")
return {**EMPTY_RESULT, "line_items": [], "charges": [],
"_error": "Could not read the receipt clearly — please edit below."}
def extract_payment(image) -> dict[str, Any]:
if image is None:
return {**EMPTY_RESULT, "line_items": [], "charges": [], "source": "payment",
"note": "", "_error": "No image provided."}
for _ in range(2):
try:
return parse_payment_json(_run_model(image, PAYMENT_USER_PROMPT, PAYMENT_SYSTEM_PROMPT))
except Exception as e:
print(f"[extract] payment parse failed: {e}")
return {**EMPTY_RESULT, "line_items": [], "charges": [], "source": "payment",
"note": "", "_error": "Could not read the payment screenshot."}