"""Receipt -> [{id, label, price}] via any OpenAI-compatible chat completions endpoint (works unmodified with OpenAI; point LLM_BASE_URL at Gemini's OpenAI-compat layer to use that instead, no code change needed). """ from __future__ import annotations import base64 import json import re import uuid import requests from .config import Config _PROMPT = """You are reading a photo of a shopping receipt. Extract every \ line item and its price, plus the store name and the date the receipt was \ issued. Respond with ONLY a JSON object, no prose, no markdown fences, shaped like: { "store_name": "", "date": "", "items": [ {"label": "", "price": } ] } - price is the item's price in the receipt's currency, as a plain number \ (no currency symbol). - Skip subtotal/tax/total lines, only real purchased items. - If a line item's price is unclear, make your best guess rather than \ omitting it. - date must be the transaction date printed on the receipt, not a guess \ based on anything else. """ def extract_receipt(image_bytes: bytes, mime_type: str = "image/jpeg") -> dict: """Returns {"store_name": str | None, "date": str | None, "items": [...]}.""" if not Config.LLM_BASE_URL or not Config.LLM_MODEL: raise RuntimeError("LLM_BASE_URL / LLM_MODEL not configured (see .env.example)") b64 = base64.b64encode(image_bytes).decode("ascii") resp = requests.post( f"{Config.LLM_BASE_URL}/chat/completions", headers={"Authorization": f"Bearer {Config.LLM_API_KEY}"}, json={ "model": Config.LLM_MODEL, "messages": [ { "role": "user", "content": [ {"type": "text", "text": _PROMPT}, { "type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{b64}"}, }, ], } ], "temperature": 0, }, timeout=60, ) resp.raise_for_status() raw = resp.json()["choices"][0]["message"]["content"] parsed = _parse_json_response(raw) items = [] for entry in parsed.get("items", []): items.append( { "id": str(uuid.uuid4()), "label": str(entry.get("label", "")).strip(), "price": float(entry.get("price", 0) or 0), } ) store_name = parsed.get("store_name") or None date = parsed.get("date") or None # Basic sanity check - if the model didn't return a real YYYY-MM-DD, # don't propagate garbage; the caller falls back to today's date. if date and not re.match(r"^\d{4}-\d{2}-\d{2}$", str(date)): date = None return {"store_name": store_name, "date": date, "items": items} def _parse_json_response(raw: str) -> dict: raw = raw.strip() # Models sometimes wrap the JSON in ```json ... ``` despite instructions. if raw.startswith("```"): raw = raw.strip("`") if raw.startswith("json"): raw = raw[4:] raw = raw.strip() return json.loads(raw)