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

Conflict Checker

frontend/index.html not found.

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