Files
Conflict_Checker/backend/main.py
T
woogiandCursor a6b0c8fdfa
Docker Release / build-and-push (push) Successful in 1m27s
Docker Release / release (push) Skipped
Add per-job run logs and separate vision/text model selection.
Capture pipeline stdout into job.log + API/UI so failed runs can be reviewed, and let users pick OpenRouter vision vs text models independently.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 14:56:48 -05:00

146 lines
5.1 KiB
Python

"""
main.py - Thin FastAPI app: upload a PDF, run the pipeline, return the report.
Run from the project root:
uvicorn backend.main:app --reload
The pipeline is synchronous and can take minutes on a large set; for a single
architect checking one set at a time that's fine. Move to a job queue if this
ever needs concurrency.
"""
import os
import tempfile
from typing import Optional
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, Response, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from backend import config
from backend.jobs import create_job, get_job, get_job_log
from backend.models_catalog import list_models
from backend.pipeline.pdf_processor import render_page_jpeg
app = FastAPI(title=config.APP_TITLE, version=config.APP_VERSION)
_FRONTEND_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "frontend")
@app.get("/health")
def health():
return {"status": "ok", "model": config.MODEL,
"text_model": config.TEXT_MODEL,
"key_configured": bool(config.AI_API_KEY),
"email_configured": bool(config.SMTP_HOST and config.SMTP_USER and config.SMTP_PASSWORD)}
@app.get("/models")
def models():
"""Vision vs text OpenRouter model lists for the UI dropdowns."""
return JSONResponse(list_models())
@app.post("/check")
async def check(
file: UploadFile = File(...),
notification_email: Optional[str] = Form(None),
project_name: Optional[str] = Form(None),
address: Optional[str] = Form(None),
occupancy: Optional[str] = Form(None),
work_type: Optional[str] = Form(None),
text_local: bool = Form(False),
vision_model: Optional[str] = Form(None),
text_model: Optional[str] = Form(None),
):
"""
Accept a PDF, start a background conflict check, and return a job_id
immediately. The client can poll GET /jobs/{id} or just wait for the email.
Optional intake fields (project_name/address/occupancy/work_type) feed the
Stage 0 jurisdiction profile; anything left blank is derived from the cover
sheet.
vision_model / text_model override the configured defaults for this run
(vision always OpenRouter; text follows the OpenRouter vs hybrid choice).
"""
if not file.filename.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="Please upload a PDF.")
os.makedirs(config.UPLOAD_DIR, exist_ok=True)
suffix = "_" + os.path.basename(file.filename)
fd, tmp_path = tempfile.mkstemp(suffix=suffix, dir=config.UPLOAD_DIR)
with os.fdopen(fd, "wb") as f:
f.write(await file.read())
email = (notification_email or "").strip() or None
project_input = {
k: v.strip()
for k, v in {
"project_name": project_name, "address": address,
"occupancy": occupancy, "work_type": work_type,
}.items()
if v and v.strip()
}
v_model = (vision_model or "").strip() or None
t_model = (text_model or "").strip() or None
job_id = create_job(
tmp_path,
source_filename=file.filename,
email=email,
project_input=project_input,
text_local=text_local,
vision_model=v_model,
text_model=t_model,
)
return JSONResponse({"job_id": job_id, "status": "queued", "email": email})
@app.get("/jobs/{job_id}")
def job_status(job_id: str):
job = get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return JSONResponse(job)
@app.get("/jobs/{job_id}/log")
def job_log(job_id: str, plain: bool = False):
"""Full captured run log (also on disk as outputs/<job_id>/job.log)."""
lines = get_job_log(job_id)
if lines is None:
raise HTTPException(status_code=404, detail="Job not found")
if plain:
return PlainTextResponse("\n".join(lines) + ("\n" if lines else ""))
return JSONResponse({"job_id": job_id, "lines": lines, "text": "\n".join(lines)})
@app.get("/jobs/{job_id}/sheet-image/{page}")
def sheet_image(job_id: str, page: int):
"""Render one page of a completed job's source PDF as JPEG (sheet viewer)."""
pdf = os.path.join(config.OUTPUT_DIR, job_id, "source.pdf")
if not os.path.isfile(pdf):
raise HTTPException(status_code=404, detail="Source PDF not found for this job")
try:
data = render_page_jpeg(pdf, page)
except IndexError:
raise HTTPException(status_code=404, detail=f"Page {page} out of range")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Could not render page: {e}")
return Response(content=data, media_type="image/jpeg",
headers={"Cache-Control": "max-age=3600"})
@app.get("/", response_class=HTMLResponse)
def index():
index_path = os.path.join(_FRONTEND_DIR, "index.html")
if os.path.isfile(index_path):
with open(index_path) as f:
return f.read()
return "<h1>Conflict Checker</h1><p>frontend/index.html not found.</p>"
# Serve any other static assets (none required for the single-file UI).
if os.path.isdir(_FRONTEND_DIR):
app.mount("/static", StaticFiles(directory=_FRONTEND_DIR), name="static")