Files
Conflict_Checker/backend/main.py
T
woogi f7e1b6bb7c
Docker Release / build-and-push (push) Successful in 1m0s
Docker Release / release (push) Skipped
Merge main: dual model dropdowns + richer job logs, adapted for agent-mode.
- llm.py: set_model_overrides(vision, text) replaces the single job override;
  UI picks still beat per-call agent model args, but never name the hybrid
  local model (avoids main's hybrid footgun); local->cloud fallback uses the
  text pick.
- jobs.py: timestamped line-split tee (job_log.py), in-memory log + log_tail
  polls, full log on terminal states (done/error/needs_review/finalization_error),
  log-only disk recovery, error email links to the run log, and failed runs now
  append the full traceback to job.log. Keeps pipeline_mode, job.json, and the
  review gate.
- models.py: vision/text split via architecture modalities, pricing kept;
  /models returns {vision, text, defaults}; /check takes vision_model/text_model
  (replacing model); /health adds text_model. models_catalog.py dropped.
- UI: two priced dropdowns (OpenRouter compute only) + live run-log panel.
- Tests updated for dual overrides and the /models shape; new coverage for
  traceback capture and local-model immunity.
2026-08-02 09:55:09 -05:00

270 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,
"text_model": config.TEXT_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():
"""Vision/text OpenRouter model lists with pricing for the UI dropdowns."""
from backend.models import fetch_models, split_vision_text
models = fetch_models()
if models is None:
raise HTTPException(status_code=502,
detail="Could not fetch the model list from OpenRouter")
vision, text = split_vision_text(models)
return {"vision": vision, "text": text,
"defaults": {"vision": config.MODEL, "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"),
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.")
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()
}
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,
pipeline_mode=pipeline_mode, vision_model=v_model,
text_model=t_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")