Apply the finding engine to schematic and PCB reviews.
FACT, REQUIREMENT, and INFERENCE are separate fields; datasheet provenance lives in the rule DB so Recommended cannot stay ERROR; LLM output is REVIEW. Designer decisions persist across rescans. Schematic and PCB share one library and the same finding object in the report UI.
This commit is contained in:
@@ -0,0 +1,288 @@
|
|||||||
|
"""Shared finding engine for schematic and PCB.
|
||||||
|
|
||||||
|
FACT / REQUIREMENT / INFERENCE stay in separate fields. Datasheet
|
||||||
|
provenance lives in the rule catalog, not in LLM prose. Recommended
|
||||||
|
never becomes ERROR. LLM output is REVIEW, never RULE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Iterable, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.periscopex.models import Finding
|
||||||
|
|
||||||
|
Provenance = Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"]
|
||||||
|
FindingClass = Literal["RULE", "RISK", "REVIEW", "INFO"]
|
||||||
|
EvidenceStatus = Literal["SUFFICIENT", "INSUFFICIENT"]
|
||||||
|
|
||||||
|
_LLM_SOURCES = frozenset({None, "review", "pcb_review"})
|
||||||
|
_SOFT_PROVENANCE = frozenset({"RECOMMENDED", "TYPICAL", "EXAMPLE"})
|
||||||
|
|
||||||
|
|
||||||
|
class RuleRecord(BaseModel):
|
||||||
|
"""One row in the rule DB (not free-text from the model)."""
|
||||||
|
rule_id: str
|
||||||
|
provenance: Provenance
|
||||||
|
finding_class: FindingClass
|
||||||
|
domain: Literal["schema", "pcb", "shared"] = "shared"
|
||||||
|
source: str | None = None
|
||||||
|
requirement: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DesignerDecision(BaseModel):
|
||||||
|
"""Stored intent so a rescan does not re-nag the same FACT."""
|
||||||
|
decision_id: str
|
||||||
|
fingerprint: str
|
||||||
|
rule_id: str | None = None
|
||||||
|
designator: str = ""
|
||||||
|
net: str | None = None
|
||||||
|
aspect: str | None = None
|
||||||
|
intent: str = ""
|
||||||
|
reason: str = ""
|
||||||
|
user_id: str = ""
|
||||||
|
created_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# rule_id → catalog. Optional override: (rule_id, source).
|
||||||
|
_RULES: dict[str, RuleRecord] = {}
|
||||||
|
_RULES_BY_SOURCE: dict[tuple[str, str], RuleRecord] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _add(rule_id: str, provenance: Provenance, cls: FindingClass, *,
|
||||||
|
domain: Literal["schema", "pcb", "shared"] = "shared",
|
||||||
|
source: str | None = None, requirement: str = "") -> None:
|
||||||
|
rec = RuleRecord(
|
||||||
|
rule_id=rule_id, provenance=provenance, finding_class=cls,
|
||||||
|
domain=domain, source=source, requirement=requirement,
|
||||||
|
)
|
||||||
|
_RULES[rule_id] = rec
|
||||||
|
if source:
|
||||||
|
_RULES_BY_SOURCE[(rule_id, source)] = rec
|
||||||
|
|
||||||
|
|
||||||
|
def _seed() -> None:
|
||||||
|
if _RULES:
|
||||||
|
return
|
||||||
|
# Schematic
|
||||||
|
for rid, req in (
|
||||||
|
("PE-MUX-001", "Pin function must match the mux / datasheet pin table."),
|
||||||
|
("PE-NC-001", "NC pins must not be connected."),
|
||||||
|
("PE-DEC-001", "Supply pin requires a decoupling capacitor on that net."),
|
||||||
|
("PE-DEC-002", "Decoupling capacitor value vs datasheet."),
|
||||||
|
("PE-I2C-001", "I2C bus requires pull-ups."),
|
||||||
|
("PE-I2C-002", "I2C pull-up value vs datasheet."),
|
||||||
|
("PE-RST-001", "Reset pin requires the specified pull."),
|
||||||
|
("PE-RST-002", "Reset pull value vs datasheet."),
|
||||||
|
("PE-BOM-001", "BOM MPN must match the netlist part."),
|
||||||
|
("PE-BOM-002", "BOM line missing from the netlist."),
|
||||||
|
("PE-DNP-001", "DNP / fitted state vs netlist."),
|
||||||
|
("PE-DRT-001", "Operating voltage must not exceed the capacitor rating."),
|
||||||
|
):
|
||||||
|
_add(rid, "MANDATORY", "RULE", domain="schema", requirement=req)
|
||||||
|
for rid in ("PE-TH-001", "PE-TH-003"):
|
||||||
|
_add(rid, "TYPICAL", "RISK", domain="schema",
|
||||||
|
requirement="Thermal estimate only with I_load and θJA from specs.")
|
||||||
|
_add("PE-PWR-001", "MANDATORY", "RISK", domain="shared",
|
||||||
|
source="power_margin_check",
|
||||||
|
requirement="I_load vs regulator capability (not Iout_max).")
|
||||||
|
_add("PE-PWR-001", "MANDATORY", "RULE", domain="pcb",
|
||||||
|
source="pcb_power_thermal",
|
||||||
|
requirement="Trace width × copper thickness vs I_load (IPC-2221 when ΔT known).")
|
||||||
|
for rid, prov, cls, req in (
|
||||||
|
("PE-SEQ-001", "RECOMMENDED", "RISK", "Power sequencing from datasheet notes."),
|
||||||
|
("PE-FLT-001", "RECOMMENDED", "RISK", "Filter topology vs datasheet."),
|
||||||
|
("PE-FLT-002", "RECOMMENDED", "RISK", "Filter component vs datasheet."),
|
||||||
|
("PE-FLT-003", "RECOMMENDED", "RISK", "Filter cutoff vs datasheet."),
|
||||||
|
("PE-XTAL-001", "MANDATORY", "RULE", "Crystal load capacitance vs Cl."),
|
||||||
|
("PE-XTAL-002", "RECOMMENDED", "RISK", "Crystal layout / load-cap note."),
|
||||||
|
("PE-XTAL-003", "RECOMMENDED", "RISK", "Crystal drive / Cl note."),
|
||||||
|
("PE-LF-001", "TYPICAL", "INFO", "Lifecycle / NRND."),
|
||||||
|
("PE-LF-002", "TYPICAL", "INFO", "Lifecycle / last-time-buy."),
|
||||||
|
("PE-LF-003", "TYPICAL", "INFO", "Lifecycle / obsolete."),
|
||||||
|
("PE-INT-001", "TYPICAL", "REVIEW", "Internal feature (ESD/pull-up) vs net."),
|
||||||
|
("PE-ESR-001", "RECOMMENDED", "RISK", "HF / ESR coverage."),
|
||||||
|
("PE-ERRATA-001", "MANDATORY", "REVIEW", "Published errata."),
|
||||||
|
):
|
||||||
|
_add(rid, prov, cls, domain="schema", requirement=req)
|
||||||
|
# PCB geometry
|
||||||
|
_add("PE-LAY-001", "MANDATORY", "RULE", domain="pcb",
|
||||||
|
requirement="PCB pad net must match the schematic pin net.")
|
||||||
|
_add("PE-LAY-002", "MANDATORY", "RULE", domain="pcb",
|
||||||
|
requirement="Schematic ref must have a footprint (except #PWR).")
|
||||||
|
_add("PE-LAY-003", "TYPICAL", "INFO", domain="pcb",
|
||||||
|
requirement="KiCad /net vs flattened schematic net is the same net.")
|
||||||
|
for rid, req in (
|
||||||
|
("PE-PLC-001", "Decoupling proximity max_distance_mm from layout_rules."),
|
||||||
|
("PE-PLC-002", "Thermal via min_via_count from layout_rules."),
|
||||||
|
("PE-PLC-003", "same_layer decoupling from layout_rules."),
|
||||||
|
("PE-PLC-004", "Keepout from layout_rules."),
|
||||||
|
("PE-SI-001", "Differential skew vs length_match mm."),
|
||||||
|
):
|
||||||
|
_add(rid, "MANDATORY", "RULE", domain="pcb", requirement=req)
|
||||||
|
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
|
||||||
|
requirement="Via current share from I_load and via count (no invented table).")
|
||||||
|
_add("PE-THM-001", "RECOMMENDED", "RISK", domain="pcb",
|
||||||
|
requirement="Dissipation vs courtyard pour/vias when P is known.")
|
||||||
|
_add("PE-KEL-001", "RECOMMENDED", "RISK", domain="pcb",
|
||||||
|
requirement="Kelvin/sense pin should not share the load net.")
|
||||||
|
_add("PE-STCH-001", "TYPICAL", "INFO", domain="pcb",
|
||||||
|
requirement="GND stitch via in the bbox of a signal over a GND pour.")
|
||||||
|
|
||||||
|
|
||||||
|
_seed()
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_rule(rule_id: str | None, source: str | None = None) -> RuleRecord | None:
|
||||||
|
if not rule_id:
|
||||||
|
return None
|
||||||
|
if source and (rule_id, source) in _RULES_BY_SOURCE:
|
||||||
|
return _RULES_BY_SOURCE[(rule_id, source)]
|
||||||
|
return _RULES.get(rule_id)
|
||||||
|
|
||||||
|
|
||||||
|
def finding_fingerprint(f: Finding) -> str:
|
||||||
|
return "|".join([
|
||||||
|
f.rule_id or f.source or "",
|
||||||
|
f.designator or "",
|
||||||
|
f.net or "",
|
||||||
|
f.aspect or "",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def complete_finding(f: Finding) -> Finding:
|
||||||
|
"""Fill FACT/REQUIREMENT/INFERENCE, clamp provenance and evidence."""
|
||||||
|
rec = lookup_rule(f.rule_id, f.source)
|
||||||
|
if not (f.facts or "").strip():
|
||||||
|
f.facts = f.finding
|
||||||
|
if not (f.requirement or "").strip():
|
||||||
|
f.requirement = (rec.requirement if rec and rec.requirement else None) or f.why
|
||||||
|
if not (f.action or "").strip():
|
||||||
|
f.action = f.recommendation or ""
|
||||||
|
if not (f.recommendation or "").strip() and f.action:
|
||||||
|
f.recommendation = f.action
|
||||||
|
|
||||||
|
llm = (f.source in {None, "review", "pcb_review"}) and not f.rule_id
|
||||||
|
|
||||||
|
if rec:
|
||||||
|
if not f.provenance:
|
||||||
|
f.provenance = rec.provenance
|
||||||
|
if not f.finding_class:
|
||||||
|
f.finding_class = rec.finding_class
|
||||||
|
elif llm or f.source in {"review", "pcb_review"}:
|
||||||
|
f.finding_class = f.finding_class or "REVIEW"
|
||||||
|
elif f.source:
|
||||||
|
f.finding_class = f.finding_class or "RULE"
|
||||||
|
else:
|
||||||
|
f.finding_class = f.finding_class or "REVIEW"
|
||||||
|
|
||||||
|
if f.source in {"review", "pcb_review"}:
|
||||||
|
f.finding_class = "REVIEW"
|
||||||
|
|
||||||
|
quote = (f.source_quote or "").strip()
|
||||||
|
if f.evidence_status == "INSUFFICIENT":
|
||||||
|
pass
|
||||||
|
elif llm and not quote:
|
||||||
|
f.evidence_status = "INSUFFICIENT"
|
||||||
|
elif not (f.facts or "").strip() or not (f.requirement or "").strip():
|
||||||
|
f.evidence_status = "INSUFFICIENT"
|
||||||
|
else:
|
||||||
|
f.evidence_status = f.evidence_status or "SUFFICIENT"
|
||||||
|
|
||||||
|
if f.evidence_status == "INSUFFICIENT":
|
||||||
|
if f.status == "ERROR":
|
||||||
|
f.status = "WARNING" if quote or rec else "INFO"
|
||||||
|
if f.finding_class == "RULE":
|
||||||
|
f.finding_class = "INFO"
|
||||||
|
if not (f.inference or "").strip():
|
||||||
|
f.inference = (
|
||||||
|
"Insufficient evidence — not inventing a violation or default "
|
||||||
|
"(no 1 oz / 10 °C / 50 Ω / IEC)."
|
||||||
|
)
|
||||||
|
|
||||||
|
prov = f.provenance or (rec.provenance if rec else None)
|
||||||
|
if prov in _SOFT_PROVENANCE and f.status == "ERROR":
|
||||||
|
f.status = "WARNING"
|
||||||
|
if f.finding_class == "RULE":
|
||||||
|
f.finding_class = "RISK"
|
||||||
|
|
||||||
|
if f.finding_class == "RULE" and f.confidence is None:
|
||||||
|
f.confidence = 0.9 if f.evidence_status == "SUFFICIENT" else 0.3
|
||||||
|
elif f.finding_class == "REVIEW" and f.confidence is None:
|
||||||
|
f.confidence = 0.55 if quote else 0.35
|
||||||
|
elif f.confidence is None:
|
||||||
|
f.confidence = 0.5 if f.evidence_status == "SUFFICIENT" else 0.25
|
||||||
|
|
||||||
|
if not (f.why or "").strip():
|
||||||
|
f.why = f.requirement
|
||||||
|
if not (f.finding or "").strip():
|
||||||
|
f.finding = f.facts
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def complete_findings(findings: list[Finding]) -> None:
|
||||||
|
for f in findings:
|
||||||
|
complete_finding(f)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_decisions(
|
||||||
|
findings: list[Finding],
|
||||||
|
decisions: Iterable[DesignerDecision] | Iterable[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Rescan must not re-nag a stored designer decision as ERROR."""
|
||||||
|
by_fp: dict[str, DesignerDecision] = {}
|
||||||
|
for raw in decisions:
|
||||||
|
d = raw if isinstance(raw, DesignerDecision) else DesignerDecision.model_validate(raw)
|
||||||
|
by_fp[d.fingerprint] = d
|
||||||
|
if not by_fp:
|
||||||
|
return
|
||||||
|
for f in findings:
|
||||||
|
fp = finding_fingerprint(f)
|
||||||
|
d = by_fp.get(fp)
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
f.suppressed = True
|
||||||
|
f.decision_id = d.decision_id
|
||||||
|
f.status = "INFO"
|
||||||
|
f.finding_class = "INFO"
|
||||||
|
extra = f"Designer decision {d.decision_id}: {d.intent}"
|
||||||
|
if d.reason:
|
||||||
|
extra = f"{extra} — {d.reason}"
|
||||||
|
if extra not in (f.inference or ""):
|
||||||
|
f.inference = ((f.inference or "") + " " + extra).strip()
|
||||||
|
if not (f.action or "").strip():
|
||||||
|
f.action = "Keep the stored intent; no layout/schematic change required."
|
||||||
|
f.recommendation = f.action
|
||||||
|
|
||||||
|
|
||||||
|
def decision_from_review(
|
||||||
|
finding: Finding,
|
||||||
|
*,
|
||||||
|
state: str,
|
||||||
|
reason: str,
|
||||||
|
user_id: str,
|
||||||
|
) -> DesignerDecision | None:
|
||||||
|
if state not in {"wontfix", "false_positive"}:
|
||||||
|
return None
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||||
|
fp = finding_fingerprint(finding)
|
||||||
|
return DesignerDecision(
|
||||||
|
decision_id=f"DEC-{finding.designator}-{ts}",
|
||||||
|
fingerprint=fp,
|
||||||
|
rule_id=finding.rule_id,
|
||||||
|
designator=finding.designator,
|
||||||
|
net=finding.net,
|
||||||
|
aspect=finding.aspect,
|
||||||
|
intent=state,
|
||||||
|
reason=reason,
|
||||||
|
user_id=user_id,
|
||||||
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_decision(store: list[dict[str, Any]], decision: DesignerDecision) -> list[dict[str, Any]]:
|
||||||
|
out = [row for row in store if row.get("fingerprint") != decision.fingerprint]
|
||||||
|
out.append(decision.model_dump())
|
||||||
|
return out
|
||||||
@@ -356,6 +356,19 @@ class Finding(BaseModel):
|
|||||||
cad_sheet: str | None = None # schematic sheet filename for plugin sync
|
cad_sheet: str | None = None # schematic sheet filename for plugin sync
|
||||||
cad_uuid: str | None = None # KiCad symbol/pin uuid
|
cad_uuid: str | None = None # KiCad symbol/pin uuid
|
||||||
variant: str | None = None # DNP / ECO / assembly variant
|
variant: str | None = None # DNP / ECO / assembly variant
|
||||||
|
# Finding engine (docs/motore-finding.md) — optional for legacy JSON.
|
||||||
|
facts: str = ""
|
||||||
|
requirement: str = ""
|
||||||
|
inference: str = ""
|
||||||
|
provenance: Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"] | None = None
|
||||||
|
finding_class: Literal["RULE", "RISK", "REVIEW", "INFO"] | None = None
|
||||||
|
confidence: float | None = None
|
||||||
|
evidence_status: Literal["SUFFICIENT", "INSUFFICIENT"] | None = None
|
||||||
|
calculation: str = ""
|
||||||
|
assumptions: list[str] = []
|
||||||
|
action: str = ""
|
||||||
|
decision_id: str | None = None
|
||||||
|
suppressed: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ValidationReport(BaseModel):
|
class ValidationReport(BaseModel):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from backend.periscopex.derating import build_derating_table
|
from backend.periscopex.finding_engine import complete_findings
|
||||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
||||||
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
||||||
from backend.periscopex.pcb_power_thermal import (
|
from backend.periscopex.pcb_power_thermal import (
|
||||||
@@ -31,6 +31,7 @@ def assign_pcb_finding_ids(findings: list[Finding]) -> None:
|
|||||||
f.finding_id = f"PCB-{f.designator}-{counter[f.designator]:03d}"
|
f.finding_id = f"PCB-{f.designator}-{counter[f.designator]:03d}"
|
||||||
if not (f.recommendation or "").strip():
|
if not (f.recommendation or "").strip():
|
||||||
f.recommendation = _FALLBACK_FIX
|
f.recommendation = _FALLBACK_FIX
|
||||||
|
complete_findings(findings)
|
||||||
|
|
||||||
|
|
||||||
def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
|
def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ def check_pcb_power_traces(
|
|||||||
),
|
),
|
||||||
source="pcb_power_thermal",
|
source="pcb_power_thermal",
|
||||||
rule_id="PE-PWR-001",
|
rule_id="PE-PWR-001",
|
||||||
|
evidence_status="INSUFFICIENT",
|
||||||
|
calculation="",
|
||||||
|
assumptions=["IPC-2221 not applied without copper thickness."],
|
||||||
net=net,
|
net=net,
|
||||||
pins=[],
|
pins=[],
|
||||||
))
|
))
|
||||||
@@ -162,6 +165,8 @@ def check_pcb_power_traces(
|
|||||||
recommendation="Add Tjmax to the shared library extraction, then re-run PCB review.",
|
recommendation="Add Tjmax to the shared library extraction, then re-run PCB review.",
|
||||||
source="pcb_power_thermal",
|
source="pcb_power_thermal",
|
||||||
rule_id="PE-PWR-001",
|
rule_id="PE-PWR-001",
|
||||||
|
evidence_status="INSUFFICIENT",
|
||||||
|
assumptions=["ΔT is Tjmax − 25 °C from the datasheet, never a default 10 °C."],
|
||||||
net=net,
|
net=net,
|
||||||
pins=[],
|
pins=[],
|
||||||
))
|
))
|
||||||
@@ -189,6 +194,10 @@ def check_pcb_power_traces(
|
|||||||
),
|
),
|
||||||
source="pcb_power_thermal",
|
source="pcb_power_thermal",
|
||||||
rule_id="PE-PWR-001",
|
rule_id="PE-PWR-001",
|
||||||
|
calculation=(
|
||||||
|
f"I = k·ΔT^0.44·A^0.725 → {i_allow:.3g} A; I_load={i_load:.3g} A"
|
||||||
|
),
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
net=net,
|
net=net,
|
||||||
pins=[],
|
pins=[],
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ are in the context. Never assume 1 oz or 50 Ω.
|
|||||||
- Creepage/clearance only if the extraction or IEC number is in context.
|
- Creepage/clearance only if the extraction or IEC number is in context.
|
||||||
|
|
||||||
### Findings
|
### 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.
|
||||||
|
|
||||||
Every finding MUST have status ERROR, WARNING, or INFO and a non-empty \
|
Every finding MUST have status ERROR, WARNING, or INFO and a non-empty \
|
||||||
recommendation. Call submit_review. Empty findings with checked_areas is \
|
recommendation. Call submit_review. Empty findings with checked_areas is \
|
||||||
valid when the layout matches the extraction.
|
valid when the layout matches the extraction.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
from backend.periscopex.finding_engine import complete_findings
|
||||||
from backend.periscopex.models import (
|
from backend.periscopex.models import (
|
||||||
ComponentConstraints,
|
ComponentConstraints,
|
||||||
ComponentType,
|
ComponentType,
|
||||||
@@ -149,6 +150,12 @@ neighbor's datasheet that you fetched via `get_datasheet_excerpt` — this \
|
|||||||
links the page number to the right datasheet.
|
links the page number to the right datasheet.
|
||||||
- **recommendation**: What to change (for ERROR/WARNING only).
|
- **recommendation**: What to change (for ERROR/WARNING only).
|
||||||
|
|
||||||
|
This is a **design review**, not a design rule. Put observations in \
|
||||||
|
`finding` (FACT), datasheet text in `why` (REQUIREMENT), and judgment \
|
||||||
|
only there — do not invent millimetres, IEC numbers, or typical values. \
|
||||||
|
Recommended datasheet notes are never ERROR. If evidence is missing, say \
|
||||||
|
so (Unverified) instead of guessing.
|
||||||
|
|
||||||
### Calibration
|
### Calibration
|
||||||
ERROR only for clear violations: required pin floating, voltage exceeding \
|
ERROR only for clear violations: required pin floating, voltage exceeding \
|
||||||
absolute max, required external component completely missing, wrong \
|
absolute max, required external component completely missing, wrong \
|
||||||
@@ -922,12 +929,19 @@ def _parse_review(
|
|||||||
mpn=mpn,
|
mpn=mpn,
|
||||||
source_designator=src_designator,
|
source_designator=src_designator,
|
||||||
finding=item["finding"],
|
finding=item["finding"],
|
||||||
|
facts=str(item.get("finding") or ""),
|
||||||
|
requirement=why,
|
||||||
|
inference=str(item.get("inference") or ""),
|
||||||
why=why,
|
why=why,
|
||||||
status=status,
|
status=status,
|
||||||
source_page=page,
|
source_page=page,
|
||||||
source_quote=item.get("source_quote", ""),
|
source_quote=item.get("source_quote", ""),
|
||||||
recommendation=item.get("recommendation", ""),
|
recommendation=item.get("recommendation", ""),
|
||||||
|
action=str(item.get("recommendation") or ""),
|
||||||
reference=f"{src_mpn} datasheet p.{page if page is not None else '?'}",
|
reference=f"{src_mpn} datasheet p.{page if page is not None else '?'}",
|
||||||
|
source="review",
|
||||||
|
finding_class="REVIEW",
|
||||||
|
evidence_status="SUFFICIENT" if quote else "INSUFFICIENT",
|
||||||
))
|
))
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
print(f"Skipping malformed finding for {ic_ref}: {exc}", file=sys.stderr)
|
print(f"Skipping malformed finding for {ic_ref}: {exc}", file=sys.stderr)
|
||||||
@@ -937,11 +951,12 @@ def _parse_review(
|
|||||||
|
|
||||||
|
|
||||||
def assign_finding_ids(findings: list[Finding]) -> None:
|
def assign_finding_ids(findings: list[Finding]) -> None:
|
||||||
"""Assign finding_id: {designator}-{001}, {002}, ..."""
|
"""Assign finding_id: {designator}-{001}, {002}, ... then run the finding engine."""
|
||||||
counter: Counter[str] = Counter()
|
counter: Counter[str] = Counter()
|
||||||
for f in findings:
|
for f in findings:
|
||||||
counter[f.designator] += 1
|
counter[f.designator] += 1
|
||||||
f.finding_id = f"{f.designator}-{counter[f.designator]:03d}"
|
f.finding_id = f"{f.designator}-{counter[f.designator]:03d}"
|
||||||
|
complete_findings(findings)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.periscopex.finding_engine import (
|
||||||
|
apply_decisions,
|
||||||
|
complete_findings,
|
||||||
|
decision_from_review,
|
||||||
|
upsert_decision,
|
||||||
|
)
|
||||||
from backend.periscopex.models import Finding
|
from backend.periscopex.models import Finding
|
||||||
from backend.periscopex.review_workflow import (
|
from backend.periscopex.review_workflow import (
|
||||||
ReviewError,
|
ReviewError,
|
||||||
@@ -49,6 +55,20 @@ async def get_report(project_id: str, request: Request):
|
|||||||
merged = merge_schema_pcb_reports(schema, pcb)
|
merged = merge_schema_pcb_reports(schema, pcb)
|
||||||
if merged is None:
|
if merged is None:
|
||||||
raise HTTPException(404, "Report not found — run the pipeline first")
|
raise HTTPException(404, "Report not found — run the pipeline first")
|
||||||
|
findings = _findings_from_report(merged)
|
||||||
|
complete_findings(findings)
|
||||||
|
dec_key = f"{prefix}/decisions.json"
|
||||||
|
if storage.exists(dec_key):
|
||||||
|
try:
|
||||||
|
apply_decisions(findings, storage.read_json(dec_key) or [])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
merged["findings"] = [json.loads(f.model_dump_json()) for f in findings]
|
||||||
|
summary = {"ERROR": 0, "WARNING": 0, "INFO": 0, "total": len(findings)}
|
||||||
|
for f in findings:
|
||||||
|
if f.status in summary:
|
||||||
|
summary[f.status] += 1
|
||||||
|
merged["summary"] = summary
|
||||||
return JSONResponse(merged)
|
return JSONResponse(merged)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,7 +168,17 @@ async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody,
|
|||||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||||
user_id = get_user_id(request)
|
user_id = get_user_id(request)
|
||||||
key, report_data = _load_report(storage, owner_user_id, project_id)
|
key, report_data = _load_report(storage, owner_user_id, project_id)
|
||||||
ids = {f.finding_id for f in _findings_from_report(report_data) if f.finding_id}
|
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||||
|
findings = _findings_from_report(report_data)
|
||||||
|
ids = {f.finding_id for f in findings if f.finding_id}
|
||||||
|
if finding_id not in ids:
|
||||||
|
pcb_key = f"{prefix}/pcb_report.json"
|
||||||
|
if storage.exists(pcb_key):
|
||||||
|
pcb_data = storage.read_json(pcb_key)
|
||||||
|
pcb_findings = _findings_from_report(pcb_data)
|
||||||
|
if finding_id in {f.finding_id for f in pcb_findings if f.finding_id}:
|
||||||
|
key, report_data, findings = pcb_key, pcb_data, pcb_findings
|
||||||
|
ids = {f.finding_id for f in findings if f.finding_id}
|
||||||
if finding_id not in ids:
|
if finding_id not in ids:
|
||||||
raise HTTPException(404, "Finding not found")
|
raise HTTPException(404, "Finding not found")
|
||||||
try:
|
try:
|
||||||
@@ -164,6 +194,22 @@ async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody,
|
|||||||
raise HTTPException(400, str(exc)) from exc
|
raise HTTPException(400, str(exc)) from exc
|
||||||
report_data["review_states"] = states
|
report_data["review_states"] = states
|
||||||
storage.write_json(key, report_data)
|
storage.write_json(key, report_data)
|
||||||
|
if body.state in {"wontfix", "false_positive"}:
|
||||||
|
found = next(
|
||||||
|
(f for f in _findings_from_report(report_data) if f.finding_id == finding_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if found is not None:
|
||||||
|
dec = decision_from_review(
|
||||||
|
found, state=body.state, reason=body.reason, user_id=user_id,
|
||||||
|
)
|
||||||
|
if dec is not None:
|
||||||
|
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||||
|
dkey = f"{prefix}/decisions.json"
|
||||||
|
existing = storage.read_json(dkey) if storage.exists(dkey) else []
|
||||||
|
if not isinstance(existing, list):
|
||||||
|
existing = []
|
||||||
|
storage.write_json(dkey, upsert_decision(existing, dec))
|
||||||
return JSONResponse(states.get(finding_id) or {"state": "open", "reason": ""})
|
return JSONResponse(states.get(finding_id) or {"state": "open", "reason": ""})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ untouched. Does not write ``.kicad_pcb`` or packing coordinates.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -17,7 +18,7 @@ from backend.periscopex.cad_bridge import (
|
|||||||
cad_index_from_graph,
|
cad_index_from_graph,
|
||||||
write_cad_bridge,
|
write_cad_bridge,
|
||||||
)
|
)
|
||||||
from backend.periscopex.functional_groups import build_placement_plan
|
from backend.periscopex.finding_engine import apply_decisions
|
||||||
from backend.periscopex.graph import build_graph
|
from backend.periscopex.graph import build_graph
|
||||||
from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph, ValidationReport
|
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_checks import assign_pcb_finding_ids, run_pcb_checks
|
||||||
@@ -227,6 +228,12 @@ async def run_pcb_pipeline(
|
|||||||
_step(project_id, "write_report", "running")
|
_step(project_id, "write_report", "running")
|
||||||
assign_pcb_finding_ids(findings)
|
assign_pcb_finding_ids(findings)
|
||||||
annotate_findings_cad(findings, cad_index_from_graph(graph))
|
annotate_findings_cad(findings, cad_index_from_graph(graph))
|
||||||
|
dec_path = ws.local_path("decisions.json")
|
||||||
|
if dec_path.is_file():
|
||||||
|
try:
|
||||||
|
apply_decisions(findings, json.loads(dec_path.read_text()))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("decisions.json apply failed")
|
||||||
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
|
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
|
||||||
for f in findings:
|
for f in findings:
|
||||||
summary[f.status] = summary.get(f.status, 0) + 1
|
summary[f.status] = summary.get(f.status, 0) + 1
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from typing import Awaitable, Callable
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from backend.periscopex.finding_engine import apply_decisions
|
||||||
from backend.periscopex.models import (
|
from backend.periscopex.models import (
|
||||||
ComponentConstraints,
|
ComponentConstraints,
|
||||||
ComponentType,
|
ComponentType,
|
||||||
@@ -789,6 +790,12 @@ async def validate_design_async(
|
|||||||
def _write_report(paused: bool = False) -> ValidationReport:
|
def _write_report(paused: bool = False) -> ValidationReport:
|
||||||
annotate_findings_cad(all_findings, graph.cad_index)
|
annotate_findings_cad(all_findings, graph.cad_index)
|
||||||
assign_finding_ids(all_findings)
|
assign_finding_ids(all_findings)
|
||||||
|
dec_path = existing_path.with_name("decisions.json")
|
||||||
|
if dec_path.is_file():
|
||||||
|
try:
|
||||||
|
apply_decisions(all_findings, json.loads(dec_path.read_text()))
|
||||||
|
except Exception:
|
||||||
|
log.exception("decisions.json apply failed")
|
||||||
summary = {"total": len(all_findings), "ERROR": 0, "WARNING": 0, "INFO": 0}
|
summary = {"total": len(all_findings), "ERROR": 0, "WARNING": 0, "INFO": 0}
|
||||||
for f in all_findings:
|
for f in all_findings:
|
||||||
summary[f.status] = summary.get(f.status, 0) + 1
|
summary[f.status] = summary.get(f.status, 0) + 1
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Motore finding — schema e PCB (stesso oggetto)
|
||||||
|
|
||||||
|
Fonte: specifica di Michele (Project chat). Vale per **analizzatore schematico e PCB**. Un finding è un oggetto riproducibile, non un paragrafo LLM.
|
||||||
|
|
||||||
|
Libreria datasheet: **una** (`library/extracted` + extracted/ di progetto). L’esame PDF profondo gira **una volta** (pipeline schema). Il job PCB consuma quella cache e aggiunge solo geometria.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Tre strati — non mescolare
|
||||||
|
|
||||||
|
Ogni finding ha **tre campi distinti**:
|
||||||
|
|
||||||
|
| Strato | Nome campo | Chi lo produce | Cosa può contenere |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **FACT** | `facts` | Motore deterministico (netlist, BOM, `.kicad_pcb`) | Solo osservabili: pin, net, mm, V, A, width, thickness |
|
||||||
|
| **REQUIREMENT** | `requirement` | Rule DB + estrazione libreria (citazione, pagina, MPN) | Testo **sourced**. Provenance nel DB, non nella prosa del modello |
|
||||||
|
| **INFERENCE** | `inference` | Ingegnere / LLM **solo** qui | Giudizio. Mai copiare un’inferenza in `facts` o `requirement` |
|
||||||
|
|
||||||
|
Non mescolare: un check non scrive “probabilmente sottodimensionato” dentro `facts`. Se manca un parametro, `evidence_status=INSUFFICIENT` e si **dice** che manca — non si inventa 1 oz, 10 °C, 50 Ω, IEC.
|
||||||
|
|
||||||
|
`finding` / `why` restano per compatibilità UI (copia di facts / requirement).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Provenance datasheet (rule DB)
|
||||||
|
|
||||||
|
Sul **record di regola**, non nel testo LLM:
|
||||||
|
|
||||||
|
| `provenance` | Significato | Classe / severity |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **MANDATORY** | Shall / abs-max / pin NC | Può essere **RULE** + ERROR |
|
||||||
|
| **RECOMMENDED** | Should / layout note | **Mai ERROR**. RISK o INFO |
|
||||||
|
| **TYPICAL** | Typical / default application | INFO o RISK, mai RULE ERROR |
|
||||||
|
| **EXAMPLE** | Figure / typical application circuit | REVIEW o INFO |
|
||||||
|
|
||||||
|
**Recommended ≠ ERROR.** Il clamp è nel motore (`complete_finding`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Classi — Design Rule ≠ Design Review
|
||||||
|
|
||||||
|
| `finding_class` | Ruolo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **RULE** | Violazione di un requisito MANDATORY misurato (FACT vs REQUIREMENT). Deterministico. |
|
||||||
|
| **RISK** | Margine / derating / inferenza ingegneristica con evidenza parziale. |
|
||||||
|
| **REVIEW** | Esame AI (schema o PCB). Non è una design rule. |
|
||||||
|
| **INFO** | Inventario, evidenza insufficiente, decisione del progettista, skip onesto. |
|
||||||
|
|
||||||
|
LLM `source=review` / `pcb_review` → sempre **REVIEW**, mai RULE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Intent + memoria DECISION
|
||||||
|
|
||||||
|
Il progettista può dichiarare un intent (es. **PSEL tied low on purpose**). Persistenza: `decisions.json` nel progetto, fingerprint:
|
||||||
|
|
||||||
|
`{rule_id\|source}|{designator}|{net}|{aspect}`
|
||||||
|
|
||||||
|
`wontfix` / `false_positive` sul report **scrive** una DECISION. Al rescan il motore **non ri-naga** come ERROR: finding `suppressed=true`, `finding_class=INFO`, `decision_id` valorizzato.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Severity, confidence, evidence — indipendenti
|
||||||
|
|
||||||
|
- `status` (severity): ERROR | WARNING | INFO
|
||||||
|
- `confidence`: 0–1 (quanto è solido il FACT+REQUIREMENT)
|
||||||
|
- `evidence_status`: SUFFICIENT | INSUFFICIENT
|
||||||
|
|
||||||
|
Alto confidence + INFO è legale (misura chiara, non è un fault). Bassa confidence + ERROR **non** è legale se evidence è INSUFFICIENT: si declassa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Oggetto finding (riproducibile)
|
||||||
|
|
||||||
|
Campi obbligatori del motore (oltre a designator / MPN / ids esistenti):
|
||||||
|
|
||||||
|
```
|
||||||
|
facts, requirement, inference
|
||||||
|
source (pagina/MPN/rule_id)
|
||||||
|
calculation
|
||||||
|
assumptions[]
|
||||||
|
confidence
|
||||||
|
status (severity)
|
||||||
|
action # cosa fare; UI usa anche recommendation
|
||||||
|
provenance
|
||||||
|
finding_class
|
||||||
|
evidence_status
|
||||||
|
decision_id? suppressed?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Libreria unica / esame unico
|
||||||
|
|
||||||
|
| Store | Path |
|
||||||
|
| --- | --- |
|
||||||
|
| Estrazioni IC | `library/extracted/{safe_mpn}.json` (shared) ∪ `extracted/` progetto |
|
||||||
|
| PDF blob | `library/datasheets/blobs/{md5}.pdf` |
|
||||||
|
|
||||||
|
PCB: `review_ic_async(..., pdf_path=None)` se c’è pintable. Skip: `no library extraction — run schematic review first`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Cosa è solo PCB
|
||||||
|
|
||||||
|
Geometria board: width/thickness, via, pour courtyard, placement mm, return/stitch, pad-net vs schema (`PE-LAY-*` gerarchia `/net`). **Non** duplicare check schema (mux, LED, filtri netlist).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Fasi (non fingere completezza)
|
||||||
|
|
||||||
|
**Fase A (questa slice):** oggetto finding + clamp provenance/class + decisions + UI schema/PCB + libreria condivisa + check geometria già gated.
|
||||||
|
|
||||||
|
**Fase B (pianificata):** gerarchia component→system; timing; energy/thermal solo con θJA/P/I_load; ratings; derating PASS/MARGIN/RISK; PI; SI oltre skew mm; return path oltre stitch INFO; ESD/EMI; thermal FEM; BOM↔PCB↔datasheet; SPOF come REVIEW.
|
||||||
+46
-205
@@ -1,238 +1,79 @@
|
|||||||
# Piano — PCB review (esame AI + check deterministici)
|
# Piano — PCB review (esame geometria + stesso motore finding)
|
||||||
|
|
||||||
Fonte di verità per **Periscope Layout review**. Non è un auto-placer.
|
Specifica finding: [`motore-finding.md`](motore-finding.md). Schema e PCB usano **lo stesso oggetto**. Questo file è il job layout.
|
||||||
|
|
||||||
**Delta vs revisione precedente:** l’esame del board è **agentico come la pipeline schema** (datasheet + neighborhood + tool, findings ERROR/WARNING/INFO con **sempre** una `recommendation`). Restano i check numerici gated. Auto-placement / pack / write-back pcbnew restano **fuori**.
|
**Non** è un auto-placer. PCB-only = geometria (mm, width, copper, via, pour, return stitch, pad-net). Validazione netlist / mux / LED resta schema.
|
||||||
|
|
||||||
Prodotto: esaminare un `.kicad_pcb` già (parzialmente) sbrogliato — placement, routing, impedenze, lunghezze, decoupling, filtri, derating, compliance datasheet — e dire *cosa è sbagliato e come sistemarlo*. Non si spostano footprint e non si scrive rame.
|
Contratto:
|
||||||
|
|
||||||
Contratto rispetto a [`docs/piano-implementazione.md`](piano-implementazione.md):
|
| | Schema | PCB review | Placement F2 (fuori) |
|
||||||
|
|
||||||
| | Schema (già in produzione) | PCB review (questo piano) | Placement F2 pack (fuori) |
|
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Promessa | Lo schema rispetta il datasheet | Il **layout esistente** rispetta datasheet + geometria | Proporre xy nuovi |
|
| Promessa | Netlist vs libreria datasheet | Layout vs libreria + geometria | xy nuovi |
|
||||||
| Metodo | Check deterministici + **review LLM per-IC** | Stesso spirito: check + **review LLM per-IC sul PCB** | Skeleton `placement_pack.json` |
|
| Motore finding | FACT / REQUIREMENT / INFERENCE | uguale | — |
|
||||||
| Trigger | BOM + netlist / `.kicad_sch` | + `.kicad_pcb` | PCB + `layout_rules` numerici |
|
| Deep exam PDF | **Una volta** → `library/extracted` | Consuma cache, `pdf_path=None` | — |
|
||||||
| Output | Finding pin/net, derating, review | Finding mm / net / Z0 / skew / via EP / layout AI; `PE-PLC*` `PE-SI*` `PE-LAY*` `PE-Z0*` | Proposte xy |
|
| Job | `MODE=run` | `MODE=pcb` | `MODE=placement` |
|
||||||
| Job | `MODE=run` (`status`) | `MODE=pcb` (`pcb_status`) | `MODE=placement` (`placement_status`) |
|
| Output | `report.json` | `pcb_report.json` (merge in GET /report) | pack json |
|
||||||
|
|
||||||
Senza `.kicad_pcb` il progetto resta uno schema completo. Il report schema **non** si riempie di finding layout.
|
Senza `.kicad_pcb` il progetto è schema-completo. Il report schema non si riempie di PE-PLC/PE-SI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. Postura: esame AI, non progettazione
|
## Motore (schema + PCB)
|
||||||
|
|
||||||
Stesso spirito della Direct Datasheet Review: il modello legge datasheet + contesto del circuito **e** geometria del footprint/net, usa tool sul grafo, sottomette finding. Non progetta il board.
|
Vedi `motore-finding.md`. Invarianti:
|
||||||
|
|
||||||
### Domains e functional Groups
|
1. Non mescolare FACT / REQUIREMENT / INFERENCE.
|
||||||
|
2. Provenance **MANDATORY | RECOMMENDED | TYPICAL | EXAMPLE** nel rule DB. **Recommended ≠ ERROR**.
|
||||||
|
3. Classi **RULE | RISK | REVIEW | INFO**. LLM = REVIEW, mai RULE.
|
||||||
|
4. `decisions.json` + fingerprint: rescan non ri-naga (PSEL low intenzionale, ecc.).
|
||||||
|
5. `status`, `confidence`, `evidence_status` indipendenti.
|
||||||
|
6. Evidenza insufficiente → lo si dice, niente 1 oz / 10 °C / 50 Ω / IEC inventati.
|
||||||
|
7. Oggetto riproducibile: facts, requirement, source, calculation, assumptions, confidence, severity, action.
|
||||||
|
|
||||||
Obbligatori come contesto dell’esame (non come packing):
|
UI report (schema e layout): stessa `FindingCard` — Fact / Requirement / Inference, badge classe e provenance.
|
||||||
|
|
||||||
- **Domains** = isole sulle power net (`functional_groups.py` / `placement_plan.json`).
|
Libreria: `library/extracted/{safe_mpn}.json` ∪ extracted/ progetto. PDF in `library/datasheets/blobs/`.
|
||||||
- **Groups** = IC + satelliti con `role_hint` (`decoupling`, `bulk`, `filter`, `crystal`, …).
|
|
||||||
|
|
||||||
Il job PCB **classifica** (riusa `build_placement_plan`) e passa domains/groups al reviewer e all’inventory. Non propone coordinate.
|
|
||||||
|
|
||||||
La pipeline Placement (`POST …/placement/start`) resta topologia routing-first. F2 pack/export **parcheggiati**.
|
|
||||||
|
|
||||||
### Placement = verifica (deterministico + AI)
|
|
||||||
|
|
||||||
Misura ciò che è **già** sul board: distanza pad-cap, layer, via EP, keepout, crystal load caps. `placement_check` (`PE-PLC-001`…`004`) è il nucleo numerico. L’AI copre note di layout del datasheet **senza mm strutturato** (citazione + `Unverified:` se la quote non verifica), sempre con recommendation.
|
|
||||||
|
|
||||||
### Routing = la stessa postura
|
|
||||||
|
|
||||||
Lunghezze, coppie, bus, Z0, pad-net vs schema. `si_check` (`PE-SI-001`) solo con `length_match` mm. L’AI può flaggare crossing/loop evidenti **solo** se il contesto geometria lo mostra; niente 3W/IEC inventati.
|
|
||||||
|
|
||||||
### Findings (come parte 1 / schema)
|
|
||||||
|
|
||||||
Ogni finding PCB:
|
|
||||||
|
|
||||||
- `status`: **ERROR** | **WARNING** | **INFO**
|
|
||||||
- `recommendation` **obbligatoria** anche per INFO (cosa fare: spostare, allungare/accorciare, cambiare rating, verificare stackup, …)
|
|
||||||
- `rule_id` sui check automatici; review AI: `source=pcb_review`
|
|
||||||
- IDs `PCB-{designator}-{001}` per non collidere con lo schema
|
|
||||||
|
|
||||||
Normalization resta **downgrade-only**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Job parallelo (riuso stack)
|
## Job PCB (invariato nello stack)
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─ MODE=run → status → report.json (schema)
|
┌─ MODE=run → status → report.json
|
||||||
Project files ───┼─ MODE=placement → placement_status → placement_plan.json
|
Project files ───┼─ MODE=placement → placement_status → placement_plan.json
|
||||||
└─ MODE=pcb → pcb_status → pcb_report.json
|
└─ MODE=pcb → pcb_status → pcb_report.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Riuso
|
Stages: `ensure_graph` → `parse_pcb` → `classify` → `inventory` → `checks` → `ai_review` (layout vs library, no PDF) → `write_report`.
|
||||||
|
|
||||||
| Pezzo | Path | Ruolo PCB |
|
Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net` vs net schema: **PE-LAY-003** una volta, non per-pad (`kicad_nets_match`).
|
||||||
| --- | --- | --- |
|
|
||||||
| Workspace / SSE | `pipeline.py` | `PipelineWorkspace`, broker |
|
|
||||||
| Dispatch | `job_runner.py`, `pipeline_worker.py` | `MODE=pcb` |
|
|
||||||
| API | `routers/pipeline.py` | `pcb/start`, `cancel`, `events`, inventory |
|
|
||||||
| Meta | `projects.py` | `pcb_status`, `pcb_state`, `pcb_execution_name`, `pcb_cancel_requested` |
|
|
||||||
| Parser | `parsers_kicad_pcb.py` | `LayoutGraph` |
|
|
||||||
| Groups | `functional_groups.py` | domains + groups |
|
|
||||||
| Placement | `placement_check.py` | PE-PLC-* |
|
|
||||||
| SI | `si_check.py` | PE-SI-001 |
|
|
||||||
| Filtri | `filter_check.py` | contesto; proximity solo con mm |
|
|
||||||
| Derating | `derating.py` | Vop vs Vrated se entrambi noti |
|
|
||||||
| Impedenza | `impedance_traces.py` | inventory Z0; finding se target numerico |
|
|
||||||
| Review LLM | `services/validation.py` + prompt PCB | per-IC, stesso loop tool/`submit_review` |
|
|
||||||
| Cad bridge | `cad_bridge.py` | `target: pcb` |
|
|
||||||
| UI | report `finding-card`, sidebar, `types.ts`, `api.ts` | tab Layout, merge findings |
|
|
||||||
|
|
||||||
### Contratto job
|
### Check PCB (geometria) — gated
|
||||||
|
|
||||||
- Non tocca `status` schema.
|
| ID | Gate |
|
||||||
- Soft-cancel `pcb_cancel_requested`.
|
|
||||||
- Mutex vs analisi e vs placement (event log condiviso).
|
|
||||||
- SSE `pcb_step_update` / `pcb_complete` / `pcb_error` / `pcb_cancelled`.
|
|
||||||
- Locale: `local/pcb/{id}`.
|
|
||||||
- Heal zombie come placement.
|
|
||||||
- LLM: stesso provider DeepSeek della review schema; `ApiLogger` stage `pcb_review`; billing via `get_billing()` (NullBilling in OSS).
|
|
||||||
- Senza API key in test: skip AI, check deterministici restano.
|
|
||||||
|
|
||||||
### Stages
|
|
||||||
|
|
||||||
1. `ensure_graph` — `design_graph.json` o graph_build.
|
|
||||||
2. `parse_pcb` — `layout_graph.json` / `parse_kicad_pcb`. Fail → error.
|
|
||||||
3. `classify` — domains + groups (`build_placement_plan`); scrive `functional_groups.json` se manca.
|
|
||||||
4. `inventory` — tracce, bus, lunghezze, Z0 → `pcb_inventory.json`.
|
|
||||||
5. `checks` — deterministici fail-soft (`run_pcb_checks`).
|
|
||||||
6. `ai_review` — per-IC come schema, contesto layout + group/domain + inventory del vicinato.
|
|
||||||
7. `write_report` — `pcb_report.json`, cad-bridge, IDs `PCB-*`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Ingressi
|
|
||||||
|
|
||||||
| Ingresso | Obbligatorio v1 |
|
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `.kicad_pcb` (`has_pcb`) | Sì |
|
| PE-LAY-001…003 | pad/ref/hierarchy nets |
|
||||||
| `design_graph.json` | Sì |
|
| PE-PLC-001…004 | `layout_rules` numerici |
|
||||||
| `extracted/` + `layout_rules` / pintable | Per check numerici e AI |
|
| PE-SI-001 | `length_match` mm |
|
||||||
| **Shared library** `library/extracted/{safe_mpn}.json` | **Unico** store schema+PCB. Deep exam PDF una volta (pipeline schema). |
|
| PE-DRT-001 | Vop e Vrated |
|
||||||
| PDF datasheet | Solo per estrazione / review **schema**. PCB **non** riattacca il PDF. |
|
| PE-PWR-001 | I_load + width; IPC solo con thickness e Tjmax |
|
||||||
| Stackup nel PCB | Per Z0 e ampacity; skip se assente |
|
| PE-VIA-001 | via count + I_load (INFO, no tabella inventata) |
|
||||||
|
| PE-THM-001 | P=I_load×drop, courtyard senza pour/via |
|
||||||
|
| PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net |
|
||||||
|
| PE-STCH-001 | pour GND + segnale senza via GND nel bbox |
|
||||||
|
| Creepage | **skip** senza numero datasheet/IEC |
|
||||||
|
| Crystal keepout | PE-PLC-004 se kind=keepout |
|
||||||
|
|
||||||
|
AI PCB: skip IC senza pintable (`run schematic review first`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Check (deterministici) + AI
|
## Fasi
|
||||||
|
|
||||||
### 3.1 Inventario (non finding)
|
**A — shipped (questa slice):** oggetto finding condiviso, clamp provenance/class, decisions, UI, libreria unica, geometria gated, net-slash.
|
||||||
|
|
||||||
Net con rame: lunghezza, layer, width min/max, via, coppia, bus, Z0 se stackup. Domains/groups elencati.
|
**B — pianificata (non finta):** gerarchia component→system; timing; energy/thermal solo con parametri; ratings; derating PASS/MARGIN/RISK; PI; SI oltre skew; return path oltre stitch INFO; ESD/EMI; thermal FEM; BOM↔PCB↔datasheet; SPOF come REVIEW.
|
||||||
|
|
||||||
### 3.2 Deterministici
|
**C — fuori:** auto-place, write-back pcbnew, unire i job, sshd/keys.
|
||||||
|
|
||||||
| ID | Cosa | Gate |
|
Deploy: `ssh periscope` + `/root/periscope/scripts/update-periscope.sh`.
|
||||||
| --- | --- | --- |
|
|
||||||
| `PE-LAY-001` | Pad net ≠ pin net schema | entrambi noti |
|
|
||||||
| `PE-LAY-002` | Ref schema senza footprint (skip `#PWR`) | graph + PCB |
|
|
||||||
| `PE-PLC-001`…`004` | decoupling / via / same_layer / keepout | `layout_rules` numerici |
|
|
||||||
| `PE-SI-001` | skew coppia | `length_match` mm |
|
|
||||||
| `PE-DRT-001` | Vop > Vrated sul cap (derating.py) | entrambe le tensioni |
|
|
||||||
| `PE-Z0-001` | Z0 fuori target | target in regola o netclass; mai 50 Ω default |
|
|
||||||
| `PE-PWR-001` | Larghezza × spessore rame vs I_load | I_load + width; IPC-2221 solo con `copper_thickness_mm` e Tjmax (ΔT=Tjmax−25 °C) |
|
|
||||||
| `PE-VIA-001` | I_load / N via | geometria via + I_load; INFO (niente tabella via inventata) |
|
|
||||||
| `PE-THM-001` | P=I_load×(Vin−Vout) senza pour/via courtyard | I_load, Vin/Vout, courtyard |
|
|
||||||
| `PE-KEL-001` | Pin sense/Kelvin su net di carico | nome pintable + ≥2 altri sul net |
|
|
||||||
| `PE-STCH-001` | Segnale sopra pour GND senza via GND nel bbox | zone GND + segmenti; INFO |
|
|
||||||
| Creepage | — | **skip** senza numero datasheet/IEC |
|
|
||||||
| Crystal keepout | `PE-PLC-004` | `layout_rules` kind=keepout |
|
|
||||||
|
|
||||||
Filtri: `check_filters` **non** duplicato nel report PCB (resta schema). L’AI e i groups usano `role_hint=filter` per proximity se c’è mm.
|
|
||||||
|
|
||||||
### 3.3 AI exam (layout-only, shared library)
|
|
||||||
|
|
||||||
Per ogni IC con **pintable in `library/extracted` o extracted/ di progetto** (stesso JSON):
|
|
||||||
|
|
||||||
- **Non** si ri-legge il PDF. L’esame datasheet profondo è quello della pipeline schema.
|
|
||||||
- System prompt **PCB**: layout vs JSON di libreria.
|
|
||||||
- User: `format_library_extraction` + `build_pcb_layout_context`.
|
|
||||||
- `review_ic_async(..., pdf_path=None)` — niente attach, niente quote-verify PDF.
|
|
||||||
- Skip: `no library extraction — run schematic review first`.
|
|
||||||
- Recommendation obbligatoria; isolation per-IC.
|
|
||||||
|
|
||||||
UI: sezione report **PCB exam** (`pcb-exam-section.tsx`) — findings `source=pcb_review` e PE-PWR/THM/VIA/KEL/STCH. Nascosta con `?domain=schema`.
|
|
||||||
|
|
||||||
### 3.4 Skip (no folklore)
|
|
||||||
|
|
||||||
3W, creepage IEC senza numero, CPWG, HV isolation, length-match USB spec, confronto foto TI vs gerber, 1 oz / 10 °C / 50 Ω di default.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. UI
|
|
||||||
|
|
||||||
| Superficie | Comportamento |
|
|
||||||
| --- | --- |
|
|
||||||
| Hub | **Run PCB review** se `hasPcb` |
|
|
||||||
| `/project/[id]/pcb` | Stepper SSE (include `ai_review`) |
|
|
||||||
| Sidebar | **Layout** → `/pcb` |
|
|
||||||
| Report | Merge + sezione **PCB exam**; `?domain=layout`; badge Layout; recommendation sempre visibile |
|
|
||||||
| Inventory | Traces/buses/Z0 + domains/groups |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Fasi di implementazione
|
|
||||||
|
|
||||||
Dopo **ogni** fase: test delle funzioni toccate, commit, push. Dopo ogni macro-fase operativa: deploy `ssh periscope` + `scripts/update-periscope.sh` (niente chiavi/sshd).
|
|
||||||
|
|
||||||
### Fase 0 — Split schema / layout
|
|
||||||
|
|
||||||
Togliere `placement_check` / `si_check` da `_run_deterministic_checks` schema. Eval schema: `layout=None` resta no-op.
|
|
||||||
|
|
||||||
**Done when:** schema senza PCB = zero `PE-PLC`/`PE-SI`.
|
|
||||||
|
|
||||||
### Fase 1 — Job + deterministici + UI (macro operativa)
|
|
||||||
|
|
||||||
Meta `pcb_*`, worker, router, `run_pcb_checks`, inventory, report merge, UI start/progress/filtro.
|
|
||||||
|
|
||||||
**Done when:** upload PCB → start → finding nel report. Deploy.
|
|
||||||
|
|
||||||
### Fase 2 — AI exam + domains/groups (macro operativa)
|
|
||||||
|
|
||||||
`classify` + `ai_review` per-IC, prompt PCB, recommendation obbligatoria, changelog Layout.
|
|
||||||
|
|
||||||
**Done when:** con API key, IC con libreria producono finding `source=pcb_review` o coverage vuota; senza key, skip loggato. Deploy.
|
|
||||||
|
|
||||||
### Fase 3 — Plugin + eval PCB
|
|
||||||
|
|
||||||
Cad-bridge pcbnew; golden fixture. Packing resta fuori.
|
|
||||||
|
|
||||||
### Fase 4 — Libreria unica + sezione PCB exam (macro operativa)
|
|
||||||
|
|
||||||
Un store `library/extracted` (più extracted/ di progetto). PCB non duplica l’esame PDF. Sezione report PCB exam + PE-PWR/THM/VIA/KEL/STCH.
|
|
||||||
|
|
||||||
**Done when:** IC senza pintable skipped; con libreria, AI layout-only; check IPC solo con stackup+Tjmax. Deploy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Fuori scope
|
|
||||||
|
|
||||||
- Auto-placement, packing mm, write-back pcbnew.
|
|
||||||
- Autore di sbroglio / change width.
|
|
||||||
- SPICE / IBIS / EM.
|
|
||||||
- Inventare mm, Ω, V/mm.
|
|
||||||
- EasyEDA, Gerber come ingresso, PADS layout.
|
|
||||||
- Unire il job PCB nella run schema.
|
|
||||||
- Toccare ssh keys / `sshd`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Done (fixture)
|
|
||||||
|
|
||||||
1. Decoupling 15 mm vs regola 2 mm → `PE-PLC-001` + recommendation.
|
|
||||||
2. 1 mm → niente proximity.
|
|
||||||
3. Skew coppia > `length_match` → `PE-SI-001`.
|
|
||||||
4. Pad net mismatch → `PE-LAY-001`.
|
|
||||||
5. Senza PCB → 400 sul start.
|
|
||||||
6. AI: contesto contiene domain/group; finding senza recommendation rifiutati/completati dal parser.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Relazione
|
|
||||||
|
|
||||||
Wave G di `piano-implementazione.md` = questo job di **esame** (ora anche LLM). F2 pack non entra.
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
What's new in Periscope.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
- [New] `docs/motore-finding.md` — engine spec. `finding_engine.py` + `decisions.json`.
|
||||||
|
- [New] Report cards show Fact / Requirement / Inference, class, provenance, insufficient evidence.
|
||||||
|
- [Changed] Recommended datasheet notes cannot stay ERROR. LLM findings are REVIEW, never RULE.
|
||||||
|
- [Changed] Schematic and PCB share `library/extracted`; PCB does not re-read the PDF.
|
||||||
|
|
||||||
## 2.31.0 — 2026-09-20 — Shared library PCB exam (no second PDF pass)
|
## 2.31.0 — 2026-09-20 — Shared library PCB exam (no second PDF pass)
|
||||||
|
|
||||||
Schematic and PCB share `library/extracted`. PCB AI consumes that cache (pintable required; no PDF attach). Report **PCB exam** section plus power-trace/thermal checks.
|
Schematic and PCB share `library/extracted`. PCB AI consumes that cache (pintable required; no PDF attach). Report **PCB exam** section plus power-trace/thermal checks.
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ export function FindingCard({
|
|||||||
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
|
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
|
||||||
const expandable =
|
const expandable =
|
||||||
!!finding.recommendation ||
|
!!finding.recommendation ||
|
||||||
|
!!finding.facts ||
|
||||||
|
!!finding.requirement ||
|
||||||
|
!!finding.inference ||
|
||||||
|
!!finding.calculation ||
|
||||||
(hasCommentSupport && !!finding.finding_id) ||
|
(hasCommentSupport && !!finding.finding_id) ||
|
||||||
!!(projectId && finding.finding_id && onReviewSaved);
|
!!(projectId && finding.finding_id && onReviewSaved);
|
||||||
|
|
||||||
@@ -187,10 +191,64 @@ export function FindingCard({
|
|||||||
{finding.rule_id}
|
{finding.rule_id}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{finding.finding_class && (
|
||||||
|
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{finding.finding_class}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{finding.provenance && (
|
||||||
|
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{finding.provenance}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{finding.evidence_status === "INSUFFICIENT" && (
|
||||||
|
<span className="inline-flex items-center rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:text-amber-300">
|
||||||
|
Insufficient evidence
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{finding.suppressed && (
|
||||||
|
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
Decision
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
{(finding.facts || finding.requirement || finding.inference) && (
|
||||||
|
<dl className="mt-2 space-y-1.5 text-xs text-muted-foreground">
|
||||||
|
{finding.facts ? (
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium text-foreground">Fact</dt>
|
||||||
|
<dd>{finding.facts}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{finding.requirement ? (
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium text-foreground">Requirement</dt>
|
||||||
|
<dd>{finding.requirement}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{finding.inference ? (
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium text-foreground">Inference</dt>
|
||||||
|
<dd>{finding.inference}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{finding.calculation ? (
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium text-foreground">Calculation</dt>
|
||||||
|
<dd className="font-mono">{finding.calculation}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{typeof finding.confidence === "number" ? (
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium text-foreground">Confidence</dt>
|
||||||
|
<dd>{Math.round(finding.confidence * 100)}%</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
{finding.recommendation && (
|
{finding.recommendation && (
|
||||||
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
|
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
|
||||||
{finding.recommendation}
|
{finding.recommendation}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export type FindingStatus = "ERROR" | "WARNING" | "INFO";
|
export type FindingStatus = "ERROR" | "WARNING" | "INFO";
|
||||||
|
export type FindingClass = "RULE" | "RISK" | "REVIEW" | "INFO";
|
||||||
|
export type DatasheetProvenance = "MANDATORY" | "RECOMMENDED" | "TYPICAL" | "EXAMPLE";
|
||||||
|
export type EvidenceStatus = "SUFFICIENT" | "INSUFFICIENT";
|
||||||
|
|
||||||
export interface Finding {
|
export interface Finding {
|
||||||
finding_id: string | null;
|
finding_id: string | null;
|
||||||
@@ -20,6 +23,18 @@ export interface Finding {
|
|||||||
cad_sheet?: string | null;
|
cad_sheet?: string | null;
|
||||||
cad_uuid?: string | null;
|
cad_uuid?: string | null;
|
||||||
variant?: string | null;
|
variant?: string | null;
|
||||||
|
facts?: string;
|
||||||
|
requirement?: string;
|
||||||
|
inference?: string;
|
||||||
|
provenance?: DatasheetProvenance | null;
|
||||||
|
finding_class?: FindingClass | null;
|
||||||
|
confidence?: number | null;
|
||||||
|
evidence_status?: EvidenceStatus | null;
|
||||||
|
calculation?: string;
|
||||||
|
assumptions?: string[];
|
||||||
|
action?: string;
|
||||||
|
decision_id?: string | null;
|
||||||
|
suppressed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FindingComment {
|
export interface FindingComment {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Finding engine — FACT/REQUIREMENT/INFERENCE, provenance clamp, decisions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.periscopex.finding_engine import (
|
||||||
|
DesignerDecision,
|
||||||
|
apply_decisions,
|
||||||
|
complete_finding,
|
||||||
|
finding_fingerprint,
|
||||||
|
lookup_rule,
|
||||||
|
)
|
||||||
|
from backend.periscopex.models import Finding
|
||||||
|
from backend.periscopex.validate import assign_finding_ids, _parse_review
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommended_never_error():
|
||||||
|
f = Finding(
|
||||||
|
designator="U1",
|
||||||
|
finding="layout note",
|
||||||
|
why="place close",
|
||||||
|
status="ERROR",
|
||||||
|
rule_id="PE-THM-001",
|
||||||
|
source="pcb_power_thermal",
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
|
facts="no pour",
|
||||||
|
requirement="datasheet layout page",
|
||||||
|
)
|
||||||
|
complete_finding(f)
|
||||||
|
assert f.provenance == "RECOMMENDED"
|
||||||
|
assert f.status != "ERROR"
|
||||||
|
assert f.finding_class != "RULE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_review_is_never_rule():
|
||||||
|
result = _parse_review(
|
||||||
|
{
|
||||||
|
"findings": [{
|
||||||
|
"finding": "strap",
|
||||||
|
"why": "datasheet",
|
||||||
|
"status": "ERROR",
|
||||||
|
"source_page": 2,
|
||||||
|
"source_quote": "PSEL must be high for 3.3 V.",
|
||||||
|
"recommendation": "Tie PSEL high.",
|
||||||
|
}],
|
||||||
|
"checked_areas": [],
|
||||||
|
},
|
||||||
|
"U1",
|
||||||
|
"PART",
|
||||||
|
)
|
||||||
|
f = result.findings[0]
|
||||||
|
complete_finding(f)
|
||||||
|
assert f.finding_class == "REVIEW"
|
||||||
|
assert f.facts
|
||||||
|
assert f.requirement
|
||||||
|
assert f.status == "ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_evidence_does_not_stay_error():
|
||||||
|
f = Finding(
|
||||||
|
designator="U1",
|
||||||
|
finding="narrow trace",
|
||||||
|
why="",
|
||||||
|
status="ERROR",
|
||||||
|
source="pcb_power_thermal",
|
||||||
|
rule_id="PE-PWR-001",
|
||||||
|
evidence_status="INSUFFICIENT",
|
||||||
|
)
|
||||||
|
complete_finding(f)
|
||||||
|
assert f.status != "ERROR"
|
||||||
|
assert "Insufficient evidence" in f.inference
|
||||||
|
assert f.finding_class != "RULE" or f.evidence_status == "INSUFFICIENT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decision_suppresses_rescan_nag():
|
||||||
|
f = Finding(
|
||||||
|
designator="U1",
|
||||||
|
finding="PSEL low",
|
||||||
|
why="must be high",
|
||||||
|
status="ERROR",
|
||||||
|
rule_id="PE-MUX-001",
|
||||||
|
source="pin_mux_check",
|
||||||
|
net="PSEL",
|
||||||
|
aspect="config",
|
||||||
|
facts="PSEL net is GND",
|
||||||
|
requirement="PSEL high for 3V3",
|
||||||
|
)
|
||||||
|
complete_finding(f)
|
||||||
|
assert f.status == "ERROR"
|
||||||
|
d = DesignerDecision(
|
||||||
|
decision_id="DEC-1",
|
||||||
|
fingerprint=finding_fingerprint(f),
|
||||||
|
rule_id="PE-MUX-001",
|
||||||
|
designator="U1",
|
||||||
|
net="PSEL",
|
||||||
|
aspect="config",
|
||||||
|
intent="wontfix",
|
||||||
|
reason="PSEL low intentional",
|
||||||
|
)
|
||||||
|
apply_decisions([f], [d])
|
||||||
|
assert f.suppressed is True
|
||||||
|
assert f.status == "INFO"
|
||||||
|
assert f.finding_class == "INFO"
|
||||||
|
assert "PSEL low intentional" in f.inference
|
||||||
|
|
||||||
|
|
||||||
|
def test_schematic_assign_ids_fills_engine_fields():
|
||||||
|
f = Finding(
|
||||||
|
designator="U3",
|
||||||
|
finding="NC tied",
|
||||||
|
why="NC must float",
|
||||||
|
status="ERROR",
|
||||||
|
rule_id="PE-NC-001",
|
||||||
|
source="nc_pin_check",
|
||||||
|
)
|
||||||
|
assign_finding_ids([f])
|
||||||
|
assert f.finding_id == "U3-001"
|
||||||
|
assert f.facts == "NC tied"
|
||||||
|
assert f.requirement
|
||||||
|
assert f.provenance == "MANDATORY"
|
||||||
|
assert f.finding_class == "RULE"
|
||||||
|
assert f.confidence is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_object_pcb_and_schema():
|
||||||
|
assert lookup_rule("PE-NC-001").domain == "schema"
|
||||||
|
assert lookup_rule("PE-LAY-001").domain == "pcb"
|
||||||
|
sch = Finding(designator="U1", finding="x", status="INFO", rule_id="PE-NC-001", source="nc_pin_check")
|
||||||
|
pcb = Finding(designator="U1", finding="y", status="INFO", rule_id="PE-LAY-001", source="pcb_net_match")
|
||||||
|
complete_finding(sch)
|
||||||
|
complete_finding(pcb)
|
||||||
|
for obj in (sch, pcb):
|
||||||
|
assert obj.facts
|
||||||
|
assert obj.finding_class
|
||||||
|
assert obj.provenance
|
||||||
|
assert obj.evidence_status
|
||||||
@@ -57,6 +57,29 @@ def test_new_fields_round_trip_json():
|
|||||||
assert again.variant == "DNP"
|
assert again.variant == "DNP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_fields_round_trip():
|
||||||
|
f = Finding(
|
||||||
|
designator="U1",
|
||||||
|
finding="x",
|
||||||
|
status="WARNING",
|
||||||
|
facts="net GND",
|
||||||
|
requirement="PSEL high",
|
||||||
|
inference="intentional?",
|
||||||
|
provenance="RECOMMENDED",
|
||||||
|
finding_class="RISK",
|
||||||
|
confidence=0.4,
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
|
calculation="n/a",
|
||||||
|
assumptions=["firmware unknown"],
|
||||||
|
action="leave PSEL low",
|
||||||
|
)
|
||||||
|
again = Finding.model_validate(json.loads(f.model_dump_json()))
|
||||||
|
assert again.facts == "net GND"
|
||||||
|
assert again.provenance == "RECOMMENDED"
|
||||||
|
assert again.finding_class == "RISK"
|
||||||
|
assert again.assumptions == ["firmware unknown"]
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_extra_keys_do_not_break_legacy_payloads():
|
def test_unknown_extra_keys_do_not_break_legacy_payloads():
|
||||||
f = Finding.model_validate(
|
f = Finding.model_validate(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user