268 lines
9.4 KiB
Python
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")
|