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
+142
View File
@@ -0,0 +1,142 @@
"""Pipeline orchestration — wraps the CAD skill modules.
Mirrors step_processor.main() but writes artifacts into a per-model directory and
returns structured metadata + BOM rows for the DB instead of printing a summary.
Heavy kernel imports happen lazily inside each stage.
"""
import logging
import math
import os
from pathlib import Path
from typing import Callable, Optional
from . import skill_bridge # noqa: F401 — sets sys.path so `modules.*` imports resolve
log = logging.getLogger("step_parser.processing")
Progress = Callable[[str], None]
def _f(v) -> Optional[float]:
try:
x = float(v)
return None if math.isnan(x) else round(x, 2)
except (TypeError, ValueError):
return None
def _i(v) -> Optional[int]:
try:
x = float(v)
return None if math.isnan(x) else int(x)
except (TypeError, ValueError):
return None
def _s(v) -> Optional[str]:
if v is None:
return None
s = str(v).strip()
return s or None
def _bom_df_to_rows(df) -> list[dict]:
rows = []
for _, r in df.iterrows():
rows.append({
"part_number": _s(r.get("part_number")),
"part_name_original": _s(r.get("part_name_original")),
"part_name_english": _s(r.get("part_name_english")),
"quantity": _i(r.get("quantity")),
"level": _i(r.get("level")),
"parent": _s(r.get("parent")),
"bbox_x_mm": _f(r.get("bbox_x_mm")),
"bbox_y_mm": _f(r.get("bbox_y_mm")),
"bbox_z_mm": _f(r.get("bbox_z_mm")),
"notes": _s(r.get("notes")),
})
return rows
def _collect_artifacts(out_dir: Path) -> list[str]:
"""All downloadable files in the model dir (original upload + generated outputs)."""
return sorted(p.name for p in out_dir.iterdir() if p.is_file())
def run_pipeline(step_path: Path, out_dir: Path, options: dict, progress: Progress) -> dict:
"""Run the requested pipeline stages. Returns metadata dict for the DB."""
meta: dict = {
"backend": None, "face_count": None, "part_count": None,
"has_chinese": False, "bbox": (None, None, None),
"bom_rows": [], "artifacts": [],
}
progress("loading")
import modules.loader as loader_mod
model = loader_mod.load_step(step_path)
if model is None:
raise RuntimeError("Failed to load STEP — build123d/FreeCAD unavailable or file invalid")
meta["backend"] = model.backend
meta["face_count"] = model.face_count
meta["part_count"] = len(model.parts) if model.parts else None
bom_df = None
if options.get("bom", True):
progress("bom")
from modules.bom import extract_bom, save_bom_xlsx
bom_df = extract_bom(model)
save_bom_xlsx(bom_df, step_path)
if bom_df is not None:
from modules.translator import has_chinese
meta["has_chinese"] = bool(bom_df["part_name_original"].apply(has_chinese).any())
if options.get("translate") and meta["has_chinese"]:
if os.environ.get("ANTHROPIC_API_KEY"):
progress("translate")
from modules.translator import get_translation_map, translate_bom
bom_df = translate_bom(bom_df, model_name=step_path.stem)
save_bom_xlsx(bom_df, step_path)
tmap = get_translation_map(bom_df)
if tmap:
from modules.rewriter import rewrite_step
rewrite_step(step_path, tmap)
else:
log.warning("translate requested but ANTHROPIC_API_KEY not set — skipping")
meta["bom_rows"] = _bom_df_to_rows(bom_df)
try:
root = bom_df[bom_df["level"] == 0]
if len(root):
r0 = root.iloc[0]
meta["bbox"] = (_f(r0["bbox_x_mm"]), _f(r0["bbox_y_mm"]), _f(r0["bbox_z_mm"]))
except Exception:
pass
if options.get("thumbnails", True):
progress("thumbnails")
from modules.renderer import render_views
render_views(model, step_path)
if options.get("diagram", False):
progress("diagram")
from modules.external_diagram import step_external_diagram
step_external_diagram(
path=str(step_path),
mode=options.get("diagram_mode", "enclosure_only"),
options={"pdf": bool(options.get("diagram_pdf", False))},
)
progress("collect")
meta["artifacts"] = _collect_artifacts(out_dir)
return meta
def run_query(step_path: Path, query: str) -> str:
"""Load a model and run a single geometry query (synchronous)."""
import modules.loader as loader_mod
from modules.query_engine import run_query as _run_query
model = loader_mod.load_step(step_path)
if model is None:
raise RuntimeError("Failed to load STEP for query")
return _run_query(model, query)