Files
AI_portal/contract-check/main.py
2026-07-07 13:16:26 +02:00

268 lines
9.4 KiB
Python

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