Wire specialist waves, Brain consolidation, and Classic-compatible reports so Agent mode can run end-to-end via OpenRouter without changing the default Classic path. Co-authored-by: Cursor <cursoragent@cursor.com>
126 lines
4.4 KiB
Python
126 lines
4.4 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
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from backend import config
|
|
from backend.jobs import PIPELINE_MODES, create_job, get_job
|
|
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,
|
|
"key_configured": bool(config.AI_API_KEY),
|
|
"email_configured": bool(config.SMTP_HOST and config.SMTP_USER and config.SMTP_PASSWORD)}
|
|
|
|
|
|
@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),
|
|
pipeline_mode: str = Form("classic"),
|
|
):
|
|
"""
|
|
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.
|
|
"""
|
|
if not file.filename.lower().endswith(".pdf"):
|
|
raise HTTPException(status_code=400, detail="Please upload a PDF.")
|
|
pipeline_mode = pipeline_mode.strip().lower()
|
|
if pipeline_mode not in PIPELINE_MODES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"pipeline_mode must be one of: {', '.join(sorted(PIPELINE_MODES))}",
|
|
)
|
|
|
|
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()
|
|
}
|
|
job_id = create_job(tmp_path, source_filename=file.filename, email=email,
|
|
project_input=project_input, text_local=text_local,
|
|
pipeline_mode=pipeline_mode)
|
|
return JSONResponse({
|
|
"job_id": job_id,
|
|
"status": "queued",
|
|
"email": email,
|
|
"pipeline_mode": pipeline_mode,
|
|
})
|
|
|
|
|
|
@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}/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")
|