Share library/extracted for schematic and PCB review.

PCB AI skips a second PDF exam (pintable cache, pdf_path=None) and the report
gains a PCB exam section plus power-trace, thermal, via, Kelvin, and GND-stitch
checks grounded in board geometry and extracted specs.
This commit is contained in:
2026-09-20 08:32:23 +02:00
parent f3f96ea4d1
commit 8f4ebc645c
15 changed files with 1014 additions and 107 deletions
+32 -12
View File
@@ -36,19 +36,39 @@ _ANALYSIS_BUSY = frozenset({
_PLACEMENT_ACTIVE = frozenset({"queued", "running"})
def _load_constraints_map(extracted_dir: Path) -> dict[str, ComponentConstraints]:
def _load_constraints_map(
extracted_dir: Path,
storage: StorageBackend | None = None,
) -> dict[str, ComponentConstraints]:
"""Project extracted/ plus shared ``library/extracted`` (one store for schema+PCB)."""
result: dict[str, ComponentConstraints] = {}
if not extracted_dir.is_dir():
if extracted_dir.is_dir():
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
if storage is None:
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)
try:
keys = storage.list_recursive("library/extracted/")
except Exception:
logger.exception("listing library/extracted failed")
return result
for key in keys:
if not key.endswith(".json"):
continue
result[c.mpn] = c
try:
data = storage.read_json(key)
c = ComponentConstraints.model_validate(data)
except Exception:
logger.exception("skipping bad library extraction %s", key)
continue
result.setdefault(c.mpn, c)
return result
@@ -110,7 +130,7 @@ async def run_pcb_pipeline(
return
_step(project_id, "classify", "running", "domains and groups")
cmap = _load_constraints_map(ws.local_path("extracted"))
cmap = _load_constraints_map(ws.local_path("extracted"), storage)
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")
@@ -168,7 +188,7 @@ async def run_pcb_pipeline(
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "ai_review", "running", "per-IC datasheet vs layout")
_step(project_id, "ai_review", "running", "layout vs shared library extraction")
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
try:
+29 -11
View File
@@ -1,4 +1,4 @@
"""Per-IC PCB datasheet review — same agentic loop as schematic, layout context."""
"""Per-IC PCB layout review — consumes shared library extraction, no second PDF exam."""
from __future__ import annotations
@@ -7,12 +7,16 @@ 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.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
from backend.periscopex.validate import ReviewResult
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 _find_pdf, review_ic_async
from backend.services.validation import review_ic_async
log = logging.getLogger(__name__)
@@ -27,6 +31,10 @@ def _ensure_recs(findings: list[Finding]) -> None:
f.recommendation = _FIX
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,
@@ -38,7 +46,11 @@ async def review_pcb_ics(
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."""
"""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] = []
@@ -50,14 +62,20 @@ async def review_pcb_ics(
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"})
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 = build_pcb_layout_context(ref, graph, layout, plan, inventory)
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, str(pdf),
graph, constraints_map, ref, None,
on_progress=on_progress,
api_logger=api_logger,
pdf_dir=pdf_dir,
+26 -27
View File
@@ -288,7 +288,7 @@ async def review_ic_async(
graph: DesignGraph,
constraints_map: ConstraintsMap,
ic_ref: str,
pdf_path: str,
pdf_path: str | None,
on_progress: ProgressCallback | None = None,
api_logger: ApiLogger | None = None,
trace_git_commit: str = "unknown",
@@ -301,21 +301,21 @@ async def review_ic_async(
) -> tuple[ReviewResult, dict]:
"""Review one IC against its datasheet. Async, multi-turn.
Returns ``(ReviewResult, trace)`` — ``trace`` is a transcript dict of the
full agentic loop (turns, tool calls + outputs, final submission) for
offline inspection. Trace assembly is best-effort and never affects the
review result.
``pdf_path`` may be ``None`` when the caller already has library
extraction (PCB layout-only exam) — no PDF is attached and citations
are not re-verified against a PDF.
"""
comp = graph.components[ic_ref]
mpn = comp.mpn or comp.value
# Datasheet identity for the trace — hash the original PDF, not the
# trimmed copy, so the reference is stable across trim-heuristic changes.
try:
ds_md5 = hashlib.md5(Path(pdf_path).read_bytes()).hexdigest()
except Exception:
log.exception("trace: datasheet md5 failed for %s", ic_ref)
ds_md5 = None
ds_md5 = None
if pdf_path:
try:
ds_md5 = hashlib.md5(Path(pdf_path).read_bytes()).hexdigest()
except Exception:
log.exception("trace: datasheet md5 failed for %s", ic_ref)
# Pre-compute which designators the excerpt tool will accept for this
# review (neighbors via signal nets only — power/GND fan-out filtered).
@@ -338,7 +338,7 @@ async def review_ic_async(
current_ic=ic_ref,
connected_designators=connected_designators,
graph=graph,
pdf_dir=pdf_dir or Path(pdf_path).parent,
pdf_dir=pdf_dir or (Path(pdf_path).parent if pdf_path else Path(".")),
storage=storage,
cache=excerpt_cache if excerpt_cache is not None else {},
fetch_budget=_PER_REVIEW_FETCH_BUDGET,
@@ -347,7 +347,7 @@ async def review_ic_async(
)
# Trim PDF up-front — both primary and fallback attempts share it.
trimmed_pdf = _select_review_pages(pdf_path)
trimmed_pdf = _select_review_pages(pdf_path) if pdf_path else None
try:
async def _run(provider, model) -> tuple[ReviewResult, dict]:
t0 = time.monotonic()
@@ -377,15 +377,13 @@ async def review_ic_async(
if extra_context.strip():
user_text += "\n\n" + extra_context.strip()
user_blocks: list = []
if trimmed_pdf:
user_blocks.append(PdfBlock(path=Path(trimmed_pdf), cacheable=True))
user_blocks.append(TextBlock(text=user_text, cacheable=True))
initial_msg = Message(
role="user",
content=[
PdfBlock(path=Path(trimmed_pdf), cacheable=True),
TextBlock(
text=user_text,
cacheable=True,
),
],
content=user_blocks,
)
messages: list[Message] = [initial_msg]
@@ -465,13 +463,14 @@ async def review_ic_async(
mpn_by_designator=mpn_by_designator,
connected=connected_designators,
)
verify_finding_citations(
result.findings,
default_pdf=Path(pdf_path),
default_mpn=mpn,
pdf_dir=excerpt_state.pdf_dir,
mpn_by_designator=mpn_by_designator,
)
if pdf_path:
verify_finding_citations(
result.findings,
default_pdf=Path(pdf_path),
default_mpn=mpn,
pdf_dir=excerpt_state.pdf_dir,
mpn_by_designator=mpn_by_designator,
)
turn_record["tool_calls"].append({
"name": "submit_review",
"input": tc.input,
@@ -610,7 +609,7 @@ async def review_ic_async(
return await call_with_fallback("validation", _run)
finally:
if trimmed_pdf != pdf_path:
if trimmed_pdf and pdf_path and trimmed_pdf != pdf_path:
Path(trimmed_pdf).unlink(missing_ok=True)