Phase 1: FastAPI backend with async job model

- backend/app: FastAPI API wrapping the CAD skill modules
  - upload -> job -> poll -> model / BOM / artifacts -> geometry query
  - SQLite via SQLModel (Model, Job, BomRow, QueryLog)
  - ThreadPoolExecutor worker, serialized, with live stage updates
- docker-compose.yml: dev server (mounts source, --reload) on :8000
- api-test.sh: end-to-end live validation script
- requirements.txt: add fastapi, uvicorn, python-multipart, sqlmodel
- external_diagram.py: port active-area detection OCC.Core -> OCP
- .gitignore, PHASE1.md

Validated live: MR16 round-trip passes (28 BOM rows, 12 artifacts,
bounding-box query, xlsx download; active-area detection working).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-06-17 16:38:26 -05:00
parent c1abe36822
commit b3c3e2a3b2
15 changed files with 701 additions and 5 deletions
+88
View File
@@ -0,0 +1,88 @@
"""Background job worker.
CAD processing is heavy and CPU-bound, so jobs run in a ThreadPoolExecutor
(serialized by default, MAX_WORKERS=1) rather than blocking the event loop with
FastAPI BackgroundTasks. Job state + results are persisted to SQLite as it runs,
so a client polling GET /api/jobs/{id} sees live stage updates.
"""
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from sqlmodel import Session, delete
from .config import MAX_WORKERS, MODELS_DIR
from .db import engine
from .models import BomRow, Job, Model
from .processing import run_pipeline
log = logging.getLogger("step_parser.worker")
_executor = ThreadPoolExecutor(max_workers=MAX_WORKERS, thread_name_prefix="cadjob")
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def submit_job(job_id: int) -> None:
_executor.submit(_run_job, job_id)
def _run_job(job_id: int) -> None:
with Session(engine) as s:
job = s.get(Job, job_id)
if job is None:
log.error("job %s vanished before it ran", job_id)
return
model = s.get(Model, job.model_id)
out_dir = MODELS_DIR / str(model.id)
step_path = out_dir / model.original_filename
options = json.loads(job.options or "{}")
job.status = "running"
job.stage = "loading"
job.started_at = _utcnow()
s.add(job)
s.commit()
def progress(stage: str) -> None:
j = s.get(Job, job_id)
j.stage = stage
s.add(j)
s.commit()
try:
meta = run_pipeline(step_path, out_dir, options, progress)
model = s.get(Model, job.model_id)
model.backend = meta["backend"]
model.face_count = meta["face_count"]
model.part_count = meta["part_count"]
model.bbox_x_mm, model.bbox_y_mm, model.bbox_z_mm = meta["bbox"]
model.has_chinese = meta["has_chinese"]
s.add(model)
# Replace any prior BOM rows for an idempotent re-run.
s.exec(delete(BomRow).where(BomRow.model_id == model.id))
for row in meta["bom_rows"]:
s.add(BomRow(model_id=model.id, **row))
job = s.get(Job, job_id)
job.status = "done"
job.stage = "done"
job.artifacts = json.dumps(meta["artifacts"])
job.finished_at = _utcnow()
s.add(job)
s.commit()
log.info("job %s done — %d artifacts", job_id, len(meta["artifacts"]))
except Exception as e: # noqa: BLE001 — record any failure on the job
log.exception("job %s failed", job_id)
s.rollback()
j = s.get(Job, job_id)
j.status = "error"
j.error = f"{type(e).__name__}: {e}"
j.finished_at = _utcnow()
s.add(j)
s.commit()