From dc65e96a229ebdb32d8996567f36a14a3f51635c Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 20 Sep 2026 08:38:59 +0200 Subject: [PATCH] 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. --- backend/periscopex/finding_engine.py | 288 ++++++++++++++++++ backend/periscopex/models.py | 13 + backend/periscopex/pcb_checks.py | 3 +- backend/periscopex/pcb_power_thermal.py | 9 + backend/periscopex/pcb_review.py | 5 + backend/periscopex/validate.py | 17 +- backend/routers/reports.py | 48 ++- backend/services/pcb_pipeline.py | 9 +- backend/services/validation.py | 7 + docs/motore-finding.md | 114 +++++++ docs/piano-pcb-review.md | 255 +++------------- frontend/content/changelog.md | 9 + .../src/components/report/finding-card.tsx | 58 ++++ frontend/src/lib/types.ts | 15 + tests/test_finding_engine.py | 135 ++++++++ tests/test_finding_schema.py | 23 ++ 16 files changed, 797 insertions(+), 211 deletions(-) create mode 100644 backend/periscopex/finding_engine.py create mode 100644 docs/motore-finding.md create mode 100644 tests/test_finding_engine.py diff --git a/backend/periscopex/finding_engine.py b/backend/periscopex/finding_engine.py new file mode 100644 index 0000000..49350d5 --- /dev/null +++ b/backend/periscopex/finding_engine.py @@ -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 diff --git a/backend/periscopex/models.py b/backend/periscopex/models.py index 065248a..a49de15 100644 --- a/backend/periscopex/models.py +++ b/backend/periscopex/models.py @@ -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): diff --git a/backend/periscopex/pcb_checks.py b/backend/periscopex/pcb_checks.py index 9fe4c76..52b737a 100644 --- a/backend/periscopex/pcb_checks.py +++ b/backend/periscopex/pcb_checks.py @@ -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]: diff --git a/backend/periscopex/pcb_power_thermal.py b/backend/periscopex/pcb_power_thermal.py index 7f59aac..ac086cf 100644 --- a/backend/periscopex/pcb_power_thermal.py +++ b/backend/periscopex/pcb_power_thermal.py @@ -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=[], )) diff --git a/backend/periscopex/pcb_review.py b/backend/periscopex/pcb_review.py index 50d1cf7..3c14b31 100644 --- a/backend/periscopex/pcb_review.py +++ b/backend/periscopex/pcb_review.py @@ -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. diff --git a/backend/periscopex/validate.py b/backend/periscopex/validate.py index de72978..022c064 100644 --- a/backend/periscopex/validate.py +++ b/backend/periscopex/validate.py @@ -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) # --------------------------------------------------------------------------- diff --git a/backend/routers/reports.py b/backend/routers/reports.py index d49bef0..53c8621 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -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": ""}) diff --git a/backend/services/pcb_pipeline.py b/backend/services/pcb_pipeline.py index 0d73fa0..9ebd92e 100644 --- a/backend/services/pcb_pipeline.py +++ b/backend/services/pcb_pipeline.py @@ -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 diff --git a/backend/services/validation.py b/backend/services/validation.py index 8279d7f..fe4b138 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -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 diff --git a/docs/motore-finding.md b/docs/motore-finding.md new file mode 100644 index 0000000..f531a06 --- /dev/null +++ b/docs/motore-finding.md @@ -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. diff --git a/docs/piano-pcb-review.md b/docs/piano-pcb-review.md index bf70639..5adb155 100644 --- a/docs/piano-pcb-review.md +++ b/docs/piano-pcb-review.md @@ -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) - Project files ───┼─ MODE=placement → placement_status → placement_plan.json - └─ MODE=pcb → pcb_status → pcb_report.json + ┌─ 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`. diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 8804ea0..b885ff6 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -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. diff --git a/frontend/src/components/report/finding-card.tsx b/frontend/src/components/report/finding-card.tsx index 30f0c73..c1b3492 100644 --- a/frontend/src/components/report/finding-card.tsx +++ b/frontend/src/components/report/finding-card.tsx @@ -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} )} + {finding.finding_class && ( + + {finding.finding_class} + + )} + {finding.provenance && ( + + {finding.provenance} + + )} + {finding.evidence_status === "INSUFFICIENT" && ( + + Insufficient evidence + + )} + {finding.suppressed && ( + + Decision + + )} {open && (
e.stopPropagation()}> + {(finding.facts || finding.requirement || finding.inference) && ( +
+ {finding.facts ? ( +
+
Fact
+
{finding.facts}
+
+ ) : null} + {finding.requirement ? ( +
+
Requirement
+
{finding.requirement}
+
+ ) : null} + {finding.inference ? ( +
+
Inference
+
{finding.inference}
+
+ ) : null} + {finding.calculation ? ( +
+
Calculation
+
{finding.calculation}
+
+ ) : null} + {typeof finding.confidence === "number" ? ( +
+
Confidence
+
{Math.round(finding.confidence * 100)}%
+
+ ) : null} +
+ )} {finding.recommendation && (

{finding.recommendation} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 6cf4afd..73bcc34 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 { diff --git a/tests/test_finding_engine.py b/tests/test_finding_engine.py new file mode 100644 index 0000000..81f2c3e --- /dev/null +++ b/tests/test_finding_engine.py @@ -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 diff --git a/tests/test_finding_schema.py b/tests/test_finding_schema.py index bf69e1e..3a98036 100644 --- a/tests/test_finding_schema.py +++ b/tests/test_finding_schema.py @@ -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( {