"""Per-IC PCB layout review — consumes shared library extraction, no second PDF exam.""" 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.finding_engine import complete_findings from backend.periscopex.functional_groups import FunctionalGroupsReport from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph from backend.periscopex.pcb_inventory import PcbInventoryReport from backend.periscopex.pcb_review import ( PCB_SYSTEM_PROMPT, build_pcb_layout_context, format_library_extraction, ) from backend.periscopex.validate import ReviewResult, _match_constraints from backend.services.api_logs import ApiLogger from backend.services.validation import 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" rec = (f.recommendation or "").strip() act = (f.action or "").strip() if not rec: rec = act or _FIX f.recommendation = rec if not act: f.action = rec complete_findings(findings) def _has_library_extraction(cons) -> bool: return cons is not None and bool(cons.pintable) 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]]: """Layout-only AI exam using ``library/extracted`` (or project extracted/). Does not attach a datasheet PDF. ICs without a library pintable are skipped with a reason to run schematic review first. """ 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 cons = _match_constraints(mpn, constraints_map) if not _has_library_extraction(cons): skipped.append({ "designator": ref, "reason": "no library extraction — run schematic review first", }) continue extra = "\n\n".join([ format_library_extraction(cons), build_pcb_layout_context(ref, graph, layout, plan, inventory), ]) try: result, _trace = await review_ic_async( graph, constraints_map, ref, None, 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