243 lines
9.9 KiB
Python
243 lines
9.9 KiB
Python
"""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
|
|
|
|
|
|
async def extract_counterparty(text: str) -> dict:
|
|
"""Find the COUNTERPARTY (not Colsys) — its name + IČO — from the contract."""
|
|
prompt = (
|
|
"Z textu smlouvy urči PROTISTRANU (druhou smluvní stranu), tedy NE "
|
|
"společnost Colsys s.r.o. (IČO 14799634), ale toho, s kým Colsys "
|
|
"smlouvu uzavírá. Vrať POUZE JSON: "
|
|
'{"name": "obchodní jméno protistrany|null", "ico": "IČO (8 číslic)|null"}. '
|
|
"Pokud je stran víc, vyber hlavního dodavatele/zhotovitele/poskytovatele. "
|
|
"IČO uveď jen číslice. Pokud nevíš, dej null."
|
|
)
|
|
try:
|
|
resp = await _get_client().chat.completions.create(
|
|
model=MODEL,
|
|
messages=[{"role": "system", "content": prompt},
|
|
{"role": "user", "content": text[:30000]}],
|
|
temperature=0.0, max_tokens=300,
|
|
)
|
|
raw = (resp.choices[0].message.content or "").strip()
|
|
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
|
d = json.loads(raw)
|
|
return {"name": d.get("name"), "ico": d.get("ico")}
|
|
except Exception as exc:
|
|
logger.warning("counterparty extraction failed: %s", exc)
|
|
return {"name": None, "ico": None}
|
|
|
|
|
|
async def suggest_changes(text: str, finding: dict) -> str:
|
|
"""Concrete suggested wording / negotiation points for one finding."""
|
|
fin = (f"Bod: {finding.get('title') or finding.get('label') or ''}\n"
|
|
f"Status: {finding.get('status')}\n"
|
|
f"Shrnutí nálezu: {finding.get('summary') or ''}\n"
|
|
f"Citace: " + "; ".join(e.get("text", "")
|
|
for e in (finding.get("excerpts") or [])))
|
|
prompt = (
|
|
"Jste expert na české obchodní právo a vyjednávání smluv. Pro daný "
|
|
"nález ze smlouvy navrhněte z pohledu PŘÍJEMCE smlouvy konkrétní úpravy: "
|
|
"(1) co konkrétně v ustanovení změnit a proč, (2) návrh přesnější/"
|
|
"bezpečnější formulace, (3) vyjednávací argument. Stručně, česky, "
|
|
"v odrážkách. Žádný JSON, jen čitelný text."
|
|
)
|
|
resp = await _get_client().chat.completions.create(
|
|
model=MODEL,
|
|
messages=[{"role": "system", "content": prompt},
|
|
{"role": "user", "content": f"{fin}\n\nKontext (text smlouvy, zkráceno):\n{text[:15000]}"}],
|
|
temperature=0.3, max_tokens=900,
|
|
)
|
|
return (resp.choices[0].message.content or "").strip()
|
|
|
|
|
|
def _chat_messages(text: str, messages: list[dict]) -> list[dict]:
|
|
system = (
|
|
"Jste asistent, který odpovídá na dotazy o konkrétní smlouvě. "
|
|
"Odpovídejte POUZE na základě textu smlouvy níže. Pokud odpověď ve "
|
|
"smlouvě není, řekněte to. Citujte relevantní pasáže. Česky, věcně.\n\n"
|
|
f"TEXT SMLOUVY:\n{text[:60000]}"
|
|
)
|
|
convo = [{"role": "system", "content": system}]
|
|
for m in messages[-12:]:
|
|
if m.get("role") in ("user", "assistant") and m.get("content"):
|
|
convo.append({"role": m["role"], "content": str(m["content"])[:4000]})
|
|
return convo
|
|
|
|
|
|
async def chat_contract(text: str, messages: list[dict]) -> str:
|
|
"""Answer a user question grounded in the contract text (non-streaming)."""
|
|
resp = await _get_client().chat.completions.create(
|
|
model=MODEL, messages=_chat_messages(text, messages),
|
|
temperature=0.2, max_tokens=1200,
|
|
)
|
|
return (resp.choices[0].message.content or "").strip()
|
|
|
|
|
|
async def chat_contract_stream(text: str, messages: list[dict]):
|
|
"""Async generator yielding answer text deltas (for SSE streaming)."""
|
|
stream = await _get_client().chat.completions.create(
|
|
model=MODEL, messages=_chat_messages(text, messages),
|
|
temperature=0.2, max_tokens=1200, stream=True,
|
|
)
|
|
async for chunk in stream:
|
|
try:
|
|
delta = chunk.choices[0].delta.content or ""
|
|
except (IndexError, AttributeError):
|
|
delta = ""
|
|
if delta:
|
|
yield delta
|
|
|
|
|
|
def extract_text(pdf_path: Path) -> tuple[str, bool]:
|
|
"""Public alias for text extraction (text, used_ocr)."""
|
|
return _extract_text(pdf_path)
|
|
|
|
|
|
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
|