Files
wgBill/backend/app/llm_client.py
T
dodox d97aac0e17 Initial scaffold: Flask/SQLite backend, React/Vite PWA frontend
Backend: receipt upload -> LLM vision extraction (OpenAI-compatible,
provider-agnostic), item review/edit, per-group splitting against
Cospend projects/members, highlight+upload via WebDAV, public share
link, bill creation via Cospend's OCS API (verified against real
source, not just doc summaries).

Frontend: capture -> review -> group -> summary flow as an installable
PWA.

install.sh / run.sh (venv + npm, tmux session) instead of Docker, per
the ~/Projects/gain pattern.
2026-08-30 15:02:18 +02:00

105 lines
3.5 KiB
Python

"""Receipt -> [{id, label, price, bbox}] 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": "<store/shop name as printed, or null if unreadable>",
"date": "<receipt date as YYYY-MM-DD, or null if unreadable>",
"items": [
{"label": "<item name as printed>", "price": <number>, \
"bbox": [x, y, w, h]}
]
}
- price is the item's price in the receipt's currency, as a plain number \
(no currency symbol).
- bbox is the item's approximate bounding box on the image, normalized to \
0-1 (x, y = top-left corner; w, h = width/height as a fraction of the \
image). Best effort is fine.
- 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),
"bbox": entry.get("bbox") or None,
}
)
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)