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.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user