- Job logs: each job's stdout/stderr is teed into outputs/<id>/job.log
(survives restarts) and served at GET /jobs/{id}/log as text/plain, so
full run logs can be shared for debugging and refinement.
- Model picker: GET /models proxies OpenRouter's public model list with
per-1M-token pricing (1h cache, 502 on failure); the UI shows a model
dropdown with costs when OpenRouter compute is selected, and the pick
overrides vision+text models for that job (Classic and Agent modes).
- Conflicts in the report view are grouped by discipline pair
(collapsible sections, severity-ordered within groups) instead of one
flat severity-only list.
260 lines
10 KiB
Python
260 lines
10 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
|
|
import threading
|
|
import time
|
|
from typing import Optional
|
|
|
|
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
import backend.jobs
|
|
from backend import config
|
|
from backend.jobs import PIPELINE_MODES, create_job, get_job, _set
|
|
from backend.pipeline.pdf_processor import render_page_jpeg
|
|
from backend.review.feedback import decision_to_label, write_label
|
|
from backend.review.finalizer import finalize_review
|
|
from backend.review.store import ReviewStore
|
|
|
|
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,
|
|
"version": config.APP_VERSION,
|
|
"build": config.APP_BUILD,
|
|
"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 list_models():
|
|
"""Available OpenRouter models with per-1M-token pricing for the UI picker."""
|
|
from backend.models import fetch_models
|
|
models = fetch_models()
|
|
if models is None:
|
|
raise HTTPException(status_code=502,
|
|
detail="Could not fetch the model list from OpenRouter")
|
|
return {"models": models, "default": config.MODEL, "default_text": config.TEXT_MODEL}
|
|
|
|
|
|
@app.get("/jobs/{job_id}/log")
|
|
def job_log(job_id: str):
|
|
"""The full captured stdout/stderr log of a job run (persists on disk)."""
|
|
path = os.path.join(config.OUTPUT_DIR, job_id, "job.log")
|
|
if not os.path.isfile(path):
|
|
raise HTTPException(status_code=404, detail="Log not found for this job")
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
return Response(content=f.read(), media_type="text/plain")
|
|
|
|
|
|
@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"),
|
|
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.
|
|
"""
|
|
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, model=model)
|
|
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}/review")
|
|
def review_queue(job_id: str):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
out_dir = job.get("out_dir") or os.path.join(config.OUTPUT_DIR, job_id)
|
|
# Read-only endpoint: don't create review/ dirs just by looking at them
|
|
# (readers already degrade to empty on missing files).
|
|
store = ReviewStore(out_dir, create=False)
|
|
queue = store.read_queue()
|
|
return {"queue": queue, "progress": store.progress(queue),
|
|
"decisions": store.read_decisions()}
|
|
|
|
|
|
@app.post("/jobs/{job_id}/review-decisions")
|
|
def save_review_decisions(job_id: str, payload: dict):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
if job.get("status") not in ("needs_review", "reviewing"):
|
|
# Positive state guard, mirroring the finalize endpoint: only jobs
|
|
# sitting at (or working through) the review gate accept decisions.
|
|
raise HTTPException(status_code=409, detail={
|
|
"detail": f"cannot save review decisions for a job in status {job.get('status')}",
|
|
})
|
|
out_dir = job.get("out_dir") or os.path.join(config.OUTPUT_DIR, job_id)
|
|
store = ReviewStore(out_dir)
|
|
queue = store.read_queue()
|
|
items_by_id = {item.get("review_item_id"): item for item in queue}
|
|
saved = 0
|
|
try:
|
|
for decision in payload.get("decisions") or []:
|
|
store.append_decision(decision)
|
|
queue_item = items_by_id.get(decision.get("review_item_id"))
|
|
if queue_item is not None:
|
|
write_label(out_dir, decision_to_label(queue_item, decision, job))
|
|
saved += 1
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
progress = store.progress(queue)
|
|
if job.get("status") == "needs_review" and saved > 0 and progress["remaining"] > 0:
|
|
try:
|
|
_set(job_id, status="reviewing")
|
|
except KeyError:
|
|
pass # job not in the in-memory registry (e.g. loaded from disk)
|
|
return {"progress": progress}
|
|
|
|
|
|
def _finalize_job(job_id: str, out_dir: str) -> None:
|
|
"""Background finalization: the ONE place the final report email may fire."""
|
|
try:
|
|
report = finalize_review(job_id, out_dir)
|
|
except Exception as e:
|
|
try:
|
|
_set(job_id, status="finalization_error", error=str(e),
|
|
finished_at=time.time(), stage=None)
|
|
except KeyError:
|
|
pass # job not in the in-memory registry
|
|
return
|
|
try:
|
|
_set(job_id, status="done", report=report,
|
|
finished_at=time.time(), stage=None)
|
|
except KeyError:
|
|
pass
|
|
try:
|
|
backend.jobs._notify(job_id, report, out_dir)
|
|
except Exception as e:
|
|
print(f"[Jobs] Final notification for {job_id} failed: {e}")
|
|
|
|
|
|
@app.post("/jobs/{job_id}/finalize-review")
|
|
def finalize_review_endpoint(job_id: str):
|
|
job = get_job(job_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
if job.get("status") in ("done", "finalizing"):
|
|
raise HTTPException(status_code=409, detail={
|
|
"detail": f"job is already {job['status']}",
|
|
})
|
|
if job.get("status") not in ("needs_review", "reviewing", "finalization_error"):
|
|
# Positive state-machine guard: finalization (and the final email) is
|
|
# only reachable after the job has passed through the review gate.
|
|
raise HTTPException(status_code=409, detail={
|
|
"detail": f"cannot finalize a job in status {job.get('status')}",
|
|
})
|
|
out_dir = job.get("out_dir") or os.path.join(config.OUTPUT_DIR, job_id)
|
|
store = ReviewStore(out_dir)
|
|
queue = store.read_queue()
|
|
decisions = store.read_decisions()
|
|
if any(item.get("blocking") and item.get("review_item_id") not in decisions
|
|
for item in queue):
|
|
# 409 detail shape: {"detail": <message>, "progress": <store.progress()>}
|
|
raise HTTPException(status_code=409, detail={
|
|
"detail": "incomplete review",
|
|
"progress": store.progress(queue),
|
|
})
|
|
try:
|
|
_set(job_id, status="finalizing")
|
|
except KeyError:
|
|
pass # job not in the in-memory registry (e.g. loaded from disk)
|
|
threading.Thread(target=_finalize_job, args=(job_id, out_dir), daemon=True).start()
|
|
return {"status": "finalizing"}
|
|
|
|
|
|
@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")
|