Show Action on finding cards and inject PCB geometry into AI context.
Layout and AI findings always get action (fallback recommendation). The card renders that sentence in the body. PCB review context now includes vias under the footprint, copper thickness, nearby widths, courtyard, and keepout polygons.
This commit is contained in:
@@ -509,6 +509,8 @@ class LayoutZone(BaseModel):
|
||||
net: str
|
||||
layer: str
|
||||
outlines: list[list[tuple[float, float]]] = []
|
||||
keepout: bool = False
|
||||
name: str = ""
|
||||
|
||||
|
||||
class LayoutGraph(BaseModel):
|
||||
|
||||
@@ -193,12 +193,20 @@ def _pts_xy(node: object) -> list[tuple[float, float]]:
|
||||
|
||||
def _parse_zone(node: object, nets: dict[str, int]) -> list[LayoutZone]:
|
||||
net = str(_val(node, "net_name") or "") or _net_name(node, nets)
|
||||
keepout = _kid(node, "keepout") is not None
|
||||
name = str(_val(node, "name") or "")
|
||||
layer_default = str(_val(node, "layer") or "")
|
||||
layers_el = _kid(node, "layers")
|
||||
if not layer_default and layers_el and len(layers_el) > 1:
|
||||
layer_default = " ".join(str(x) for x in layers_el[1:] if not isinstance(x, list))
|
||||
zones: list[LayoutZone] = []
|
||||
for poly in _kids(node, "filled_polygon"):
|
||||
layer = _val(poly, "layer")
|
||||
for poly in list(_kids(node, "filled_polygon")) + list(_kids(node, "polygon")):
|
||||
layer = str(_val(poly, "layer") or layer_default)
|
||||
pts = _pts_xy(poly)
|
||||
if layer and len(pts) >= 3:
|
||||
zones.append(LayoutZone(net=net, layer=layer, outlines=[pts]))
|
||||
if len(pts) >= 3:
|
||||
zones.append(LayoutZone(
|
||||
net=net, layer=layer, outlines=[pts], keepout=keepout, name=name,
|
||||
))
|
||||
return zones
|
||||
|
||||
|
||||
|
||||
@@ -29,8 +29,13 @@ def assign_pcb_finding_ids(findings: list[Finding]) -> None:
|
||||
for f in findings:
|
||||
counter[f.designator] += 1
|
||||
f.finding_id = f"PCB-{f.designator}-{counter[f.designator]:03d}"
|
||||
if not (f.recommendation or "").strip():
|
||||
f.recommendation = _FALLBACK_FIX
|
||||
rec = (f.recommendation or "").strip()
|
||||
act = (f.action or "").strip()
|
||||
if not rec:
|
||||
rec = act or _FALLBACK_FIX
|
||||
f.recommendation = rec
|
||||
if not act:
|
||||
f.action = rec
|
||||
complete_findings(findings)
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
from backend.periscopex.functional_groups import FunctionalGroupsReport
|
||||
from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph
|
||||
from backend.periscopex.pcb_inventory import PcbInventoryReport
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||
from backend.periscopex.pcb_power_thermal import _footprint_region
|
||||
from backend.periscopex.placement_check import _in_poly
|
||||
from backend.periscopex.si_check import net_length_mm
|
||||
|
||||
PCB_SYSTEM_PROMPT = """\
|
||||
@@ -23,21 +27,29 @@ do not invent millimetres, and do not write Gerbers.
|
||||
- Domains and functional groups (IC + satellites).
|
||||
- Placement vs numeric layout_rules (max_distance_mm, same_layer, thermal vias).
|
||||
- Routing: lengths, skew, stubs — length match only with datasheet millimetres.
|
||||
- Power copper: comment on width/thickness vs I_load only when those numbers \
|
||||
are in the context. Never assume 1 oz or 50 Ω.
|
||||
- Thermal: copper pour / vias under the package vs θJA / layout notes in the extraction.
|
||||
- Power copper: use the parsed copper thickness and nearby trace widths in \
|
||||
the "Parsed board geometry" block. Never assume 1 oz or 50 Ω.
|
||||
- Thermal: use vias-under-footprint counts and drill sizes vs PowerPAD/EPAD \
|
||||
notes. Do not claim the via count or size is missing if that block lists them.
|
||||
- Keepout / courtyard: use parsed courtyard vertices and keepout polygons. \
|
||||
Do not claim antenna keepout geometry is missing if keepout zones are listed.
|
||||
- Kelvin/sense pins, crystal keepout — only if named in the pintable/layout_rules.
|
||||
- Creepage/clearance only if the extraction or IEC number is in context.
|
||||
|
||||
### Evidence
|
||||
Cite numbers from "Parsed board geometry". Say insufficient evidence ONLY \
|
||||
when that extract is empty or explicitly "(none)". If vias, copper thickness, \
|
||||
trace widths, or keepout polygons are listed, you MUST use them.
|
||||
|
||||
### Findings
|
||||
This is a **design review** of geometry vs the library JSON, not a second \
|
||||
datasheet exam. Put board measurements in `finding` (FACT), library text in \
|
||||
`why` (REQUIREMENT). Recommended layout notes are never ERROR. Missing \
|
||||
width/thickness/I_load → say insufficient evidence, do not assume 1 oz.
|
||||
`why` (REQUIREMENT). Recommended layout notes are never ERROR.
|
||||
|
||||
Every finding MUST have status ERROR, WARNING, or INFO and a non-empty \
|
||||
recommendation. Call submit_review. Empty findings with checked_areas is \
|
||||
valid when the layout matches the extraction.
|
||||
`recommendation` (the Action the designer should take) even for INFO. \
|
||||
Call submit_review. Empty findings with checked_areas is valid when the \
|
||||
layout matches the extraction.
|
||||
"""
|
||||
|
||||
|
||||
@@ -71,6 +83,149 @@ def format_library_extraction(cons: ComponentConstraints) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _outline_overlaps_region(
|
||||
outlines: list[list[tuple[float, float]]],
|
||||
region: list[tuple[float, float]],
|
||||
) -> bool:
|
||||
if len(region) < 3:
|
||||
return False
|
||||
rx = [p[0] for p in region]
|
||||
ry = [p[1] for p in region]
|
||||
cx = (min(rx) + max(rx)) / 2
|
||||
cy = (min(ry) + max(ry)) / 2
|
||||
for outline in outlines:
|
||||
if len(outline) < 3:
|
||||
continue
|
||||
for x, y in outline:
|
||||
if _in_poly(x, y, region):
|
||||
return True
|
||||
if _in_poly(cx, cy, outline):
|
||||
return True
|
||||
ox = [p[0] for p in outline]
|
||||
oy = [p[1] for p in outline]
|
||||
if _in_poly((min(ox) + max(ox)) / 2, (min(oy) + max(oy)) / 2, region):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def format_pcb_geometry(
|
||||
ic_ref: str,
|
||||
graph: DesignGraph,
|
||||
layout: LayoutGraph,
|
||||
) -> str:
|
||||
"""Vias under footprint, copper thickness, nearby widths, courtyard/keepout."""
|
||||
lines = ["### Parsed board geometry (from .kicad_pcb — cite these numbers)"]
|
||||
fp = layout.footprints.get(ic_ref)
|
||||
if fp is None:
|
||||
lines.append(f"Footprint {ic_ref}: (none)")
|
||||
lines.append("Vias under footprint: (none)")
|
||||
return "\n".join(lines)
|
||||
|
||||
region = _footprint_region(fp)
|
||||
if fp.courtyard and len(fp.courtyard) >= 3:
|
||||
verts = "; ".join(f"({x:.2f},{y:.2f})" for x, y in fp.courtyard[:16])
|
||||
lines.append(f"Courtyard vertices: {verts}")
|
||||
else:
|
||||
lines.append(
|
||||
"Courtyard: (none in .kicad_pcb; using pad bbox + 1.5 mm for via/trace queries)"
|
||||
)
|
||||
|
||||
by_net: dict[str, list] = defaultdict(list)
|
||||
for v in layout.vias:
|
||||
if _in_poly(v.x, v.y, region):
|
||||
by_net[v.net or "(unnamed)"].append(v)
|
||||
total = sum(len(vs) for vs in by_net.values())
|
||||
if total == 0:
|
||||
lines.append(
|
||||
f"Vias under footprint: 0 (board vias parsed: {len(layout.vias)})"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f"Vias under footprint: {total} (board vias parsed: {len(layout.vias)})"
|
||||
)
|
||||
for net, vs in sorted(by_net.items(), key=lambda kv: (-len(kv[1]), kv[0])):
|
||||
drills = sorted({d for d in (v.drill for v in vs) if d})
|
||||
drill_s = (
|
||||
", ".join(f"{d:g} mm" for d in drills[:6]) if drills else "(drill not in file)"
|
||||
)
|
||||
lines.append(f" {net}: count={len(vs)} drill={drill_s}")
|
||||
|
||||
t = layout.stackup.copper_thickness_mm if layout.stackup else None
|
||||
if t and t > 0:
|
||||
lines.append(f"Copper thickness: {t * 1000:g} µm ({t:g} mm) — parsed stackup, not 1 oz")
|
||||
if layout.stackup.copper_layers:
|
||||
lines.append("Copper layers: " + ", ".join(layout.stackup.copper_layers[:12]))
|
||||
else:
|
||||
lines.append("Copper thickness: (none in parsed stackup)")
|
||||
|
||||
pin_nets: set[str] = set()
|
||||
comp = graph.components.get(ic_ref)
|
||||
if comp:
|
||||
pin_nets.update(n for n in comp.pins.values() if n)
|
||||
pin_nets.update(p.net for p in fp.pads if p.net)
|
||||
|
||||
widths: list[str] = []
|
||||
seen_w: set[tuple[str, float, str]] = set()
|
||||
for s in layout.segments:
|
||||
if s.width <= 0:
|
||||
continue
|
||||
near = (
|
||||
_in_poly(s.start[0], s.start[1], region)
|
||||
or _in_poly(s.end[0], s.end[1], region)
|
||||
or _in_poly(
|
||||
(s.start[0] + s.end[0]) / 2,
|
||||
(s.start[1] + s.end[1]) / 2,
|
||||
region,
|
||||
)
|
||||
)
|
||||
on_pin = bool(s.net) and any(kicad_nets_match(s.net, n) for n in pin_nets)
|
||||
if not near and not on_pin:
|
||||
continue
|
||||
key = (s.net or "", round(s.width, 4), s.layer or "")
|
||||
if key in seen_w:
|
||||
continue
|
||||
seen_w.add(key)
|
||||
where = "courtyard" if near else "pin-net"
|
||||
widths.append(
|
||||
f" {s.net or '(unnamed)'} width={s.width:g} mm layer={s.layer or '—'} ({where})"
|
||||
)
|
||||
if len(widths) >= 24:
|
||||
break
|
||||
if widths:
|
||||
lines.append("Nearby / pin-net trace widths:")
|
||||
lines.extend(widths)
|
||||
else:
|
||||
lines.append("Nearby / pin-net trace widths: (none)")
|
||||
|
||||
keep_hits: list[str] = []
|
||||
keep_board: list[str] = []
|
||||
for z in layout.zones:
|
||||
bbox = ""
|
||||
if z.outlines and z.outlines[0]:
|
||||
xs = [p[0] for p in z.outlines[0]]
|
||||
ys = [p[1] for p in z.outlines[0]]
|
||||
bbox = f" bbox=({min(xs):.1f},{min(ys):.1f})-({max(xs):.1f},{max(ys):.1f})"
|
||||
label = z.name or z.net or "(unnamed)"
|
||||
kind = "keepout" if z.keepout else "zone"
|
||||
entry = f" {kind} {label} layer={z.layer or '—'} net={z.net or '—'}{bbox}"
|
||||
if z.keepout:
|
||||
keep_board.append(entry)
|
||||
if _outline_overlaps_region(z.outlines, region):
|
||||
keep_hits.append(entry)
|
||||
if keep_hits:
|
||||
lines.append("Zones overlapping courtyard:")
|
||||
lines.extend(keep_hits[:16])
|
||||
else:
|
||||
lines.append("Zones overlapping courtyard: (none)")
|
||||
if keep_board:
|
||||
lines.append("Board keepout polygons (antenna / RF):")
|
||||
lines.extend(keep_board[:16])
|
||||
else:
|
||||
lines.append("Board keepout polygons: (none)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_pcb_layout_context(
|
||||
ic_ref: str,
|
||||
graph: DesignGraph,
|
||||
@@ -139,10 +294,17 @@ def build_pcb_layout_context(
|
||||
seen.add(net_name)
|
||||
mm = net_length_mm(layout, net_name)
|
||||
lines.append(f" {net_name}: {mm:.2f} mm")
|
||||
lines.append(format_pcb_geometry(ic_ref, graph, layout))
|
||||
elif layout is None:
|
||||
lines.append("No LayoutGraph — skip millimetre claims.")
|
||||
lines.append("### Parsed board geometry (from .kicad_pcb — cite these numbers)")
|
||||
lines.append("Vias under footprint: (none)")
|
||||
lines.append("Copper thickness: (none in parsed stackup)")
|
||||
lines.append("Nearby / pin-net trace widths: (none)")
|
||||
lines.append("Board keepout polygons: (none)")
|
||||
else:
|
||||
lines.append(f"No footprint for {ic_ref} on the PCB.")
|
||||
lines.append(format_pcb_geometry(ic_ref, graph, layout))
|
||||
if inventory and inventory.nets:
|
||||
sample = inventory.nets[:12]
|
||||
lines.append("Inventory sample (name length_mm pair):")
|
||||
|
||||
@@ -924,6 +924,8 @@ def _parse_review(
|
||||
why = (
|
||||
"Unverified: no verbatim datasheet quote. " + why
|
||||
).strip()
|
||||
rec = str(item.get("recommendation") or item.get("action") or "").strip()
|
||||
act = str(item.get("action") or rec).strip()
|
||||
findings.append(Finding(
|
||||
designator=ic_ref,
|
||||
mpn=mpn,
|
||||
@@ -936,8 +938,8 @@ def _parse_review(
|
||||
status=status,
|
||||
source_page=page,
|
||||
source_quote=item.get("source_quote", ""),
|
||||
recommendation=item.get("recommendation", ""),
|
||||
action=str(item.get("recommendation") or ""),
|
||||
recommendation=rec,
|
||||
action=act,
|
||||
reference=f"{src_mpn} datasheet p.{page if page is not None else '?'}",
|
||||
source="review",
|
||||
finding_class="REVIEW",
|
||||
|
||||
@@ -797,7 +797,17 @@ SUBMIT_REVIEW_SCHEMA = {
|
||||
},
|
||||
"recommendation": {
|
||||
"type": "string",
|
||||
"description": "What to change to fix the issue. Only for ERROR/WARNING.",
|
||||
"description": (
|
||||
"What to change on the board or schematic. "
|
||||
"Required for every finding, including INFO."
|
||||
),
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Same as recommendation if you prefer that name. "
|
||||
"Required for every finding when recommendation is empty."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["finding", "why", "status", "source_page"],
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -27,8 +28,14 @@ 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
|
||||
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:
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.32.1 — 2026-09-20 — PCB Action on card + parsed geometry in AI context
|
||||
|
||||
Finding cards always show an **Action.** sentence (`action`, else `recommendation`, else a default). PCB AI context now includes vias under the footprint, copper thickness, nearby trace widths, courtyard, and keepout polygons from `.kicad_pcb`.
|
||||
|
||||
- [Fixed] Layout/AI findings no longer show an empty Action chip; the sentence is on the card.
|
||||
- [Changed] PCB exam injects parsed via counts/drills, stackup thickness, pin-net widths, courtyard/keepout. Insufficient evidence only when those extracts are empty.
|
||||
- [Changed] submit_review `recommendation` is required for INFO as well as ERROR/WARNING.
|
||||
|
||||
## 2.32.0 — 2026-09-20 — Finding engine (schema + PCB)
|
||||
|
||||
Same finding object on schematic analysis and PCB exam: FACT / REQUIREMENT / INFERENCE, datasheet provenance in the rule DB, RULE vs REVIEW, designer decisions, independent severity/confidence/evidence.
|
||||
|
||||
@@ -58,9 +58,10 @@ export function FindingCard({
|
||||
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||
const commentCount = comments?.length ?? 0;
|
||||
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
|
||||
const actionText = (finding.action || finding.recommendation || "").trim();
|
||||
const actionText =
|
||||
(finding.action || finding.recommendation || "").trim() ||
|
||||
"Review this finding against the datasheet and the board, then change the design if it applies.";
|
||||
const expandable =
|
||||
!!actionText ||
|
||||
!!finding.facts ||
|
||||
!!finding.requirement ||
|
||||
!!finding.inference ||
|
||||
@@ -120,14 +121,18 @@ export function FindingCard({
|
||||
{finding.why && !finding.facts && !finding.requirement && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{finding.why}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
|
||||
<span className="font-medium text-foreground">Action. </span>
|
||||
{actionText}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{actionText && (
|
||||
{expandable && (
|
||||
<span className="inline-flex items-center h-7 px-2 text-xs text-muted-foreground">
|
||||
<ChevronDown
|
||||
className={cn("h-3.5 w-3.5 mr-1 transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
Action
|
||||
Details
|
||||
</span>
|
||||
)}
|
||||
{hasCommentSupport && finding.finding_id && (
|
||||
@@ -250,12 +255,6 @@ export function FindingCard({
|
||||
) : null}
|
||||
</dl>
|
||||
)}
|
||||
{actionText && (
|
||||
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
|
||||
<span className="font-medium text-foreground">Action. </span>
|
||||
{actionText}
|
||||
</p>
|
||||
)}
|
||||
{hasCommentSupport && finding.finding_id && (
|
||||
<FindingComments
|
||||
findingId={finding.finding_id}
|
||||
|
||||
@@ -143,6 +143,21 @@ def test_empty_recommendation_gets_action():
|
||||
assert f.recommendation.strip() == f.action.strip()
|
||||
|
||||
|
||||
def test_pcb_review_ai_finding_action_without_recommendation():
|
||||
f = Finding(
|
||||
designator="U1",
|
||||
finding="PowerPAD vias",
|
||||
why="thermal pad",
|
||||
status="INFO",
|
||||
source="pcb_review",
|
||||
recommendation="",
|
||||
action="",
|
||||
)
|
||||
complete_finding(f)
|
||||
assert f.action.strip()
|
||||
assert f.finding_class == "REVIEW"
|
||||
|
||||
|
||||
def test_recommended_rc_cap_is_review_not_rule():
|
||||
f = Finding(
|
||||
designator="C1",
|
||||
|
||||
@@ -76,6 +76,23 @@ def test_kicad_sch_is_rejected_as_pcb(tmp_path: Path):
|
||||
parse_kicad_pcb(p)
|
||||
|
||||
|
||||
def test_parse_keepout_zone_polygon(tmp_path: Path):
|
||||
p = tmp_path / "keepout.kicad_pcb"
|
||||
p.write_text("""(kicad_pcb (version 20240108) (generator pcbnew)
|
||||
(zone (net 0) (net_name "") (layer "F.Cu") (name "ANT_KEEPOUT")
|
||||
(keepout (tracks not_allowed) (vias not_allowed) (copperpour not_allowed))
|
||||
(polygon (pts (xy 0 0) (xy 10 0) (xy 10 8) (xy 0 8)))
|
||||
)
|
||||
)
|
||||
""")
|
||||
g = parse_kicad_pcb(p)
|
||||
assert len(g.zones) == 1
|
||||
z = g.zones[0]
|
||||
assert z.keepout is True
|
||||
assert z.name == "ANT_KEEPOUT"
|
||||
assert len(z.outlines[0]) == 4
|
||||
|
||||
|
||||
def test_empty_board_parses(tmp_path: Path):
|
||||
p = tmp_path / "empty.kicad_pcb"
|
||||
p.write_text("(kicad_pcb (version 20240108) (generator pcbnew))\n")
|
||||
|
||||
@@ -208,6 +208,101 @@ def test_inventory_lists_net_length_and_pair():
|
||||
assert names["USB_DP"].pair == "USB_DM"
|
||||
|
||||
|
||||
def test_layout_context_includes_via_counts():
|
||||
from backend.periscopex.models import LayoutStackup, LayoutVia, LayoutZone
|
||||
|
||||
graph = _graph()
|
||||
plan = FunctionalGroupsReport(
|
||||
domains=[PlacementDomain(domain_id="3v3", power_nets=["+3V3"], ic_refs=["U3"])],
|
||||
groups=[PlacementIcGroup(ref="U3", satellites=[])],
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
stackup=LayoutStackup(
|
||||
copper_layers=["F.Cu", "B.Cu"],
|
||||
dielectrics=[],
|
||||
copper_thickness_mm=0.035,
|
||||
),
|
||||
footprints={
|
||||
"U3": LayoutFootprint(
|
||||
reference="U3", x=0, y=0, layer="F.Cu",
|
||||
courtyard=[(-2, -2), (2, -2), (2, 2), (-2, 2)],
|
||||
pads=[LayoutPad(number="1", x=0, y=0, net="+3V3")],
|
||||
),
|
||||
},
|
||||
vias=[
|
||||
LayoutVia(x=0.2, y=0.1, net="GND", drill=0.3),
|
||||
LayoutVia(x=-0.4, y=0.0, net="GND", drill=0.3),
|
||||
],
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(5, 0), width=0.45, layer="F.Cu", net="+3V3"),
|
||||
],
|
||||
zones=[
|
||||
LayoutZone(
|
||||
net="",
|
||||
layer="F.Cu",
|
||||
keepout=True,
|
||||
name="ANT_KEEPOUT",
|
||||
outlines=[[(20, 20), (30, 20), (30, 30), (20, 30)]],
|
||||
),
|
||||
],
|
||||
)
|
||||
text = build_pcb_layout_context("U3", graph, layout, plan)
|
||||
assert "Vias under footprint: 2" in text
|
||||
assert "count=2" in text
|
||||
assert "0.3 mm" in text
|
||||
assert "Copper thickness: 35 µm" in text
|
||||
assert "width=0.45 mm" in text
|
||||
assert "ANT_KEEPOUT" in text
|
||||
assert "Board keepout polygons" in text
|
||||
|
||||
|
||||
def test_pcb_ai_finding_gets_action():
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.models import Finding
|
||||
from backend.services.pcb_validation import _ensure_recs
|
||||
|
||||
f = Finding(
|
||||
designator="U1",
|
||||
mpn="PADIC",
|
||||
finding="PowerPAD under U1",
|
||||
why="datasheet thermal pad",
|
||||
status="INFO",
|
||||
recommendation="",
|
||||
action="",
|
||||
source="pcb_review",
|
||||
)
|
||||
_ensure_recs([f])
|
||||
complete_finding(f)
|
||||
assert f.action.strip()
|
||||
assert f.recommendation.strip()
|
||||
assert f.finding_class == "REVIEW"
|
||||
assert f.facts
|
||||
assert f.requirement
|
||||
|
||||
|
||||
def test_parse_review_action_field_without_recommendation():
|
||||
from backend.periscopex.validate import _parse_review
|
||||
|
||||
result = _parse_review(
|
||||
{
|
||||
"findings": [{
|
||||
"finding": "EPAD vias present",
|
||||
"why": "layout note",
|
||||
"status": "INFO",
|
||||
"source_page": 12,
|
||||
"source_quote": "Connect EPAD with vias to GND.",
|
||||
"action": "Keep the nine 0.3 mm vias under U5 EPAD.",
|
||||
}],
|
||||
"checked_areas": ["thermal"],
|
||||
},
|
||||
"U5",
|
||||
"ESP",
|
||||
)
|
||||
f = result.findings[0]
|
||||
assert f.action == "Keep the nine 0.3 mm vias under U5 EPAD."
|
||||
assert f.recommendation == "Keep the nine 0.3 mm vias under U5 EPAD."
|
||||
|
||||
|
||||
def test_layout_context_includes_domain_and_group():
|
||||
graph = _graph()
|
||||
plan = FunctionalGroupsReport(
|
||||
@@ -221,6 +316,7 @@ def test_layout_context_includes_domain_and_group():
|
||||
assert "Domain: 3v3" in text
|
||||
assert "Functional group: U3" in text
|
||||
assert "Footprint U3" in text
|
||||
assert "Vias under footprint" in text
|
||||
|
||||
|
||||
def test_merge_reports_prefixes_do_not_collide():
|
||||
|
||||
Reference in New Issue
Block a user