Files
wgBill/backend/app/llm_client.py
T
dodox 1b38396a2c Per-user auth via Nextcloud Login Flow v2, Tailwind UI rewrite
- auth.py/auth_routes.py: Login Flow v2 (verified against real NC
  source/docs) - no OAuth2 client registration needed, backend polls
  server-side so no CORS issues. Sessions are hashed-token cookies;
  NC app passwords encrypted at rest (Fernet). Every /api/* route
  guarded by a blueprint-wide before_request, not per-route decorators,
  so future routes are protected by default.
- receipts/groups scoped per owner_nc_user_id; cross-user access 404s.
- nc_client/cospend_client take (username, app_password) per call
  instead of one shared global credential - each user's uploads/shares/
  bills now happen as themselves.
- Frontend: LoginGate component drives the login flow (open NC login
  in a new tab, poll our backend, done).
- Merged the old separate review step into the split screen, redesigned
  with Tailwind (was unstyled/broken), default-excluded-per-item
  splitting with one-click "include everyone" fixed, DD.MM.YYYY date
  field, receipt-icon branding.
- Dropped LLM bounding-box highlighting - unreliable on real receipts,
  plain photo upload instead.
- Only mention who a bill is split with in its title when there's more
  than one bill off the same receipt to disambiguate.
2026-08-30 16:02:29 +02:00

100 lines
3.2 KiB
Python

"""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": "<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>}
]
}
- 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)