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:
@@ -49,6 +49,9 @@ TERMINAL_EVENTS = frozenset({
|
||||
"placement_complete",
|
||||
"placement_error",
|
||||
"placement_cancelled",
|
||||
"pcb_complete",
|
||||
"pcb_error",
|
||||
"pcb_cancelled",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -350,6 +350,9 @@ def get_execution_state(execution_name: str | None) -> ExecutionState:
|
||||
if execution_name.startswith("local/placement/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
return _local_state(f"placement:{project_id}")
|
||||
if execution_name.startswith("local/pcb/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
return _local_state(f"pcb:{project_id}")
|
||||
return _cloud_run_state(execution_name)
|
||||
|
||||
|
||||
@@ -369,6 +372,10 @@ def cancel_execution(execution_name: str | None) -> None:
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
_local_cancel(f"placement:{project_id}")
|
||||
return
|
||||
if execution_name.startswith("local/pcb/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
_local_cancel(f"pcb:{project_id}")
|
||||
return
|
||||
_cloud_run_cancel(execution_name)
|
||||
|
||||
|
||||
@@ -383,3 +390,16 @@ def enqueue_placement_pipeline(project_id: str, user_id: str) -> str:
|
||||
proc_key=f"placement:{project_id}",
|
||||
execution_name=f"local/placement/{project_id}",
|
||||
)
|
||||
|
||||
|
||||
def enqueue_pcb_pipeline(project_id: str, user_id: str) -> str:
|
||||
"""Dispatch the parallel PCB review pipeline (deterministic + AI exam)."""
|
||||
if use_cloud_run_jobs():
|
||||
return _enqueue_cloud_run_job(
|
||||
project_id, user_id, resume=False, free=False, mode="pcb",
|
||||
)
|
||||
return _spawn_local_subprocess(
|
||||
project_id, user_id, resume=False, free=False, mode="pcb",
|
||||
proc_key=f"pcb:{project_id}",
|
||||
execution_name=f"local/pcb/{project_id}",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"""PCB review pipeline — parallel to analysis (exam, not auto-place).
|
||||
|
||||
Stages: ensure_graph → parse_pcb → classify → inventory → checks →
|
||||
ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
|
||||
untouched. Does not write ``.kicad_pcb`` or packing coordinates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.cad_bridge import (
|
||||
annotate_findings_cad,
|
||||
build_cad_bridge,
|
||||
cad_index_from_graph,
|
||||
write_cad_bridge,
|
||||
)
|
||||
from backend.periscopex.functional_groups import build_placement_plan
|
||||
from backend.periscopex.graph import build_graph
|
||||
from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph, ValidationReport
|
||||
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
|
||||
from backend.periscopex.pcb_inventory import build_pcb_inventory
|
||||
from backend.services import projects as proj_svc
|
||||
from backend.services.pipeline import PipelineWorkspace, broker
|
||||
from backend.services.storage import StorageBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PCB_ACTIVE = frozenset({"queued", "running"})
|
||||
_ANALYSIS_BUSY = frozenset({
|
||||
proj_svc.STATUS_QUEUED,
|
||||
proj_svc.STATUS_RUNNING,
|
||||
})
|
||||
_PLACEMENT_ACTIVE = frozenset({"queued", "running"})
|
||||
|
||||
|
||||
def _load_constraints_map(extracted_dir: Path) -> dict[str, ComponentConstraints]:
|
||||
result: dict[str, ComponentConstraints] = {}
|
||||
if not extracted_dir.is_dir():
|
||||
return result
|
||||
for f in extracted_dir.glob("*.json"):
|
||||
try:
|
||||
c = ComponentConstraints.model_validate_json(
|
||||
f.read_text(encoding="utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("skipping bad extraction %s", f)
|
||||
continue
|
||||
result[c.mpn] = c
|
||||
return result
|
||||
|
||||
|
||||
def _publish(project_id: str, event: str, data: dict) -> None:
|
||||
broker.publish(project_id, event, data)
|
||||
|
||||
|
||||
def _step(project_id: str, stage: str, status: str, detail: str = "") -> None:
|
||||
payload: dict = {"stage": stage, "status": status}
|
||||
if detail:
|
||||
payload["detail"] = detail
|
||||
_publish(project_id, "pcb_step_update", payload)
|
||||
|
||||
|
||||
async def run_pcb_pipeline(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> None:
|
||||
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||
if not meta:
|
||||
raise ValueError(f"Project {project_id} not found")
|
||||
if (meta.pcb_status or "draft") not in _PCB_ACTIVE:
|
||||
logger.warning(
|
||||
"pcb worker booted with pcb_status=%s for %s; exiting",
|
||||
meta.pcb_status, project_id,
|
||||
)
|
||||
return
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="running",
|
||||
pcb_cancel_requested=False,
|
||||
pcb_state=None,
|
||||
)
|
||||
|
||||
try:
|
||||
async with PipelineWorkspace(storage, user_id, project_id) as ws:
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
graph = await _ensure_graph(ws, meta, project_id)
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "parse_pcb", "running")
|
||||
layout = _load_layout(ws)
|
||||
if layout is None:
|
||||
raise FileNotFoundError(
|
||||
"Missing or unreadable uploads/pcb.kicad_pcb"
|
||||
)
|
||||
_step(
|
||||
project_id, "parse_pcb", "complete",
|
||||
f"{len(layout.footprints)} footprints, {len(layout.nets)} nets",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "classify", "running", "domains and groups")
|
||||
cmap = _load_constraints_map(ws.local_path("extracted"))
|
||||
plan = build_placement_plan(graph, cmap)
|
||||
fg_path = ws.local_path("functional_groups.json")
|
||||
fg_path.write_text(plan.model_dump_json(indent=2) + "\n")
|
||||
ws._upload_file("functional_groups.json")
|
||||
_step(
|
||||
project_id, "classify", "complete",
|
||||
f"{len(plan.domains)} domains, {len(plan.groups)} groups",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "inventory", "running")
|
||||
z0_by_net: dict[str, float] = {}
|
||||
try:
|
||||
from backend.periscopex.impedance_traces import analyze_where_needed
|
||||
|
||||
zrep = analyze_where_needed(layout, graph)
|
||||
for row in zrep.get("nets") or []:
|
||||
if not isinstance(row, dict) or row.get("error"):
|
||||
continue
|
||||
name = str(row.get("net_name") or row.get("name") or "")
|
||||
z = row.get("z0_ohm") or row.get("mean_z0") or row.get("z0")
|
||||
if name and isinstance(z, (int, float)):
|
||||
z0_by_net[name] = float(z)
|
||||
except Exception:
|
||||
logger.exception("Z0 inventory skipped")
|
||||
inventory = build_pcb_inventory(
|
||||
layout, graph,
|
||||
domain_ids=[d.domain_id for d in plan.domains],
|
||||
group_count=len(plan.groups),
|
||||
z0_by_net=z0_by_net,
|
||||
)
|
||||
inv_path = ws.local_path("pcb_inventory.json")
|
||||
inv_path.write_text(inventory.model_dump_json(indent=2) + "\n")
|
||||
ws._upload_file("pcb_inventory.json")
|
||||
_step(
|
||||
project_id, "inventory", "complete",
|
||||
f"{len(inventory.nets)} routed nets",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "checks", "running")
|
||||
findings = run_pcb_checks(graph, cmap, layout)
|
||||
_step(
|
||||
project_id, "checks", "complete",
|
||||
f"{len(findings)} deterministic findings",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "ai_review", "running", "per-IC datasheet vs layout")
|
||||
coverage: dict[str, list[str]] = {}
|
||||
skipped: list[dict] = []
|
||||
try:
|
||||
from backend.services.api_logs import ApiLogger
|
||||
from backend.services.pcb_validation import review_pcb_ics
|
||||
|
||||
pdf_dir = ws.local_path("uploads/datasheets")
|
||||
pdf_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger_api = ApiLogger()
|
||||
|
||||
async def _prog(ref, turn, tool, detail):
|
||||
_step(
|
||||
project_id, "ai_review", "running",
|
||||
f"{ref} {tool} {detail}".strip(),
|
||||
)
|
||||
|
||||
ai_findings, coverage, skipped = await review_pcb_ics(
|
||||
graph, cmap, layout, plan, inventory,
|
||||
pdf_dir, storage=storage, api_logger=logger_api,
|
||||
on_progress=_prog,
|
||||
)
|
||||
findings.extend(ai_findings)
|
||||
logger_api.flush(storage, user_id, project_id)
|
||||
detail = (
|
||||
f"{len(ai_findings)} AI findings, {len(skipped)} skipped"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("PCB AI review failed — keeping deterministic findings")
|
||||
detail = "AI skipped (error)"
|
||||
_step(project_id, "ai_review", "complete", detail)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "write_report", "running")
|
||||
assign_pcb_finding_ids(findings)
|
||||
annotate_findings_cad(findings, cad_index_from_graph(graph))
|
||||
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
|
||||
for f in findings:
|
||||
summary[f.status] = summary.get(f.status, 0) + 1
|
||||
report = ValidationReport(
|
||||
project=project_id,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
findings=findings,
|
||||
summary=summary,
|
||||
coverage=coverage,
|
||||
not_reviewed=skipped,
|
||||
)
|
||||
report_path = ws.local_path("pcb_report.json")
|
||||
report_path.write_text(report.model_dump_json(indent=2) + "\n")
|
||||
ws._upload_file("pcb_report.json")
|
||||
|
||||
bridge = build_cad_bridge(report, project_id)
|
||||
write_cad_bridge(ws.local_path("periscope-findings.json"), bridge)
|
||||
ws._upload_file("periscope-findings.json")
|
||||
_step(project_id, "write_report", "complete", f"{len(findings)} findings")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="complete",
|
||||
pcb_state={
|
||||
"findings": len(findings),
|
||||
"domains": len(plan.domains),
|
||||
"groups": len(plan.groups),
|
||||
"skipped": len(skipped),
|
||||
},
|
||||
pcb_cancel_requested=False,
|
||||
)
|
||||
_publish(project_id, "pcb_complete", {
|
||||
"findings": len(findings),
|
||||
"domains": len(plan.domains),
|
||||
"groups": len(plan.groups),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("pcb pipeline failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="error",
|
||||
pcb_state={"error": str(e)},
|
||||
)
|
||||
_publish(project_id, "pcb_error", {"error": str(e)})
|
||||
|
||||
|
||||
async def _ensure_graph(ws: PipelineWorkspace, meta, project_id: str) -> DesignGraph:
|
||||
graph_path = ws.local_path("design_graph.json")
|
||||
if graph_path.is_file():
|
||||
_step(project_id, "ensure_graph", "running", "reusing design_graph.json")
|
||||
graph = DesignGraph.model_validate_json(graph_path.read_text(encoding="utf-8"))
|
||||
_step(
|
||||
project_id, "ensure_graph", "complete",
|
||||
f"{len(graph.components)} components (cached)",
|
||||
)
|
||||
return graph
|
||||
|
||||
_step(project_id, "ensure_graph", "running", "building design graph")
|
||||
bom_path = ws.local_path("uploads/bom.csv")
|
||||
netlist_path = ws.netlist_local_path()
|
||||
if not bom_path.is_file() or not Path(netlist_path).is_file():
|
||||
raise FileNotFoundError("Missing BOM or netlist for PCB graph_build")
|
||||
|
||||
col_map = meta.bom_columns or {}
|
||||
graph = build_graph(
|
||||
str(netlist_path),
|
||||
str(bom_path),
|
||||
str(ws.local_path("extracted")),
|
||||
str(ws.local_path("patterns")),
|
||||
str(ws.local_path("models")),
|
||||
reference_col=col_map.get("reference", "Reference"),
|
||||
mpn_col=col_map.get("mpn", "Manufacturer Part Number"),
|
||||
include_subdesigns=(
|
||||
set(meta.netlist_subdesigns)
|
||||
if meta.netlist_subdesigns is not None
|
||||
else None
|
||||
),
|
||||
pcb_path=ws.local_path("uploads/pcb.kicad_pcb"),
|
||||
)
|
||||
graph_path.write_text(graph.model_dump_json(indent=2) + "\n")
|
||||
ws._upload_file("design_graph.json")
|
||||
_step(
|
||||
project_id, "ensure_graph", "complete",
|
||||
f"{len(graph.components)} components, {len(graph.nets)} nets",
|
||||
)
|
||||
return graph
|
||||
|
||||
|
||||
def _load_layout(ws: PipelineWorkspace) -> LayoutGraph | None:
|
||||
cached = ws.local_path("layout_graph.json")
|
||||
if cached.is_file():
|
||||
try:
|
||||
return LayoutGraph.model_validate_json(
|
||||
cached.read_text(encoding="utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("bad layout_graph.json — trying pcb parse")
|
||||
|
||||
pcb = ws.local_path("uploads/pcb.kicad_pcb")
|
||||
if not pcb.is_file():
|
||||
return None
|
||||
try:
|
||||
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||
|
||||
layout = parse_kicad_pcb(pcb)
|
||||
cached.write_text(layout.model_dump_json(indent=2) + "\n")
|
||||
ws._upload_file("layout_graph.json")
|
||||
return layout
|
||||
except Exception:
|
||||
logger.exception("kicad_pcb parse failed during PCB review")
|
||||
return None
|
||||
|
||||
|
||||
def _cancelled(storage: StorageBackend, user_id: str, project_id: str) -> bool:
|
||||
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||
return bool(meta and meta.pcb_cancel_requested)
|
||||
|
||||
|
||||
def _finish_cancelled(storage: StorageBackend, user_id: str, project_id: str) -> None:
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="cancelled",
|
||||
pcb_cancel_requested=False,
|
||||
)
|
||||
_publish(project_id, "pcb_cancelled", {})
|
||||
|
||||
|
||||
def pcb_busy(meta: proj_svc.ProjectMeta) -> bool:
|
||||
return (meta.pcb_status or "draft") in _PCB_ACTIVE
|
||||
|
||||
|
||||
def analysis_busy(meta: proj_svc.ProjectMeta) -> bool:
|
||||
return meta.status in _ANALYSIS_BUSY
|
||||
|
||||
|
||||
def placement_busy(meta: proj_svc.ProjectMeta) -> bool:
|
||||
return (meta.placement_status or "draft") in _PLACEMENT_ACTIVE
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Per-IC PCB datasheet review — same agentic loop as schematic, layout context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.cad_bridge import annotate_findings_cad, cad_index_from_graph
|
||||
from backend.periscopex.functional_groups import FunctionalGroupsReport
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph, ValidationReport
|
||||
from backend.periscopex.pcb_inventory import PcbInventoryReport
|
||||
from backend.periscopex.pcb_review import PCB_SYSTEM_PROMPT, build_pcb_layout_context
|
||||
from backend.periscopex.validate import ReviewResult
|
||||
from backend.services.api_logs import ApiLogger
|
||||
from backend.services.validation import _find_pdf, review_ic_async
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_FIX = "Adjust the layout to match the datasheet recommendation, then re-run PCB review."
|
||||
|
||||
|
||||
def _ensure_recs(findings: list[Finding]) -> None:
|
||||
for f in findings:
|
||||
if f.source in (None, "review"):
|
||||
f.source = "pcb_review"
|
||||
if not (f.recommendation or "").strip():
|
||||
f.recommendation = _FIX
|
||||
|
||||
|
||||
async def review_pcb_ics(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
plan: FunctionalGroupsReport | None,
|
||||
inventory: PcbInventoryReport | None,
|
||||
pdf_dir: Path,
|
||||
storage=None,
|
||||
api_logger: ApiLogger | None = None,
|
||||
on_progress=None,
|
||||
) -> tuple[list[Finding], dict[str, list[str]], list[dict]]:
|
||||
"""Review each IC with a datasheet. Fail-soft per IC. No auto-place."""
|
||||
findings: list[Finding] = []
|
||||
coverage: dict[str, list[str]] = {}
|
||||
skipped: list[dict] = []
|
||||
cache: dict = {}
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
mpn = (comp.mpn or "").strip() or (comp.value or "").strip()
|
||||
if not mpn:
|
||||
skipped.append({"designator": ref, "reason": "no MPN in BOM"})
|
||||
continue
|
||||
pdf = _find_pdf(mpn, pdf_dir, storage=storage)
|
||||
if pdf is None:
|
||||
skipped.append({"designator": ref, "reason": "no datasheet PDF"})
|
||||
continue
|
||||
extra = build_pcb_layout_context(ref, graph, layout, plan, inventory)
|
||||
try:
|
||||
result, _trace = await review_ic_async(
|
||||
graph, constraints_map, ref, str(pdf),
|
||||
on_progress=on_progress,
|
||||
api_logger=api_logger,
|
||||
pdf_dir=pdf_dir,
|
||||
storage=storage,
|
||||
excerpt_cache=cache,
|
||||
extra_context=extra,
|
||||
system_prompt=PCB_SYSTEM_PROMPT,
|
||||
log_stage="pcb_review",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("PCB AI review failed for %s — skipping", ref)
|
||||
skipped.append({"designator": ref, "reason": "pcb_review error"})
|
||||
continue
|
||||
if not isinstance(result, ReviewResult):
|
||||
continue
|
||||
_ensure_recs(result.findings)
|
||||
annotate_findings_cad(result.findings, cad_index_from_graph(graph))
|
||||
findings.extend(result.findings)
|
||||
if result.checked_areas:
|
||||
coverage[ref] = list(result.checked_areas)
|
||||
return findings, coverage, skipped
|
||||
@@ -264,6 +264,8 @@ class PipelineWorkspace:
|
||||
self._upload_file("bom_summary.json")
|
||||
self._upload_file("derating.json")
|
||||
self._upload_file("report.json")
|
||||
self._upload_file("pcb_report.json")
|
||||
self._upload_file("pcb_inventory.json")
|
||||
self._upload_file("periscope-findings.json")
|
||||
self._upload_file("review_fingerprints.json")
|
||||
self._upload_file("api_logs.jsonl")
|
||||
|
||||
@@ -143,6 +143,12 @@ class ProjectMeta(BaseModel):
|
||||
placement_execution_name: str | None = None
|
||||
placement_cancel_requested: bool = False
|
||||
|
||||
# PCB review pipeline (parallel exam — does not overwrite analysis status).
|
||||
pcb_status: str = "draft"
|
||||
pcb_state: dict[str, Any] | None = None
|
||||
pcb_execution_name: str | None = None
|
||||
pcb_cancel_requested: bool = False
|
||||
|
||||
|
||||
def completed_review_refs_for_retry(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
@@ -401,6 +407,70 @@ def heal_if_placement_stuck(
|
||||
)
|
||||
|
||||
|
||||
def heal_if_pcb_stuck(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> ProjectMeta | None:
|
||||
"""Unstick pcb_status queued/running when the worker is gone."""
|
||||
meta = get_project(storage, user_id, project_id)
|
||||
if meta is None:
|
||||
return None
|
||||
pst = meta.pcb_status or "draft"
|
||||
if pst not in ("queued", "running"):
|
||||
return None
|
||||
|
||||
prefix = _project_prefix(user_id, project_id)
|
||||
events_prefix = f"{prefix}/events/"
|
||||
last_event = None
|
||||
try:
|
||||
keys = sorted(
|
||||
k for k in storage.list_prefix(events_prefix)
|
||||
if k.endswith(".json") and "/events/" in k
|
||||
)
|
||||
if keys:
|
||||
last_event = storage.read_json(keys[-1])
|
||||
except Exception:
|
||||
last_event = None
|
||||
|
||||
if (last_event or {}).get("event") == "pcb_complete":
|
||||
data = (last_event or {}).get("data") or {}
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="complete",
|
||||
pcb_cancel_requested=False,
|
||||
pcb_state={
|
||||
"findings": data.get("findings"),
|
||||
"domains": data.get("domains"),
|
||||
"groups": data.get("groups"),
|
||||
},
|
||||
)
|
||||
|
||||
from backend.services import job_runner
|
||||
|
||||
exec_name = meta.pcb_execution_name or f"local/pcb/{project_id}"
|
||||
try:
|
||||
state = job_runner.get_execution_state(exec_name)
|
||||
except Exception:
|
||||
state = "unknown"
|
||||
|
||||
if state in ("pending", "running"):
|
||||
return None
|
||||
|
||||
has_report = storage.exists(f"{prefix}/pcb_report.json")
|
||||
if has_report:
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="complete",
|
||||
pcb_cancel_requested=False,
|
||||
pcb_state=meta.pcb_state,
|
||||
)
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="error",
|
||||
pcb_cancel_requested=False,
|
||||
pcb_state={"error": f"PCB worker terminated ({state})"},
|
||||
)
|
||||
|
||||
|
||||
# --- CRUD ---
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ from backend.periscopex.models import (
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
LayoutGraph,
|
||||
NetType,
|
||||
ValidationReport,
|
||||
)
|
||||
@@ -61,8 +60,6 @@ from backend.periscopex.dnp_check import check_dnp_enables
|
||||
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
|
||||
from backend.periscopex.errata_check import check_errata
|
||||
from backend.periscopex.internal_features_check import check_internal_features
|
||||
from backend.periscopex.placement_check import check_placement
|
||||
from backend.periscopex.si_check import check_si
|
||||
from backend.periscopex.crystal_cl_check import check_crystal_cl
|
||||
from backend.periscopex.nc_pin_check import check_nc_pins
|
||||
|
||||
@@ -77,7 +74,6 @@ def _is_deterministic(f: Finding) -> bool:
|
||||
def _run_deterministic_checks(
|
||||
graph: DesignGraph, constraints_map: dict,
|
||||
lifecycle_map: dict | None = None,
|
||||
layout: LayoutGraph | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Run the deterministic graph checks, fail-soft per check — a check bug
|
||||
can never break the review or the report."""
|
||||
@@ -100,8 +96,6 @@ def _run_deterministic_checks(
|
||||
("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)),
|
||||
("errata_check", lambda: check_errata(graph, constraints_map)),
|
||||
("internal_features_check", lambda: check_internal_features(graph, constraints_map)),
|
||||
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
|
||||
("si_check", lambda: check_si(graph, constraints_map, layout)),
|
||||
("crystal_cl_check", lambda: check_crystal_cl(graph)),
|
||||
("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)),
|
||||
):
|
||||
@@ -112,17 +106,6 @@ def _run_deterministic_checks(
|
||||
return out
|
||||
|
||||
|
||||
def _load_layout_graph(graph_path: str) -> LayoutGraph | None:
|
||||
path = Path(graph_path).with_name("layout_graph.json")
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
return LayoutGraph.model_validate_json(path.read_text())
|
||||
except Exception:
|
||||
log.exception("layout_graph.json invalid — skipping placement_check")
|
||||
return None
|
||||
|
||||
|
||||
def _assistant_text(blocks) -> str:
|
||||
"""Best-effort extraction of text content from a completion's raw
|
||||
assistant blocks. Provider-agnostic and never raises."""
|
||||
@@ -312,6 +295,9 @@ async def review_ic_async(
|
||||
pdf_dir: Path | None = None,
|
||||
storage=None,
|
||||
excerpt_cache: dict | None = None,
|
||||
extra_context: str = "",
|
||||
system_prompt: str | None = None,
|
||||
log_stage: str = "review",
|
||||
) -> tuple[ReviewResult, dict]:
|
||||
"""Review one IC against its datasheet. Async, multi-turn.
|
||||
|
||||
@@ -373,7 +359,7 @@ async def review_ic_async(
|
||||
|
||||
session = await provider.create_session(
|
||||
model=model,
|
||||
system=SYSTEM_PROMPT,
|
||||
system=system_prompt or SYSTEM_PROMPT,
|
||||
# Gemini 2.5/3 thinking models count thoughts against this cap.
|
||||
# 4096 was too tight: U3 (largest IC) burned the entire budget
|
||||
# on thinking and emitted zero visible output, dropping its
|
||||
@@ -387,13 +373,16 @@ async def review_ic_async(
|
||||
)
|
||||
try:
|
||||
context = build_component_context(graph, constraints_map, ic_ref)
|
||||
user_text = f"Review this component's usage:\n\n{context}"
|
||||
if extra_context.strip():
|
||||
user_text += "\n\n" + extra_context.strip()
|
||||
|
||||
initial_msg = Message(
|
||||
role="user",
|
||||
content=[
|
||||
PdfBlock(path=Path(trimmed_pdf), cacheable=True),
|
||||
TextBlock(
|
||||
text=f"Review this component's usage:\n\n{context}",
|
||||
text=user_text,
|
||||
cacheable=True,
|
||||
),
|
||||
],
|
||||
@@ -503,7 +492,7 @@ async def review_ic_async(
|
||||
)
|
||||
if api_logger:
|
||||
api_logger.log(
|
||||
stage="review", identifier=ic_ref,
|
||||
stage=log_stage, identifier=ic_ref,
|
||||
model=model, provider=provider.name,
|
||||
input_tokens=total_input, output_tokens=total_output,
|
||||
cache_creation_input_tokens=total_cache_creation,
|
||||
@@ -607,7 +596,7 @@ async def review_ic_async(
|
||||
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
|
||||
if api_logger:
|
||||
api_logger.log(
|
||||
stage="review", identifier=ic_ref,
|
||||
stage=log_stage, identifier=ic_ref,
|
||||
model=model, provider=provider.name,
|
||||
input_tokens=total_input, output_tokens=total_output,
|
||||
cache_creation_input_tokens=total_cache_creation,
|
||||
@@ -730,7 +719,7 @@ async def validate_design_async(
|
||||
if loaded:
|
||||
lifecycle_map.update(loaded)
|
||||
deterministic_findings = _run_deterministic_checks(
|
||||
graph, constraints_map, lifecycle_map, layout=_load_layout_graph(graph_path),
|
||||
graph, constraints_map, lifecycle_map,
|
||||
)
|
||||
|
||||
pdf_dir_path = Path(pdf_dir)
|
||||
|
||||
Reference in New Issue
Block a user