Files
step-parse/backend/app/main.py
T
Jason Stedwell b3c3e2a3b2 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>
2026-06-17 16:38:26 -05:00

158 lines
5.1 KiB
Python

"""STEP Parser API — Phase 1.
Endpoints:
GET /api/health
POST /api/upload multipart STEP upload -> creates model + job
GET /api/jobs/{job_id} job status / stage / artifacts
GET /api/models list models
GET /api/models/{id} model metadata + BOM + artifacts
GET /api/models/{id}/artifacts/{name} download a generated file
POST /api/models/{id}/query run a geometry query
"""
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlmodel import Session, select
from . import processing
from .config import ALLOWED_SUFFIXES, MODELS_DIR
from .db import get_session, init_db
from .models import BomRow, Job, Model, QueryLog
from .worker import submit_job
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("step_parser.api")
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
log.info("DB initialized")
yield
app = FastAPI(title="STEP Parser API", version="0.1.0", lifespan=lifespan)
# Dev-permissive CORS; tighten to the Unraid frontend origin before it leaves the LAN.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class QueryRequest(BaseModel):
query: str
@app.get("/api/health")
def health():
return {"status": "ok"}
@app.post("/api/upload")
async def upload(
file: UploadFile = File(...),
thumbnails: bool = Form(True),
bom: bool = Form(True),
diagram: bool = Form(False),
translate: bool = Form(False),
diagram_mode: str = Form("enclosure_only"),
session: Session = Depends(get_session),
):
safe_name = Path(file.filename or "").name
suffix = Path(safe_name).suffix.lower()
if suffix not in ALLOWED_SUFFIXES:
raise HTTPException(400, f"Unsupported file type '{suffix}'. Expected .step or .stp")
stem = Path(safe_name).stem
model = Model(name=stem, original_filename=safe_name, stem=stem)
session.add(model)
session.commit()
session.refresh(model)
out_dir = MODELS_DIR / str(model.id)
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / safe_name).write_bytes(await file.read())
options = {
"thumbnails": thumbnails, "bom": bom, "diagram": diagram,
"translate": translate, "diagram_mode": diagram_mode,
}
job = Job(model_id=model.id, status="pending", options=json.dumps(options))
session.add(job)
session.commit()
session.refresh(job)
submit_job(job.id)
return {"model_id": model.id, "job_id": job.id, "status": job.status}
@app.get("/api/jobs/{job_id}")
def get_job(job_id: int, session: Session = Depends(get_session)):
job = session.get(Job, job_id)
if job is None:
raise HTTPException(404, "job not found")
data = job.model_dump()
data["artifacts"] = json.loads(job.artifacts) if job.artifacts else []
data["options"] = json.loads(job.options) if job.options else {}
return data
@app.get("/api/models")
def list_models(session: Session = Depends(get_session)):
return session.exec(select(Model).order_by(Model.id.desc())).all()
@app.get("/api/models/{model_id}")
def get_model(model_id: int, session: Session = Depends(get_session)):
model = session.get(Model, model_id)
if model is None:
raise HTTPException(404, "model not found")
bom = session.exec(
select(BomRow).where(BomRow.model_id == model_id).order_by(BomRow.level, BomRow.id)
).all()
latest = session.exec(
select(Job).where(Job.model_id == model_id).order_by(Job.id.desc())
).first()
artifacts = json.loads(latest.artifacts) if (latest and latest.artifacts) else []
return {
"model": model,
"bom": bom,
"artifacts": artifacts,
"latest_job": {"id": latest.id, "status": latest.status, "stage": latest.stage} if latest else None,
}
@app.get("/api/models/{model_id}/artifacts/{name}")
def get_artifact(model_id: int, name: str):
safe = Path(name).name # block path traversal
path = MODELS_DIR / str(model_id) / safe
if not path.is_file():
raise HTTPException(404, "artifact not found")
return FileResponse(path, filename=safe)
@app.post("/api/models/{model_id}/query")
def query_model(model_id: int, req: QueryRequest, session: Session = Depends(get_session)):
model = session.get(Model, model_id)
if model is None:
raise HTTPException(404, "model not found")
step_path = MODELS_DIR / str(model_id) / model.original_filename
if not step_path.is_file():
raise HTTPException(404, "model source file missing")
try:
result = processing.run_query(step_path, req.query)
except Exception as e: # noqa: BLE001
raise HTTPException(500, f"query failed: {type(e).__name__}: {e}")
session.add(QueryLog(model_id=model_id, query=req.query, result=result))
session.commit()
return {"query": req.query, "result": result}