285 lines
11 KiB
Python
285 lines
11 KiB
Python
"""Supplier-offer extraction via Claude.
|
||
|
||
Inputs are supplier price offers (Czech "nabídka") in wildly varied layouts —
|
||
PDF (text or scanned) and XLSX. We extract the SUPPLIER's identity plus the
|
||
line items into a fixed schema the CFO defined. Colsys s.r.o. (IČO 14799634)
|
||
is always the BUYER, never the supplier.
|
||
"""
|
||
import base64
|
||
import io
|
||
import json
|
||
import logging
|
||
import os
|
||
from pathlib import Path
|
||
|
||
import fitz # PyMuPDF
|
||
import openpyxl
|
||
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 = 8
|
||
MAX_IMG_LONG_EDGE = 2200
|
||
MAX_XLSX_ROWS = 400 # guard against huge sheets blowing the prompt
|
||
|
||
|
||
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 dodavatelských nabídek (cenových nabídek).
|
||
Z poskytnutého dokumentu vyextrahujete údaje o DODAVATELI a seznam položek do pevného JSON tvaru.
|
||
|
||
DŮLEŽITÉ — kdo je dodavatel:
|
||
- DODAVATEL = firma, která nabídku VYSTAVILA a nabízí zboží (prodávající).
|
||
- Firma „Colsys s.r.o." (IČO 14799634, DIČ CZ14799634) je VŽDY ODBĚRATEL (kupující), NIKDY dodavatel. Pokud ji uvidíte, NEzapisujte ji jako dodavatele.
|
||
- Dodavatel je tedy ta druhá strana (např. Rasel, ELKOV elektro, ABBAS apod.).
|
||
|
||
Vraťte POUZE platný JSON, žádné markdown obaly, žádný komentář mimo JSON:
|
||
|
||
{
|
||
"is_offer": true,
|
||
"offer_number": "string|null",
|
||
"supplier": {
|
||
"name": "string|null",
|
||
"ico": "string|null",
|
||
"dic": "string|null",
|
||
"email": "string|null",
|
||
"address": "string|null"
|
||
},
|
||
"currency": "CZK",
|
||
"line_items": [
|
||
{
|
||
"name": "string",
|
||
"supplier_id": "string|null",
|
||
"quantity": "number|null",
|
||
"unit": "string|null",
|
||
"unit_price": "number|null",
|
||
"total_price": "number|null"
|
||
}
|
||
]
|
||
}
|
||
|
||
Pravidla:
|
||
- is_offer = false, pokud dokument NENÍ dodavatelská nabídka/cenová nabídka zboží (např. mzdová tabulka, výkaz směn, obecný dokument). V takovém případě nech supplier prázdné a line_items jako [].
|
||
- "offer_number" = číslo nabídky dodavatele (např. „Nabídka č. NP250703175", „25NA00023"). Pokud není, null.
|
||
- "name" = název zboží přesně jak je uvedeno.
|
||
- "supplier_id" = kód/ID zboží u dodavatele (sloupce „Kód", „Obj. kód", „Kód zboží", „Katalogové číslo"). Pokud není, null.
|
||
- "quantity" = množství (číslo).
|
||
- "unit" = měrná jednotka (m, ks, kg, bal, ...). Zachovej přesně.
|
||
- "unit_price" = jednotková cena BEZ DPH za jednotku (sloupce „J.cena", „Cena/MJ", „Cena/mj", „Jednotková cena"). Pokud je uvedena jen cena s DPH, použij ji a poznamenej to není potřeba — prostě vrať dostupnou jednotkovou cenu.
|
||
- "total_price" = cena za položku celkem BEZ DPH (sloupce „Celkem bez DPH", „Cena celkem", „Celkem"). Pokud není uvedena, dopočítej quantity × unit_price.
|
||
- Čísla zapisuj s tečkou jako desetinným oddělovačem, BEZ mezer a BEZ symbolu měny (např. 1289.40, ne „1 289,40 Kč").
|
||
- Zachovej PŘESNÉ pořadí položek tak, jak jsou v dokumentu.
|
||
- Vynech řádky, které nejsou skutečné položky zboží (mezisoučty, „Součet položek", „CELKEM K ÚHRADĚ", rekapitulace DPH, prázdné řádky).
|
||
- Pokud údaj chybí, použij null. NEVYMÝŠLEJ si hodnoty."""
|
||
|
||
|
||
async def extract_offer(path: Path) -> dict:
|
||
"""Extract structured supplier-offer data. Routes PDF→vision, XLSX→text."""
|
||
suffix = path.suffix.lower()
|
||
if suffix == ".pdf":
|
||
content = _pdf_content(path)
|
||
elif suffix in (".xlsx", ".xlsm", ".xls"):
|
||
content = _xlsx_content(path)
|
||
else:
|
||
raise RuntimeError(f"Nepodporovaný formát: {suffix}")
|
||
|
||
if not content:
|
||
raise RuntimeError("Soubor neobsahuje žádný čitelný obsah")
|
||
|
||
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=8000,
|
||
)
|
||
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)
|
||
|
||
|
||
ORDER_EMAIL_PROMPT = """Jste extraktor dat z českého e-mailu, kterým zaměstnanec Colsys žádá o vystavení objednávky.
|
||
Z textu e-mailu vytáhněte údaje objednávky do pevného JSON tvaru. Text může pocházet z HTML e-mailu (různé zalomení, mezery).
|
||
|
||
Vraťte POUZE platný JSON, žádné markdown, žádný komentář:
|
||
|
||
{
|
||
"dodavatel": {
|
||
"name": "string|null",
|
||
"ico": "string|null",
|
||
"dic": "string|null",
|
||
"email": "string|null",
|
||
"kontakt": "string|null"
|
||
},
|
||
"nazev_zakazky": "string|null",
|
||
"kod_zakazky": "string|null",
|
||
"text": "string|null",
|
||
"cena_celkem_text": "string|null",
|
||
"termin_dodani": "string|null",
|
||
"kontaktni_osoba": "string|null",
|
||
"misto_dodani": "string|null",
|
||
"splatnost": "string|null",
|
||
"zaruka": "string|null",
|
||
"prilohy": "string|null",
|
||
"zpusob_odeslani": "string|null"
|
||
}
|
||
|
||
Pravidla:
|
||
- Hodnoty hledejte u odpovídajících štítků: „Dodavatel", „IČ"/„IČO", „DIČ", „KONTAKTNÍ OSOBA DODAVATELE" → dodavatel.kontakt (jméno + tel + email; email i do dodavatel.email), „NÁZEV ZAKÁZKY", „KÓD ZAKÁZKY", „TEXT", „CENA CELKEM" → cena_celkem_text (přepište přesně, např. „-" pokud není), „TERMÍN DODÁNÍ", „KONTAKTNÍ OSOBA V MÍSTĚ DODÁNÍ", „MÍSTO DODÁNÍ", „SPLATNOST", „ZÁRUKA", „PŘÍLOHY", „ZPŮSOB ODESLÁNÍ OBJEDNÁVKY".
|
||
- TERMÍN DODÁNÍ zapište jako DD.MM.RRRR pokud je to datum.
|
||
- Ignorujte patičkový právní text o VNP/VSP a větu „tento text výše je obsažen v zápatí…".
|
||
- Pokud údaj chybí, použijte null. NEVYMÝŠLEJTE si."""
|
||
|
||
|
||
async def parse_order_email(body_text: str) -> dict:
|
||
"""Extract order-form fields from the structured request e-mail body."""
|
||
body_text = (body_text or "").strip()
|
||
if not body_text:
|
||
return {}
|
||
try:
|
||
resp = await _get_client().chat.completions.create(
|
||
model=MODEL,
|
||
messages=[
|
||
{"role": "system", "content": ORDER_EMAIL_PROMPT},
|
||
{"role": "user", "content": body_text[:20000]},
|
||
],
|
||
temperature=0.0,
|
||
max_tokens=2000,
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("order-email LLM call failed")
|
||
raise RuntimeError(f"AI zpracování e-mailu selhalo: {exc}")
|
||
raw = (resp.choices[0].message.content or "").strip()
|
||
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||
try:
|
||
return json.loads(raw)
|
||
except json.JSONDecodeError as exc:
|
||
logger.error("order-email JSON parse failed: %s\n%s", exc, raw[:400])
|
||
return {}
|
||
|
||
|
||
def _pdf_content(path: Path) -> list[dict]:
|
||
"""PDF → page images for the vision model, plus the extracted text layer
|
||
(helps with exact codes/prices that can be hard to OCR from the raster)."""
|
||
doc = fitz.open(str(path))
|
||
content: list[dict] = [{
|
||
"type": "text",
|
||
"text": "Níže je dodavatelská nabídka jako obrázky stránek "
|
||
"(a pod nimi textová vrstva, pokud existuje). "
|
||
"Vyextrahujte data podle definovaného JSON tvaru.",
|
||
}]
|
||
text_parts = []
|
||
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)
|
||
content.append({
|
||
"type": "image_url",
|
||
"image_url": {"url": f"data:image/png;base64,{_compress(img)}"},
|
||
})
|
||
txt = page.get_text("text") or ""
|
||
if txt.strip():
|
||
text_parts.append(f"--- strana {i + 1} ---\n{txt.strip()}")
|
||
doc.close()
|
||
if text_parts:
|
||
content.append({
|
||
"type": "text",
|
||
"text": "Textová vrstva PDF:\n" + "\n\n".join(text_parts),
|
||
})
|
||
return content
|
||
|
||
|
||
def _xlsx_content(path: Path) -> list[dict]:
|
||
"""XLSX → a compact text dump of every sheet (rows as tab-joined cells)."""
|
||
wb = openpyxl.load_workbook(str(path), data_only=True, read_only=True)
|
||
blocks = []
|
||
for ws in wb.worksheets:
|
||
lines = [f"=== List: {ws.title} ==="]
|
||
for r, row in enumerate(ws.iter_rows(values_only=True)):
|
||
if r >= MAX_XLSX_ROWS:
|
||
lines.append("… (zbytek listu vynechán)")
|
||
break
|
||
cells = ["" if c is None else str(c) for c in row]
|
||
if any(c.strip() for c in cells):
|
||
lines.append("\t".join(cells).rstrip())
|
||
blocks.append("\n".join(lines))
|
||
wb.close()
|
||
dump = "\n\n".join(blocks)
|
||
return [{
|
||
"type": "text",
|
||
"text": "Níže je obsah excelové dodavatelské nabídky (buňky listů). "
|
||
"Vyextrahujte data podle definovaného JSON tvaru.\n\n" + dump,
|
||
}]
|
||
|
||
|
||
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:
|
||
def _num(v):
|
||
if v is None or v == "":
|
||
return None
|
||
if isinstance(v, (int, float)):
|
||
return float(v)
|
||
s = str(v).strip()
|
||
# Czech number formatting: "1 289,40" → "1289.40"
|
||
s = s.replace(" ", "").replace(" ", "").replace(",", ".")
|
||
# strip trailing currency/units the model may have left in
|
||
s = "".join(ch for ch in s if ch.isdigit() or ch in ".-")
|
||
try:
|
||
return float(s) if s not in ("", ".", "-") else None
|
||
except ValueError:
|
||
return None
|
||
|
||
if not isinstance(data, dict):
|
||
return {"is_offer": False, "supplier": {}, "line_items": []}
|
||
|
||
data.setdefault("is_offer", True)
|
||
data.setdefault("supplier", {})
|
||
data.setdefault("currency", "CZK")
|
||
items = data.get("line_items") or []
|
||
for item in items:
|
||
for k in ("quantity", "unit_price", "total_price"):
|
||
if k in item:
|
||
item[k] = _num(item[k])
|
||
# backfill total if missing but quantity & unit price present
|
||
if item.get("total_price") is None and \
|
||
item.get("quantity") is not None and \
|
||
item.get("unit_price") is not None:
|
||
item["total_price"] = round(item["quantity"] * item["unit_price"], 2)
|
||
data["line_items"] = items
|
||
return data
|