"""FastAPI app: upload contract PDF → analyze against checklist → annotated PDF.""" import asyncio import json import logging import os import uuid from pathlib import Path from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from analyzer import (analyze_contract, chat_contract_stream, extract_counterparty, extract_text, suggest_changes) from checklist import DEFAULT_CHECKLIST from pdf_annotator import annotate from registry import ares_search_by_name, reputation logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = FastAPI(title="Contract Terms Check") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/contract-check")) WORK_DIR.mkdir(parents=True, exist_ok=True) jobs: dict[str, dict] = {} @app.get("/") async def root(): return FileResponse("static/index.html") @app.get("/api/checklist") async def get_checklist(): """Default checklist items the UI can pre-populate.""" return {"items": DEFAULT_CHECKLIST} @app.post("/api/upload") async def upload(file: UploadFile = File(...)): suffix = Path(file.filename or "").suffix.lower() if suffix != ".pdf": raise HTTPException(400, "Podporovaný formát: .pdf") job_id = str(uuid.uuid4()) job_dir = WORK_DIR / job_id job_dir.mkdir() input_path = job_dir / "input.pdf" raw = await file.read() input_path.write_bytes(raw) logger.info("Job %s: %s (%d bytes)", job_id, file.filename, len(raw)) jobs[job_id] = { "filename": file.filename, "job_dir": str(job_dir), "input_path": str(input_path), "analysis": None, "checklist": None, } return {"job_id": job_id} class AnalyzeRequest(BaseModel): items: list[dict] # [{id, label, hint?, default?}] @app.post("/api/analyze/{job_id}") async def analyze(job_id: str, req: AnalyzeRequest): if job_id not in jobs: raise HTTPException(404, "Úloha nenalezena") if not req.items: raise HTTPException(400, "Vyberte alespoň jednu položku ke kontrole") job = jobs[job_id] input_path = Path(job["input_path"]) try: analysis = await analyze_contract(input_path, req.items) except Exception as exc: logger.exception("Analysis failed") raise HTTPException(500, str(exc)) # Merge LLM-returned items with the original checklist labels so the UI # can show the user-facing label even if the LLM was terse. labels_by_id = {it["id"]: it["label"] for it in req.items} for it in analysis.get("items", []): if "label" not in it and it.get("id") in labels_by_id: it["label"] = labels_by_id[it["id"]] job["analysis"] = analysis job["checklist"] = req.items job["used_ocr"] = bool(analysis.get("_used_ocr")) return analysis class AnnotateRequest(BaseModel): include_supplier: bool = True include_advice: bool = True include_chat: bool = False chat: list[dict] | None = None # transcript to embed if include_chat @app.post("/api/annotated/{job_id}") async def annotated_pdf(job_id: str, req: AnnotateRequest | None = None): if job_id not in jobs: raise HTTPException(404, "Úloha nenalezena") job = jobs[job_id] if not job.get("analysis"): raise HTTPException(400, "Nejprve spusťte analýzu") # Reliability check must be done — its data belongs in the PDF. if not job.get("supplier"): raise HTTPException(400, "Nejprve dokončete ověření protistrany.") req = req or AnnotateRequest() input_path = Path(job["input_path"]) out_path = Path(job["job_dir"]) / "annotated.pdf" analysis = job["analysis"] skip_highlights = bool(job.get("used_ocr")) supplier = job.get("supplier") if req.include_supplier else None advice = job.get("advice_texts") if req.include_advice else None chat = (req.chat or []) if req.include_chat else None try: await asyncio.to_thread( annotate, input_path, out_path, analysis.get("items", []), analysis.get("overall_summary", ""), analysis.get("risk_level", ""), skip_highlights, job.get("filename") or "", supplier, advice, chat, ) except Exception as exc: logger.exception("Annotation failed") raise HTTPException(500, f"Anotace selhala: {exc}") stem = Path(job["filename"]).stem if job.get("filename") else "smlouva" return FileResponse( str(out_path), media_type="application/pdf", filename=f"kontrola_{stem}.pdf", ) def _get_text(job: dict) -> str: """Extract + cache the contract text on the job (lazy, for supplier/chat).""" if not job.get("contract_text"): text, used_ocr = extract_text(Path(job["input_path"])) job["contract_text"] = text job["used_ocr"] = job.get("used_ocr") or used_ocr return job["contract_text"] class SupplierRequest(BaseModel): ico: str | None = None # manual override; if absent → auto-detect @app.post("/api/supplier/{job_id}") async def supplier(job_id: str, req: SupplierRequest): """Reputation check of the counterparty in public registers. Cascade: manual IČO → contract → ARES name search → ask for manual.""" if job_id not in jobs: raise HTTPException(404, "Úloha nenalezena") job = jobs[job_id] def _store(result): if result.get("found"): job["supplier"] = result return result if req.ico: rep = await asyncio.to_thread(reputation, req.ico) if rep is None: return {"found": False, "reason": "IČO nebylo nalezeno v ARES."} return _store({"found": True, "ico": req.ico, "source": "manual", "report": rep}) text = _get_text(job) cp = await extract_counterparty(text) ico = "".join(c for c in (cp.get("ico") or "") if c.isdigit()) if ico: rep = await asyncio.to_thread(reputation, ico) if rep: return _store({"found": True, "ico": ico, "source": "contract", "name_guess": cp.get("name"), "report": rep}) name = cp.get("name") cands = await asyncio.to_thread(ares_search_by_name, name) if name else [] if len(cands) == 1: rep = await asyncio.to_thread(reputation, cands[0]["ico"]) if rep: return _store({"found": True, "ico": cands[0]["ico"], "source": "ares_name", "name_guess": name, "report": rep}) if cands: return {"found": False, "name_guess": name, "candidates": cands, "reason": "Nalezeno více subjektů — vyberte, nebo zadejte IČO."} return {"found": False, "name_guess": name, "reason": "IČO se nepodařilo zjistit z textu ani z ARES — zadejte ručně."} class AdviceRequest(BaseModel): item_id: str @app.post("/api/advice/{job_id}") async def advice(job_id: str, req: AdviceRequest): """LLM-suggested wording/negotiation changes for one finding.""" if job_id not in jobs: raise HTTPException(404, "Úloha nenalezena") job = jobs[job_id] if not job.get("analysis"): raise HTTPException(400, "Nejprve spusťte analýzu") fin = next((i for i in job["analysis"].get("items", []) if i.get("id") == req.item_id), None) if not fin: raise HTTPException(404, "Nález nenalezen") text = _get_text(job) try: advice_text = await suggest_changes(text, fin) except Exception as exc: logger.exception("advice failed") raise HTTPException(500, f"Návrh úprav selhal: {exc}") # Persist so it can be included in the exported PDF. job.setdefault("advice_texts", {})[req.item_id] = { "title": fin.get("title") or fin.get("label") or req.item_id, "text": advice_text, } return {"advice": advice_text} class ChatRequest(BaseModel): messages: list[dict] # [{role:'user'|'assistant', content:str}] @app.post("/api/chat/{job_id}") async def chat(job_id: str, req: ChatRequest): """Q&A grounded in the contract text — streamed as SSE (same event format as Firemní asistent: textResponseChunk / finalizeResponseStream).""" if job_id not in jobs: raise HTTPException(404, "Úloha nenalezena") job = jobs[job_id] text = _get_text(job) messages = req.messages or [] async def gen(): full = "" try: async for delta in chat_contract_stream(text, messages): full += delta yield "data: " + json.dumps( {"type": "textResponseChunk", "textResponse": delta}) + "\n\n" yield "data: " + json.dumps( {"type": "finalizeResponseStream", "textResponse": full}) + "\n\n" except Exception as exc: logger.exception("chat stream failed") yield "data: " + json.dumps({"type": "error", "error": str(exc)}) + "\n\n" return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}) @app.get("/health") async def health(): return {"status": "ok"} app.mount("/static", StaticFiles(directory="static"), name="static")