"""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 {'