Files
AI_portal/offer-to-order/extractor.py
2026-07-07 13:16:26 +02:00

285 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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