Add MODE=pcb layout exam job with AI and deterministic checks.

Parallel pcb_status pipeline: parse board, classify domains/groups,
inventory traces, PE-LAY/PLC/SI/DRT checks, per-IC datasheet review.
Findings merge into the report UI. No auto-place or pcbnew write-back.
This commit is contained in:
2026-09-19 22:54:17 +02:00
parent a2011dad91
commit 16c606ae3d
28 changed files with 1906 additions and 32 deletions
+189 -2
View File
@@ -463,7 +463,7 @@ async def events(project_id: str, request: Request):
break
ev = msg["event"]
# Skip placement events in the shared log.
if ev.startswith("placement_"):
if ev.startswith("placement_") or ev.startswith("pcb_"):
continue
yield {
"event": ev,
@@ -527,6 +527,9 @@ async def status(project_id: str, request: Request):
"placement_status": meta.placement_status,
"placement_state": meta.placement_state,
"placement_running": (meta.placement_status or "draft") in ("queued", "running"),
"pcb_status": meta.pcb_status,
"pcb_state": meta.pcb_state,
"pcb_running": (meta.pcb_status or "draft") in ("queued", "running"),
"healed": healed is not None,
}
@@ -548,6 +551,7 @@ _PLACEMENT_SSE_TERMINAL = frozenset({
async def start_placement(project_id: str, request: Request):
"""Enqueue the Placement topology pipeline (free, no analysis status change)."""
from backend.services.placement_pipeline import analysis_busy, placement_busy
from backend.services.pcb_pipeline import pcb_busy
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
@@ -557,6 +561,8 @@ async def start_placement(project_id: str, request: Request):
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
if placement_busy(meta):
raise HTTPException(409, "Placement pipeline already running or queued")
if pcb_busy(meta):
raise HTTPException(409, "PCB review is running; wait or cancel it first")
if (meta.placement_status or "draft") not in _PLACEMENT_START_OK:
raise HTTPException(
409,
@@ -694,7 +700,7 @@ async def placement_events(project_id: str, request: Request):
if not (
ev.startswith("placement_")
or ev == "heartbeat"
):
) or ev.startswith("pcb_"):
continue
yield {
"event": ev,
@@ -734,6 +740,187 @@ async def placement_events(project_id: str, request: Request):
)
# ---------------------------------------------------------------------------
# PCB review pipeline (parallel — exam, not auto-place)
# ---------------------------------------------------------------------------
_PCB_START_OK = frozenset({"draft", "complete", "error", "cancelled"})
_PCB_SSE_TERMINAL = frozenset({
"pcb_complete",
"pcb_error",
"pcb_cancelled",
})
@router.post("/pipeline/{project_id}/pcb/start", status_code=202)
async def start_pcb(project_id: str, request: Request):
from backend.services.pcb_pipeline import analysis_busy, pcb_busy, placement_busy
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
if not meta.has_pcb:
raise HTTPException(400, "Upload a .kicad_pcb before starting PCB review")
if not meta.has_bom or not meta.has_netlist:
raise HTTPException(400, "Upload BOM and netlist before starting PCB review")
if analysis_busy(meta):
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
if placement_busy(meta):
raise HTTPException(409, "Placement pipeline is running; wait or cancel it first")
if pcb_busy(meta):
raise HTTPException(409, "PCB review already running or queued")
if (meta.pcb_status or "draft") not in _PCB_START_OK:
raise HTTPException(
409,
f"Cannot start PCB review from pcb_status={meta.pcb_status}",
)
proj_svc.update_project(
storage, owner_user_id, project_id,
pcb_status="queued",
pcb_cancel_requested=False,
pcb_state=None,
pcb_execution_name=None,
)
try:
event_bridge.GCSEventBroker(storage, owner_user_id).clear_history(project_id)
except Exception:
logger.exception("failed to clear events before PCB start for %s", project_id)
try:
execution_name = job_runner.enqueue_pcb_pipeline(
project_id, owner_user_id,
)
except Exception:
logger.exception("enqueue_pcb_pipeline failed for %s", project_id)
proj_svc.update_project(
storage, owner_user_id, project_id,
pcb_status="error",
pcb_state={"error": "Failed to enqueue PCB worker"},
)
raise HTTPException(503, "Failed to enqueue PCB worker; please retry")
proj_svc.update_project(
storage, owner_user_id, project_id,
pcb_execution_name=execution_name,
)
return {"status": "started", "project_id": project_id}
@router.post("/pipeline/{project_id}/pcb/cancel")
async def cancel_pcb(project_id: str, request: Request):
from backend.services.pcb_pipeline import pcb_busy
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
if not pcb_busy(meta):
raise HTTPException(
409,
f"PCB review is not running (pcb_status={meta.pcb_status})",
)
proj_svc.update_project(
storage, owner_user_id, project_id,
pcb_cancel_requested=True,
)
return {"status": "cancel_requested", "project_id": project_id}
@router.get("/pipeline/{project_id}/pcb/inventory")
async def get_pcb_inventory(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/pcb_inventory.json"
if not storage.exists(key):
raise HTTPException(404, "PCB inventory not found — run PCB review first")
return storage.read_json(key)
@router.get("/pipeline/{project_id}/pcb/events")
async def pcb_events(project_id: str, request: Request):
owner_user_id, meta = await resolve_or_404(request, project_id)
storage = get_storage(request)
async def event_generator():
execution_name = meta.pcb_execution_name
crash_detected: dict[str, str | None] = {"reason": None}
async def watch_status() -> None:
poll_interval = 2.0
saw_active = (meta.pcb_status or "draft") in ("queued", "running")
while True:
await asyncio.sleep(poll_interval)
try:
cur = proj_svc.get_project(storage, owner_user_id, project_id)
except Exception:
continue
if cur is None:
continue
pst = cur.pcb_status or "draft"
if pst in ("queued", "running"):
saw_active = True
elif saw_active and pst in ("complete", "error", "cancelled"):
crash_detected["reason"] = f"pcb_status={pst} (terminal)"
return
if execution_name:
try:
state = job_runner.get_execution_state(execution_name)
except Exception:
state = "unknown"
if state in _EXEC_TERMINAL and (
saw_active or pst in ("queued", "running")
):
crash_detected["reason"] = f"execution state={state}"
return
watcher = asyncio.create_task(watch_status())
try:
async for msg in event_bridge.tail_events(
storage, owner_user_id, project_id,
terminal_events=_PCB_SSE_TERMINAL,
):
if crash_detected["reason"] is not None:
break
ev = msg["event"]
if not (ev.startswith("pcb_") or ev == "heartbeat"):
continue
yield {
"event": ev,
"data": json.dumps(msg.get("data", {})),
}
if ev in _PCB_SSE_TERMINAL:
return
if crash_detected["reason"] is not None:
cur = proj_svc.get_project(storage, owner_user_id, project_id)
err = None
if cur and cur.pcb_state:
err = cur.pcb_state.get("error")
yield {
"event": "pcb_error",
"data": json.dumps({
"error": err or crash_detected["reason"]
or "pcb worker terminated without a terminal event",
"synthetic": True,
}),
}
finally:
watcher.cancel()
try:
await watcher
except (asyncio.CancelledError, Exception):
pass
return EventSourceResponse(
event_generator(),
ping=15,
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
+3
View File
@@ -148,6 +148,9 @@ async def get_project(project_id: str, request: Request):
healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id)
if healed_pl is not None:
meta = healed_pl
healed_pcb = proj_svc.heal_if_pcb_stuck(storage, owner_user_id, project_id)
if healed_pcb is not None:
meta = healed_pcb
return meta.model_dump()
+9 -3
View File
@@ -40,10 +40,16 @@ async def get_report(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/report.json"
if not storage.exists(key):
schema_key = f"{prefix}/report.json"
pcb_key = f"{prefix}/pcb_report.json"
schema = storage.read_json(schema_key) if storage.exists(schema_key) else None
pcb = storage.read_json(pcb_key) if storage.exists(pcb_key) else None
from backend.periscopex.pcb_checks import merge_schema_pcb_reports
merged = merge_schema_pcb_reports(schema, pcb)
if merged is None:
raise HTTPException(404, "Report not found — run the pipeline first")
return JSONResponse(storage.read_json(key))
return JSONResponse(merged)
@router.get("/report/{project_id}/cad-bridge")