"""Invoice data extraction via Claude vision.""" import base64 import io import json import logging import os from pathlib import Path import fitz # PyMuPDF from PIL import Image from openai import AsyncOpenAI logger = logging.getLogger(__name__) _client: AsyncOpenAI | None = None MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514") MAX_PAGES = 6 # cap to keep token cost predictable MAX_IMG_LONG_EDGE = 2200 # px — enough resolution for invoice details def _get_client() -> AsyncOpenAI: global _client if _client is None: _client = AsyncOpenAI( base_url=os.getenv("LITELLM_BASE_URL", "http://host.docker.internal:4000/v1"), api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"), ) return _client SYSTEM_PROMPT = """Jste přesný extraktor dat z českých faktur. Z obrázku/obrázků faktury vyextrahujete strukturovaná data pro účetní systém. Vraťte POUZE platný JSON v tomto tvaru, žádné markdown obaly, žádný komentář mimo JSON: { "invoice_number": "string|null", "issue_date": "YYYY-MM-DD|null", "due_date": "YYYY-MM-DD|null", "taxable_date": "YYYY-MM-DD|null", "variable_symbol": "string|null", "constant_symbol": "string|null", "specific_symbol": "string|null", "currency": "CZK", "supplier": { "name": "string|null", "ico": "string|null", "dic": "string|null", "address": "string|null" }, "customer": { "name": "string|null", "ico": "string|null", "dic": "string|null", "address": "string|null" }, "bank_account": "string|null", "iban": "string|null", "payment_method": "převod|hotově|karta|dobírka|jiné|null", "line_items": [ { "description": "string", "quantity": "number|null", "unit": "string|null", "unit_price_excluding_vat": "number|null", "vat_rate": "number|null", "total_excluding_vat": "number|null", "total_including_vat": "number|null" } ], "vat_breakdown": [ {"rate": 21, "base": 0.0, "vat": 0.0, "total": 0.0} ], "total_excluding_vat": "number|null", "total_vat": "number|null", "total_including_vat": "number|null", "notes": "string|null" } Pravidla: - Datumy zapisujte ve formátu YYYY-MM-DD (ISO 8601). - Čísla zapisujte jako desetinná čísla s tečkou jako oddělovačem (např. 1234.56). NIKDY nepoužívejte čárku ani mezery v číslech. - Pokud údaj na faktuře není, použijte null. NEVYMÝŠLEJTE si. - IČO je 8místné číslo (může mít vedoucí nuly). DIČ obvykle začíná „CZ". - variable_symbol je obvykle stejný jako číslo faktury, ale ne vždy — zapisujte přesně co je na faktuře. - U položek (line_items) zachovejte přesné pořadí jak jsou na faktuře. - Pokud je faktura v jiné měně než CZK, zapište správný kód měny (EUR, USD, atd.). - Adresy zapisujte jako jeden řetězec (ulice + číslo, PSČ město).""" async def extract_invoice(pdf_or_image_path: Path) -> dict: """Extract structured invoice data using Claude vision.""" images = _to_images(pdf_or_image_path) if not images: raise RuntimeError("Soubor neobsahuje žádné zobrazitelné stránky") content = [ {"type": "text", "text": "Z následujících obrázků faktury vyextrahujte strukturovaná data podle definovaného JSON tvaru."}, ] for img_b64 in images: content.append({ "type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}, }) try: resp = await _get_client().chat.completions.create( model=MODEL, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": content}, ], temperature=0.0, max_tokens=4000, ) except Exception as exc: logger.exception("LLM call failed") raise RuntimeError(f"AI extrakce selhala: {exc}") raw = (resp.choices[0].message.content or "").strip() raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip() try: data = json.loads(raw) except json.JSONDecodeError as exc: logger.error("JSON parse failed: %s\n%s", exc, raw[:500]) raise RuntimeError(f"Nepodařilo se zpracovat odpověď AI: {exc}") return _normalize(data) def _to_images(path: Path) -> list[str]: """Return list of base64-encoded PNG strings, one per page.""" suffix = path.suffix.lower() if suffix == ".pdf": doc = fitz.open(str(path)) out = [] for i, page in enumerate(doc): if i >= MAX_PAGES: break pix = page.get_pixmap(dpi=200, alpha=False) img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) out.append(_compress(img)) doc.close() return out elif suffix in (".jpg", ".jpeg", ".png", ".webp"): img = Image.open(path).convert("RGB") return [_compress(img)] raise RuntimeError(f"Nepodporovaný formát: {suffix}") def _compress(img: Image.Image) -> str: if max(img.size) > MAX_IMG_LONG_EDGE: ratio = MAX_IMG_LONG_EDGE / max(img.size) img = img.resize( (int(img.size[0] * ratio), int(img.size[1] * ratio)), Image.LANCZOS, ) buf = io.BytesIO() img.save(buf, format="PNG", optimize=True) return base64.b64encode(buf.getvalue()).decode("ascii") def _normalize(data: dict) -> dict: """Coerce known numeric fields to float and clean nulls.""" def _num(v): if v is None or v == "": return None if isinstance(v, (int, float)): return float(v) try: return float(str(v).replace(",", ".").replace(" ", "")) except (ValueError, TypeError): return None for k in ("total_excluding_vat", "total_vat", "total_including_vat"): if k in data: data[k] = _num(data[k]) for item in data.get("line_items") or []: for k in ("quantity", "unit_price_excluding_vat", "vat_rate", "total_excluding_vat", "total_including_vat"): if k in item: item[k] = _num(item[k]) for br in data.get("vat_breakdown") or []: for k in ("rate", "base", "vat", "total"): if k in br: br[k] = _num(br[k]) return data