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>
146 lines
5.1 KiB
Python
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")
|