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_uuid: str | None = None # KiCad symbol/pin uuid
|
||||
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):
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
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.pcb_net_match import check_pcb_net_match
|
||||
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}"
|
||||
if not (f.recommendation or "").strip():
|
||||
f.recommendation = _FALLBACK_FIX
|
||||
complete_findings(findings)
|
||||
|
||||
|
||||
def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
|
||||
|
||||
@@ -141,6 +141,9 @@ def check_pcb_power_traces(
|
||||
),
|
||||
source="pcb_power_thermal",
|
||||
rule_id="PE-PWR-001",
|
||||
evidence_status="INSUFFICIENT",
|
||||
calculation="",
|
||||
assumptions=["IPC-2221 not applied without copper thickness."],
|
||||
net=net,
|
||||
pins=[],
|
||||
))
|
||||
@@ -162,6 +165,8 @@ def check_pcb_power_traces(
|
||||
recommendation="Add Tjmax to the shared library extraction, then re-run PCB review.",
|
||||
source="pcb_power_thermal",
|
||||
rule_id="PE-PWR-001",
|
||||
evidence_status="INSUFFICIENT",
|
||||
assumptions=["ΔT is Tjmax − 25 °C from the datasheet, never a default 10 °C."],
|
||||
net=net,
|
||||
pins=[],
|
||||
))
|
||||
@@ -189,6 +194,10 @@ def check_pcb_power_traces(
|
||||
),
|
||||
source="pcb_power_thermal",
|
||||
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,
|
||||
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.
|
||||
|
||||
### 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 \
|
||||
recommendation. Call submit_review. Empty findings with checked_areas is \
|
||||
valid when the layout matches the extraction.
|
||||
|
||||
@@ -20,6 +20,7 @@ from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from backend.periscopex.finding_engine import complete_findings
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
@@ -149,6 +150,12 @@ neighbor's datasheet that you fetched via `get_datasheet_excerpt` — this \
|
||||
links the page number to the right datasheet.
|
||||
- **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
|
||||
ERROR only for clear violations: required pin floating, voltage exceeding \
|
||||
absolute max, required external component completely missing, wrong \
|
||||
@@ -922,12 +929,19 @@ def _parse_review(
|
||||
mpn=mpn,
|
||||
source_designator=src_designator,
|
||||
finding=item["finding"],
|
||||
facts=str(item.get("finding") or ""),
|
||||
requirement=why,
|
||||
inference=str(item.get("inference") or ""),
|
||||
why=why,
|
||||
status=status,
|
||||
source_page=page,
|
||||
source_quote=item.get("source_quote", ""),
|
||||
recommendation=item.get("recommendation", ""),
|
||||
action=str(item.get("recommendation") or ""),
|
||||
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:
|
||||
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:
|
||||
"""Assign finding_id: {designator}-{001}, {002}, ..."""
|
||||
"""Assign finding_id: {designator}-{001}, {002}, ... then run the finding engine."""
|
||||
counter: Counter[str] = Counter()
|
||||
for f in findings:
|
||||
counter[f.designator] += 1
|
||||
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 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.review_workflow import (
|
||||
ReviewError,
|
||||
@@ -49,6 +55,20 @@ async def get_report(project_id: str, request: Request):
|
||||
merged = merge_schema_pcb_reports(schema, pcb)
|
||||
if merged is None:
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
user_id = get_user_id(request)
|
||||
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:
|
||||
raise HTTPException(404, "Finding not found")
|
||||
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
|
||||
report_data["review_states"] = states
|
||||
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": ""})
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ untouched. Does not write ``.kicad_pcb`` or packing coordinates.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -17,7 +18,7 @@ from backend.periscopex.cad_bridge import (
|
||||
cad_index_from_graph,
|
||||
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.models import ComponentConstraints, DesignGraph, LayoutGraph, ValidationReport
|
||||
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")
|
||||
assign_pcb_finding_ids(findings)
|
||||
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}
|
||||
for f in findings:
|
||||
summary[f.status] = summary.get(f.status, 0) + 1
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Awaitable, Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from backend.periscopex.finding_engine import apply_decisions
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
@@ -789,6 +790,12 @@ async def validate_design_async(
|
||||
def _write_report(paused: bool = False) -> ValidationReport:
|
||||
annotate_findings_cad(all_findings, graph.cad_index)
|
||||
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}
|
||||
for f in all_findings:
|
||||
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 (già in produzione) | PCB review (questo piano) | Placement F2 pack (fuori) |
|
||||
| | Schema | PCB review | Placement F2 (fuori) |
|
||||
| --- | --- | --- | --- |
|
||||
| Promessa | Lo schema rispetta il datasheet | Il **layout esistente** rispetta datasheet + geometria | Proporre xy nuovi |
|
||||
| Metodo | Check deterministici + **review LLM per-IC** | Stesso spirito: check + **review LLM per-IC sul PCB** | Skeleton `placement_pack.json` |
|
||||
| Trigger | BOM + netlist / `.kicad_sch` | + `.kicad_pcb` | PCB + `layout_rules` numerici |
|
||||
| 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` (`status`) | `MODE=pcb` (`pcb_status`) | `MODE=placement` (`placement_status`) |
|
||||
| Promessa | Netlist vs libreria datasheet | Layout vs libreria + geometria | xy nuovi |
|
||||
| Motore finding | FACT / REQUIREMENT / INFERENCE | uguale | — |
|
||||
| Deep exam PDF | **Una volta** → `library/extracted` | Consuma cache, `pdf_path=None` | — |
|
||||
| Job | `MODE=run` | `MODE=pcb` | `MODE=placement` |
|
||||
| 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`).
|
||||
- **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**.
|
||||
Libreria: `library/extracted/{safe_mpn}.json` ∪ extracted/ progetto. PDF in `library/datasheets/blobs/`.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
└─ 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 |
|
||||
| --- | --- | --- |
|
||||
| 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 |
|
||||
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`).
|
||||
|
||||
### Contratto job
|
||||
### Check PCB (geometria) — gated
|
||||
|
||||
- Non tocca `status` schema.
|
||||
- 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 |
|
||||
| ID | Gate |
|
||||
| --- | --- |
|
||||
| `.kicad_pcb` (`has_pcb`) | Sì |
|
||||
| `design_graph.json` | Sì |
|
||||
| `extracted/` + `layout_rules` / pintable | Per check numerici e AI |
|
||||
| **Shared library** `library/extracted/{safe_mpn}.json` | **Unico** store schema+PCB. Deep exam PDF una volta (pipeline schema). |
|
||||
| PDF datasheet | Solo per estrazione / review **schema**. PCB **non** riattacca il PDF. |
|
||||
| Stackup nel PCB | Per Z0 e ampacity; skip se assente |
|
||||
| PE-LAY-001…003 | pad/ref/hierarchy nets |
|
||||
| PE-PLC-001…004 | `layout_rules` numerici |
|
||||
| PE-SI-001 | `length_match` mm |
|
||||
| PE-DRT-001 | Vop e Vrated |
|
||||
| PE-PWR-001 | I_load + width; IPC solo con thickness e Tjmax |
|
||||
| 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 |
|
||||
| --- | --- | --- |
|
||||
| `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.
|
||||
Deploy: `ssh periscope` + `/root/periscope/scripts/update-periscope.sh`.
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
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)
|
||||
|
||||
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 expandable =
|
||||
!!finding.recommendation ||
|
||||
!!finding.facts ||
|
||||
!!finding.requirement ||
|
||||
!!finding.inference ||
|
||||
!!finding.calculation ||
|
||||
(hasCommentSupport && !!finding.finding_id) ||
|
||||
!!(projectId && finding.finding_id && onReviewSaved);
|
||||
|
||||
@@ -187,10 +191,64 @@ export function FindingCard({
|
||||
{finding.rule_id}
|
||||
</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>
|
||||
|
||||
{open && (
|
||||
<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 && (
|
||||
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
|
||||
{finding.recommendation}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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 {
|
||||
finding_id: string | null;
|
||||
@@ -20,6 +23,18 @@ export interface Finding {
|
||||
cad_sheet?: string | null;
|
||||
cad_uuid?: 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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
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():
|
||||
f = Finding.model_validate(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user