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
|
||||
|
||||
Reference in New Issue
Block a user