"""LLM-based contract analysis against a user-supplied checklist.""" import io import json import logging import os from pathlib import Path import fitz # PyMuPDF from PIL import Image import pytesseract from openai import AsyncOpenAI logger = logging.getLogger(__name__) _client: AsyncOpenAI | None = None 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 MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514") SYSTEM_PROMPT = """Jste expert na české obchodní právo a pomáháte podnikateli posoudit smlouvu, kterou má podepsat. Vaším úkolem je analyzovat smlouvu z pohledu PŘÍJEMCE smlouvy (toho, kdo ji má podepsat), nikoliv toho, kdo ji sepsal. Hledáte rizika, nevýhodná ustanovení a věci, na které by si měl dát příjemce pozor. Text smlouvy obdržíte rozdělený podle stran — každá strana začíná značkou "--- Strana N ---". U každého citátu MUSÍTE uvést, na které straně se nachází. Pro každý bod kontrolního seznamu posoudíte smlouvu a vrátíte JSON s následující strukturou: { "items": [ { "id": "id_polozky", "status": "ok" | "warning" | "problem" | "missing", "title": "Krátký nadpis nálezu (3-7 slov)", "summary": "1-3 věty vysvětlující nález z pohledu příjemce smlouvy", "excerpts": [ { "text": "Přesný citát ze smlouvy (krátký, 5-15 slov, ideálně dohledatelný v PDF)", "comment": "Stručný komentář k úryvku — co znamená / proč je to relevantní", "page": <číslo strany, kde se citát nachází (celé číslo)> } ] } ], "overall_summary": "Celkové shrnutí 3-5 vět: hlavní rizika a hlavní výhody. ŽÁDNÁ doporučení co dělat.", "risk_level": "low" | "medium" | "high" } Pravidla: - Status "ok" = ustanovení je vyvážené a bezpečné pro příjemce - Status "warning" = stojí za pozornost, ne nutně problém - Status "problem" = jasně nevýhodné nebo riskantní pro příjemce - Status "missing" = bod se ve smlouvě nepokrývá (chybí ustanovení) - Excerpts musí být DOSLOVNÉ citáty ze smlouvy (kvůli zvýraznění v PDF). Pokud nelze citovat (např. status=missing), vraťte prázdné pole []. - KAŽDÝ citát MUSÍ obsahovat číslo strany ("page"). Stranu poznáte podle značky "--- Strana N ---" před textem. - NEDÁVEJTE doporučení co s nálezem dělat — uživatel sám ví, co bude řešit. Pouze konstatujte fakta. - Komentáře piště česky, věcně a stručně. - Nevymýšlejte si — pokud něco ve smlouvě není, řekněte to. - Vraťte POUZE JSON, žádný markdown, žádný text okolo.""" async def analyze_contract(pdf_path: Path, checklist_items: list[dict]) -> dict: """Run LLM analysis on a contract PDF against the given checklist.""" text, used_ocr = _extract_text(pdf_path) if not text.strip(): raise RuntimeError( "Z PDF se nepodařilo získat žádný text — ani standardním " "způsobem ani OCR. Zkuste lepší kvalitu skenu nebo PDF s textovou vrstvou." ) checklist_block = "\n".join( f"- id={it['id']}: {it['label']} — {it.get('hint', '')}" for it in checklist_items ) user_msg = f"""KONTROLNÍ SEZNAM (co posoudit): {checklist_block} TEXT SMLOUVY: {text} Vraťte JSON s analýzou každé položky kontrolního seznamu.""" resp = await _get_client().chat.completions.create( model=MODEL, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_msg}, ], temperature=0.1, max_tokens=8000, ) raw = (resp.choices[0].message.content or "").strip() logger.info("Analysis raw length: %d chars", len(raw)) # Strip markdown fences if model wrapped it anyway raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip() try: data = json.loads(raw) except json.JSONDecodeError as e: logger.error("JSON parse failed: %s\n%s", e, raw[:1000]) raise RuntimeError(f"Nepodařilo se zpracovat odpověď AI: {e}") # Tell the caller whether we had to OCR — UI can show a notice and # we'll skip in-page highlighting since the original PDF has no text layer. data["_used_ocr"] = used_ocr return data def _extract_text(pdf_path: Path) -> tuple[str, bool]: """Extract text from PDF. Falls back to OCR if the PDF has no text layer. Returns (text, used_ocr). """ doc = fitz.open(str(pdf_path)) parts = [] total_chars = 0 for i, page in enumerate(doc): t = page.get_text() parts.append(f"--- Strana {i + 1} ---\n{t}") total_chars += len(t.strip()) # Heuristic: <20 chars per page on average → looks like a scan needs_ocr = doc.page_count > 0 and (total_chars / max(doc.page_count, 1)) < 20 if not needs_ocr: doc.close() return "\n\n".join(parts), False logger.info("PDF appears to be scanned (~%d chars across %d pages) — running OCR", total_chars, doc.page_count) parts = [] for i, page in enumerate(doc): # Rasterize page at 250 DPI for OCR quality pix = page.get_pixmap(dpi=250, alpha=False) img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) try: ocr_text = pytesseract.image_to_string(img, lang="ces+eng") except pytesseract.TesseractError as e: logger.error("Tesseract failed on page %d: %s", i + 1, e) ocr_text = "" parts.append(f"--- Strana {i + 1} ---\n{ocr_text}") doc.close() return "\n\n".join(parts), True