Register library components from a datasheet without an exam (2.63.1).
POST /api/library/datasheets now writes an IC inbox card or a passive/discrete model. Empty pintables never enter library/extracted. AF Board+AI runs after unchanged run_pcb_checks (PE-SI / HF line stay).
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
# Albero delle analisi Periscope
|
||||
|
||||
Versione codice **2.63.0** (`periscope/src/frontend/content/changelog.md`). Letto in `/Users/michelebigi/Development/periscope`. Costituzione senza eccezioni: via ≠ pad ≠ traccia ≠ zona; niente I/Z/mm inventati; skip se manca evidenza. **Non è DRC KiCad.** DRC = KiCad. Questo documento mappa l’analisi, non implementa.
|
||||
Versione codice **2.63.1** (`periscope/src/frontend/content/changelog.md`). Letto in `/Users/michelebigi/Development/periscope`. Costituzione senza eccezioni: via ≠ pad ≠ traccia ≠ zona; niente I/Z/mm inventati; skip se manca evidenza. **Non è DRC KiCad.** DRC = KiCad. Questo documento mappa l’analisi, non implementa.
|
||||
|
||||
I test pytest stanno in `tests/datasheet/`, `tests/library/`, `tests/schematic/`, `tests/pcb/`, `tests/af_ai/` (stessi file di 2.62.1, cartelle di mestiere). I path `tests/test_*.py` sotto i nodi restano i nomi file.
|
||||
|
||||
Due pipeline:
|
||||
Due pipeline più una porta libreria (non è un esame):
|
||||
|
||||
0. **Libreria** — `POST /api/library/datasheets` registra un componente da PDF senza `MODE=run`/`MODE=pcb`.
|
||||
1. **Schematico / review AI** — `MODE=run`, `services/pipeline.py` → `validate_design_async` (`validation.py`): estrazione datasheet, check deterministici, review IC DeepSeek.
|
||||
2. **Esame PCB** — `MODE=pcb`, `services/pcb_pipeline.py` → `run_pcb_checks` (`pcb_checks.py`) + review AI PCB (`pcb_review.py` / `pcb_validation.py`). Non auto-place.
|
||||
2. **Esame PCB** — `MODE=pcb`, `services/pcb_pipeline.py` → `run_pcb_checks` (`pcb_checks.py`, lista 2.62.1 **invariata**) + sezione additiva AF Board+AI (`af_ai_hf.py`) + review AI PCB (`pcb_review.py` / `pcb_validation.py`). Non auto-place.
|
||||
|
||||
I certifier USB-C / Ethernet / PoE girano su **entrambe** le pipeline (grafo, non geometria). DDR / CPU / FPGA / USB-PD sono la stessa qualità di finding ma partono da `run_pcb_checks` (grafo). Niente pezzo sul grafo → silenzio, non N/A.
|
||||
|
||||
@@ -16,7 +17,9 @@ Nodi analisi: **108** (99 PE-* + 1 LED senza `rule_id` + 8 processo estrazione/A
|
||||
---
|
||||
|
||||
```
|
||||
Periscope 2.62.1
|
||||
Periscope 2.63.1
|
||||
├── 0. Libreria (porta, non esame)
|
||||
│ └── PDF + MPN → scheda (inbox IC / passivo / discreto) senza MODE=run/pcb
|
||||
├── 1. Schematico / review AI (MODE=run)
|
||||
│ ├── ingest (non è analisi: parser → grafo)
|
||||
│ ├── estrazione datasheet
|
||||
@@ -25,7 +28,7 @@ Periscope 2.62.1
|
||||
│ └── review AI per-IC + post-pass
|
||||
└── 2. Esame PCB (MODE=pcb)
|
||||
├── ingest layout (non è DRC)
|
||||
├── run_pcb_checks
|
||||
├── run_pcb_checks ← 2.62.1 invariato (SI + HF line inclusi)
|
||||
│ ├── net / footprint
|
||||
│ ├── placement vs layout_rules
|
||||
│ ├── SI + linee HF (2.62.0 Fase A)
|
||||
@@ -35,6 +38,7 @@ Periscope 2.62.1
|
||||
│ ├── ESD / return / EMI / SPOF
|
||||
│ ├── BOM ↔ PCB ↔ datasheet
|
||||
│ └── resto Fase B (2.62.1)
|
||||
├── AF Board + AI (additivo: flag → investigate_hf_hypotheses; non sostituisce SI/HF)
|
||||
├── review AI PCB
|
||||
└── tab antenna RF (non in run_pcb_checks)
|
||||
```
|
||||
|
||||
@@ -40,6 +40,8 @@ Zero eccezioni al principio. Le sezioni “non conformi” restano debito: non s
|
||||
|
||||
**Conforme nel disegno, non nel dispatcher.** Ogni modulo (`si_check`, `hf_line_check`, `stackup_check`, …) ha un mestiere e skip senza evidenza. `run_pcb_checks` è un elenco esplicito — si legge. **Non conforme:** `except Exception: log + skip` per ogni check (fallimento strumento vs finding vs dati, collassati). `pcb_power_thermal.py` (~748) e `si_check.py` (~862) e `interface_class_check.py` (~831) sono al limite: ancora un dominio, ma non “piccoli moduli”.
|
||||
|
||||
AF Board+AI (`af_ai_hf.py`) è **additivo**: `pcb_pipeline` chiama `append_investigated` dopo `run_pcb_checks`. Non entra nella lista 2.62.1. Non è DRC.
|
||||
|
||||
Non è DRC (clearance/track_width/annular restano KiCad). FEM assente: **conforme al vincolo**.
|
||||
|
||||
## 9. Check schematico (derating, LED, crystal, mux, rail, …)
|
||||
@@ -58,7 +60,7 @@ Non è DRC (clearance/track_width/annular restano KiCad). FEM assente: **conform
|
||||
|
||||
**Prima: non conforme come porta.** Catalogo e `library_has_*` vivevano in `services/projects.py` (CRUD progetti + libreria). La pagina `/library` era sola lettura e l’empty state mandava a “crea un progetto e lancia la review”. Admin `/admin/components` mescola libreria e users.
|
||||
|
||||
**Dopo questa macro-fase: conforme come porta, con debito.** `services/library.py` + `routers/library.py` + `/library` (import PDF senza esame, GET/PUT scheda). Store content-addressed (`datasheet_store.py`) resta esplicito. `library_gate.py` è piccolo e duro (niente pintable velenosa). Debito: `projects.py` re-export per non riscrivere la pipeline; `list_library_catalog` prima ingoiava JSON rotti — ora logga e salta la riga (dato rotto ≠ crash dello store).
|
||||
**Dopo 2.63.1: conforme come porta.** `POST /api/library/datasheets` registra una scheda (inbox IC o modello passivo/discreto) senza esame. Pintable vuota non va in `library/extracted/` (`library_gate`). PUT promuove inbox → extracted. `projects.py` re-export resta debito. `list_library_catalog` logga e salta JSON rotti.
|
||||
|
||||
## 13. Pipeline orchestrazione (`pipeline.py`, `pcb_pipeline.py`, job)
|
||||
|
||||
|
||||
+10
-12
@@ -1,27 +1,25 @@
|
||||
# Libreria componenti — porta prodotto
|
||||
|
||||
La libreria **non** vive dentro l’esame. È una porta a sé: pagina `/library` + API `/api/library`. L’analisi (preliminare datasheet, schematico, PCB) *usa* la libreria; non è l’unico modo per riempirla.
|
||||
La libreria **non** vive dentro l’esame. È una porta a sé: pagina `/library` + API `/api/library`. L’analisi *usa* la libreria; non è l’unico modo per riempirla.
|
||||
|
||||
## Flusso
|
||||
|
||||
```text
|
||||
PDF + MPN
|
||||
PDF + MPN + kind (ic | passive_part | simple)
|
||||
↓
|
||||
POST /api/library/datasheets
|
||||
↓
|
||||
blob MD5 + ref MPN
|
||||
↓
|
||||
catalogo (tab Datasheets)
|
||||
scheda componente
|
||||
IC → library/inbox/{mpn}.json (niente pintable inventata)
|
||||
passive_part → library/passives/{mpn}.json
|
||||
simple → library/models/{mpn}.json
|
||||
```
|
||||
|
||||
Nessun progetto, nessun `MODE=run` / `MODE=pcb`. Stesso store `library/datasheets/{blobs,refs}`.
|
||||
|
||||
Aggiornare una scheda già estratta:
|
||||
|
||||
```text
|
||||
GET /api/library/components/{ic|passive|passive_part|simple}/{mpn}
|
||||
PUT stesso path (JSON)
|
||||
```
|
||||
`library/extracted/` si scrive **solo** con pintable valida (`library_gate`: ≥2 pin, almeno un nome). PUT `/api/library/components/ic/{mpn}` promuove inbox → extracted.
|
||||
|
||||
Pintable IC: `library_gate` rifiuta pintable vuota o senza nomi (niente stub in libreria condivisa).
|
||||
|
||||
@@ -29,9 +27,9 @@ Pintable IC: `library_gate` rifiuta pintable vuota o senza nomi (niente stub in
|
||||
|
||||
- Non è admin/Clerk/JWT (non si tocca auth).
|
||||
- Non è DRC, non è FEM.
|
||||
- Non estrae da sola il pintable (LLM = preliminare datasheet nell’analisi, o un job futuro). Il PDF c’è comunque.
|
||||
- Non sostituisce `library_has_*` usati dalla pipeline.
|
||||
- Non inventa pin dal PDF. L’LLM prelim resta un mestiere a parte; la scheda c’è comunque.
|
||||
- Non sostituisce `library_has_*` usati dalla pipeline (`library_has_extraction` resta falso finché non c’è pintable).
|
||||
|
||||
## Test
|
||||
|
||||
`tests/library/` — catalogo, alias MPN, import PDF senza progetto, GET/PUT scheda, rifiuto PDF non valido.
|
||||
`tests/library/` — catalogo, alias MPN, import PDF senza progetto (scheda IC in inbox, passivo in passives), GET/PUT scheda, promozione pintable, rifiuto PDF non valido / kind ignoto.
|
||||
|
||||
@@ -112,3 +112,45 @@ def investigate_hf_hypotheses(
|
||||
if any(kicad_nets_match(net, flag) for flag in flagged if net):
|
||||
out.append(finding)
|
||||
return out
|
||||
|
||||
|
||||
def hypotheses_from_rows(rows: list | None) -> list[HfHypothesis]:
|
||||
"""Parse structured AI flags. Unknown classes and empty nets are dropped."""
|
||||
out: list[HfHypothesis] = []
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
net = str(row.get("net") or "").strip()
|
||||
kind = str(row.get("issue_class") or "").strip().lower()
|
||||
why = str(row.get("why") or "").strip()
|
||||
if not net or kind not in ALLOWED_ISSUE_CLASSES:
|
||||
continue
|
||||
out.append(HfHypothesis(net=net, issue_class=kind, why=why))
|
||||
return out
|
||||
|
||||
|
||||
def append_investigated(
|
||||
pcb_findings: list[Finding],
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
hypotheses: list[HfHypothesis],
|
||||
impedance_nets: list[dict] | dict | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Append AF+AI extras. Never drops ``run_pcb_checks`` rows.
|
||||
|
||||
Duplicate SI/HF clones (same rule_id+net already present) are skipped so
|
||||
a stub FAIL is not counted twice. ``PE-AF-001`` is always additive.
|
||||
"""
|
||||
extra = investigate_hf_hypotheses(
|
||||
graph, constraints_map, layout, hypotheses, impedance_nets,
|
||||
)
|
||||
seen = {(f.rule_id, f.net) for f in pcb_findings}
|
||||
added: list[Finding] = []
|
||||
for finding in extra:
|
||||
key = (finding.rule_id, finding.net)
|
||||
if finding.rule_id == RULE_Z_NO_EVIDENCE or key not in seen:
|
||||
pcb_findings.append(finding)
|
||||
added.append(finding)
|
||||
seen.add(key)
|
||||
return added
|
||||
|
||||
@@ -81,6 +81,7 @@ async def post_library_datasheet(
|
||||
request: Request,
|
||||
mpn: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
kind: str = Form("ic"),
|
||||
):
|
||||
raw = await file.read()
|
||||
if len(raw) > MAX_DATASHEET_BYTES:
|
||||
@@ -88,7 +89,9 @@ async def post_library_datasheet(
|
||||
413, f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB"
|
||||
)
|
||||
try:
|
||||
row = lib_svc.import_datasheet(get_storage(request), mpn, raw)
|
||||
row = lib_svc.import_component_from_datasheet(
|
||||
get_storage(request), mpn, raw, kind=kind,
|
||||
)
|
||||
except LibraryError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return row
|
||||
|
||||
@@ -17,6 +17,9 @@ log = logging.getLogger(__name__)
|
||||
|
||||
MAX_DATASHEET_BYTES = 30 * 1024 * 1024
|
||||
COMPONENT_TYPES = ("ic", "passive", "passive_part", "simple")
|
||||
# Import-from-PDF kinds. "passive" is a series regex, not a single part.
|
||||
IMPORT_KINDS = ("ic", "passive_part", "simple")
|
||||
INBOX_PREFIX = "library/inbox/"
|
||||
|
||||
|
||||
class LibraryError(ValueError):
|
||||
@@ -111,11 +114,30 @@ def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None:
|
||||
return legacy if storage.exists(legacy) else None
|
||||
|
||||
|
||||
def inbox_key(name: str) -> str:
|
||||
safe = safe_mpn(name)
|
||||
if not safe:
|
||||
raise LibraryError("Invalid MPN")
|
||||
return f"{INBOX_PREFIX}{safe}.json"
|
||||
|
||||
|
||||
def drop_inbox(storage: StorageBackend, name: str) -> None:
|
||||
"""Remove the pending IC card once a real pintable exists."""
|
||||
try:
|
||||
key = inbox_key(name)
|
||||
except LibraryError:
|
||||
return
|
||||
if storage.exists(key):
|
||||
storage.delete_key(key)
|
||||
|
||||
|
||||
def save_to_library(
|
||||
storage: StorageBackend, src_key: str, category: str, filename: str,
|
||||
) -> str:
|
||||
dst = f"library/{category}/{filename}"
|
||||
storage.copy_object(src_key, dst)
|
||||
if category == "extracted":
|
||||
drop_inbox(storage, filename.replace(".json", ""))
|
||||
return dst
|
||||
|
||||
|
||||
@@ -149,6 +171,66 @@ def import_datasheet(
|
||||
}
|
||||
|
||||
|
||||
def _register_ic_card(storage: StorageBackend, name: str) -> dict:
|
||||
"""Pending IC card. Empty pintable never goes to library/extracted."""
|
||||
if library_has_extraction(storage, name) is not None:
|
||||
return {"component_type": "ic", "status": "extracted"}
|
||||
key = inbox_key(name)
|
||||
if not storage.exists(key):
|
||||
storage.write_json(key, {
|
||||
"mpn": name,
|
||||
"kind": "ic",
|
||||
"status": "needs_pintable",
|
||||
"source": "library_import",
|
||||
"pintable": [],
|
||||
"absolute_maximum_ratings": [],
|
||||
"rules": [],
|
||||
})
|
||||
return {"component_type": "ic", "status": "needs_pintable"}
|
||||
|
||||
|
||||
def _register_model_card(
|
||||
storage: StorageBackend, name: str, kind: str,
|
||||
) -> dict:
|
||||
key = component_key(kind, name)
|
||||
if kind == "passive_part" and not storage.exists(key):
|
||||
legacy = f"library/models/{safe_mpn(name)}.json"
|
||||
if storage.exists(legacy):
|
||||
return {"component_type": kind, "status": "registered"}
|
||||
if not storage.exists(key):
|
||||
specs_type = "passive" if kind == "passive_part" else "discrete"
|
||||
storage.write_json(key, {
|
||||
"mpn": name,
|
||||
"specs": {"specs_type": specs_type, "values": {}},
|
||||
})
|
||||
return {"component_type": kind, "status": "registered"}
|
||||
|
||||
|
||||
def import_component_from_datasheet(
|
||||
storage: StorageBackend,
|
||||
mpn: str,
|
||||
data: bytes,
|
||||
kind: str = "ic",
|
||||
extra_mpns: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""PDF + MPN → library component. No project, no MODE=run/pcb."""
|
||||
kind_n = (kind or "ic").strip().lower()
|
||||
if kind_n not in IMPORT_KINDS:
|
||||
raise LibraryError(f"Unknown component kind: {kind}")
|
||||
row = import_datasheet(storage, mpn, data, extra_mpns=extra_mpns)
|
||||
if kind_n == "ic":
|
||||
extra = _register_ic_card(storage, row["mpn"])
|
||||
else:
|
||||
extra = _register_model_card(storage, row["mpn"], kind_n)
|
||||
row.update(extra)
|
||||
row["has_extraction"] = library_has_extraction(storage, row["mpn"]) is not None
|
||||
row["has_model"] = (
|
||||
library_has_model(storage, row["mpn"]) is not None
|
||||
or library_has_passive_model(storage, row["mpn"]) is not None
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def remember_datasheet(
|
||||
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
|
||||
) -> None:
|
||||
@@ -163,9 +245,11 @@ def get_component(storage: StorageBackend, component_type: str, name: str) -> di
|
||||
key = component_key(component_type, name)
|
||||
if component_type == "passive_part" and not storage.exists(key):
|
||||
key = f"library/models/{safe_mpn(name)}.json"
|
||||
if not storage.exists(key):
|
||||
return None
|
||||
return storage.read_json(key)
|
||||
if storage.exists(key):
|
||||
return storage.read_json(key)
|
||||
if component_type == "ic" and storage.exists(inbox_key(name)):
|
||||
return storage.read_json(inbox_key(name))
|
||||
return None
|
||||
|
||||
|
||||
def update_component(
|
||||
@@ -181,8 +265,14 @@ def update_component(
|
||||
ok, reason = should_promote_extraction(body)
|
||||
if not ok:
|
||||
raise LibraryError(f"IC rejected: {reason}")
|
||||
body.pop("kind", None)
|
||||
body.pop("status", None)
|
||||
if body.get("source") == "library_import":
|
||||
body.pop("source")
|
||||
key = component_key(component_type, name)
|
||||
storage.write_json(key, body)
|
||||
if component_type == "ic":
|
||||
drop_inbox(storage, name)
|
||||
return body
|
||||
|
||||
|
||||
@@ -205,10 +295,34 @@ def list_library_catalog(storage: StorageBackend) -> dict:
|
||||
"pin_count": len(data.get("pintable", [])),
|
||||
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
||||
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
|
||||
"library_status": "extracted",
|
||||
})
|
||||
except Exception:
|
||||
log.exception("skip unreadable IC catalog key %s", key)
|
||||
|
||||
seen_inbox: set[str] = set()
|
||||
for key in storage.list_prefix(INBOX_PREFIX):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if not mpn or mpn in seen_ic:
|
||||
continue
|
||||
seen_ic.add(mpn)
|
||||
seen_inbox.add(mpn)
|
||||
ics.append({
|
||||
"mpn": mpn,
|
||||
"type": "ic",
|
||||
"subtype": data.get("component_subtype") or "",
|
||||
"pin_count": len(data.get("pintable") or []),
|
||||
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
||||
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
|
||||
"library_status": data.get("status") or "needs_pintable",
|
||||
})
|
||||
except Exception:
|
||||
log.exception("skip unreadable inbox catalog key %s", key)
|
||||
|
||||
passives: list[dict] = []
|
||||
seen_p: set[str] = set()
|
||||
for key in storage.list_prefix("library/patterns/"):
|
||||
@@ -265,8 +379,9 @@ def list_library_catalog(storage: StorageBackend) -> dict:
|
||||
datasheets.append({
|
||||
"mpn": mpn,
|
||||
"hash": ref.get("hash"),
|
||||
"has_extraction": mpn in seen_ic,
|
||||
"has_extraction": mpn in seen_ic and mpn not in seen_inbox,
|
||||
"has_model": mpn in seen_m,
|
||||
"has_component": mpn in seen_ic or mpn in seen_m,
|
||||
})
|
||||
except Exception:
|
||||
log.exception("skip unreadable datasheet ref %s", key)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""PCB review pipeline — parallel to analysis (exam, not auto-place).
|
||||
|
||||
Stages: ensure_graph → parse_pcb → classify → inventory → checks →
|
||||
ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
|
||||
untouched. Does not write ``.kicad_pcb`` or packing coordinates.
|
||||
af_ai → ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
|
||||
untouched. ``af_ai`` is additive (AI flags then deterministic investigate) and
|
||||
does not replace ``run_pcb_checks`` SI/HF. Does not write ``.kicad_pcb``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +28,7 @@ from backend.periscopex.layout_rules import needs_layout_rules_refresh
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport,
|
||||
)
|
||||
from backend.periscopex.af_ai_hf import append_investigated, hypotheses_from_rows
|
||||
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
|
||||
from backend.periscopex.pcb_inventory import build_pcb_inventory
|
||||
from backend.services import projects as proj_svc
|
||||
@@ -162,6 +164,29 @@ def _step(project_id: str, stage: str, status: str, detail: str = "") -> None:
|
||||
_publish(project_id, "pcb_step_update", payload)
|
||||
|
||||
|
||||
def _run_af_ai_section(
|
||||
ws: PipelineWorkspace,
|
||||
graph: DesignGraph,
|
||||
cmap: dict,
|
||||
layout: LayoutGraph | None,
|
||||
zrep,
|
||||
findings: list,
|
||||
) -> int:
|
||||
"""AI-flag-then-investigate. Does not replace ``run_pcb_checks``."""
|
||||
raw: list = []
|
||||
hyp_path = ws.local_path("hf_hypotheses.json")
|
||||
if hyp_path.is_file():
|
||||
try:
|
||||
loaded = json.loads(hyp_path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, list):
|
||||
raw = loaded
|
||||
except Exception:
|
||||
logger.exception("hf_hypotheses.json unreadable — AF+AI flags empty")
|
||||
hyps = hypotheses_from_rows(raw)
|
||||
added = append_investigated(findings, graph, cmap, layout, hyps, zrep)
|
||||
return len(added)
|
||||
|
||||
|
||||
async def run_pcb_pipeline(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> None:
|
||||
@@ -280,6 +305,17 @@ async def run_pcb_pipeline(
|
||||
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "af_ai", "running", "AI HF flags then deterministic investigate")
|
||||
n_af = _run_af_ai_section(ws, graph, cmap, layout, zrep, findings)
|
||||
_step(
|
||||
project_id, "af_ai", "complete",
|
||||
f"{n_af} extra AF+AI findings (SI/HF checks kept)",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.63.1 — 2026-09-21 — Library component from PDF; AF+AI beside PCB checks
|
||||
|
||||
Library import registers a **component** (not only a PDF blob) with no exam. AF Board+AI is a pipeline section **after** unchanged `run_pcb_checks` (PE-SI / HF line stay in 2.62.1).
|
||||
|
||||
- [Fix] `POST /api/library/datasheets` writes an IC inbox card or a passive/discrete model. Empty pintable never lands in `library/extracted/`.
|
||||
- [Fix] PCB pipeline stage `af_ai` calls `append_investigated` beside `run_pcb_checks`. Duplicate SI clones are not double-counted. `PE-AF-001` stays additive.
|
||||
- [New] Report tree root “AF Board + AI”. Progress step between deterministic checks and PCB AI exam.
|
||||
|
||||
## 2.63.0 — 2026-09-21 — Library as its own product door
|
||||
|
||||
Standalone `/library` + `/api/library`: browse, add a datasheet PDF without an exam, update a component card. Datasheet prelim stays on the analysis path. Tests grouped datasheet → library → schematic → PCB → AF+AI (extra HF flags, then deterministic investigate). Rust criteria documented; no rustup. FEM out. DRC remains KiCad.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "periscope-web",
|
||||
"version": "2.63.0",
|
||||
"version": "2.63.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"sync-version": "node scripts/sync-version.mjs",
|
||||
|
||||
@@ -3,10 +3,21 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { importLibraryDatasheet } from "@/lib/api";
|
||||
import { importLibraryDatasheet, type LibraryComponentType } from "@/lib/api";
|
||||
|
||||
export function LibraryImport({ onImported }: { onImported: () => void }) {
|
||||
const KINDS: { value: "ic" | "passive_part" | "simple"; label: string }[] = [
|
||||
{ value: "ic", label: "IC" },
|
||||
{ value: "passive_part", label: "Passive" },
|
||||
{ value: "simple", label: "Discrete" },
|
||||
];
|
||||
|
||||
export function LibraryImport({
|
||||
onImported,
|
||||
}: {
|
||||
onImported: (row: { type: LibraryComponentType; name: string }) => void;
|
||||
}) {
|
||||
const [mpn, setMpn] = useState("");
|
||||
const [kind, setKind] = useState<"ic" | "passive_part" | "simple">("ic");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -24,10 +35,11 @@ export function LibraryImport({ onImported }: { onImported: () => void }) {
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await importLibraryDatasheet(mpn.trim(), file);
|
||||
const row = await importLibraryDatasheet(mpn.trim(), file, kind);
|
||||
const name = row.mpn;
|
||||
setMpn("");
|
||||
setFile(null);
|
||||
onImported();
|
||||
onImported({ type: kind, name });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Import failed");
|
||||
} finally {
|
||||
@@ -43,8 +55,9 @@ export function LibraryImport({ onImported }: { onImported: () => void }) {
|
||||
<div>
|
||||
<p className="text-sm font-medium">Add a component from a datasheet</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
PDF + MPN. No project and no exam required. Pin tables still come
|
||||
from the datasheet prelim in analysis.
|
||||
PDF + MPN on this page. No exam, no PCB run, no schematic run.
|
||||
Pin tables are not invented: fill them here or wait for extraction
|
||||
evidence.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
@@ -55,6 +68,20 @@ export function LibraryImport({ onImported }: { onImported: () => void }) {
|
||||
className="sm:w-56 font-mono text-sm"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) =>
|
||||
setKind(e.target.value as "ic" | "passive_part" | "simple")
|
||||
}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm sm:w-36"
|
||||
aria-label="Component kind"
|
||||
>
|
||||
{KINDS.map((k) => (
|
||||
<option key={k.value} value={k.value}>
|
||||
{k.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
|
||||
@@ -143,14 +143,15 @@ export default function LibraryPage() {
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Component library</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
|
||||
Shared datasheets and extracted cards. Add a PDF here without
|
||||
starting an exam. Analysis still runs the datasheet prelim when a
|
||||
board is reviewed.
|
||||
Shared component store. Add a part from a manufacturer PDF here —
|
||||
this page is not the exam. Analysis still uses the library; it is
|
||||
not the only way to fill it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LibraryImport
|
||||
onImported={() => {
|
||||
onImported={(row) => {
|
||||
setEditor(row);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
@@ -169,9 +170,9 @@ export default function LibraryPage() {
|
||||
<Library className="h-8 w-8 text-muted-foreground mx-auto" />
|
||||
<p className="text-sm font-medium">Library is empty</p>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Drop a manufacturer PDF and its MPN above. Nothing is invented
|
||||
from an empty library — pin tables arrive when extraction has
|
||||
evidence.
|
||||
Drop a manufacturer PDF and its MPN above. The part is in the
|
||||
library immediately. Pin tables are not invented from an empty
|
||||
PDF parse — fill the card or wait for extraction evidence.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -264,7 +265,12 @@ export default function LibraryPage() {
|
||||
<td className="px-3 py-2 text-center">{ic.pin_count}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline">pin table</Badge>
|
||||
{ic.library_status === "needs_pintable" ||
|
||||
ic.pin_count === 0 ? (
|
||||
<Badge variant="outline">needs pin table</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">pin table</Badge>
|
||||
)}
|
||||
{ic.has_datasheet && (
|
||||
<DatasheetLink
|
||||
mpn={ic.mpn}
|
||||
@@ -471,9 +477,11 @@ export default function LibraryPage() {
|
||||
<Badge variant="outline">pin table ready</Badge>
|
||||
) : d.has_model ? (
|
||||
<Badge variant="outline">specs ready</Badge>
|
||||
) : d.has_component ? (
|
||||
<Badge variant="outline">in library</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
PDF saved — extract on next review
|
||||
PDF saved
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
DesignGraph,
|
||||
} from "@/lib/types";
|
||||
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
||||
import { isPcbExamFinding } from "@/lib/layout-finding";
|
||||
import { isAfAiFinding, isPcbExamFinding } from "@/lib/layout-finding";
|
||||
|
||||
interface FindingsTreeProps {
|
||||
findings: Finding[];
|
||||
@@ -94,11 +94,15 @@ function TreeBranch({
|
||||
|
||||
export function FindingsTree(props: FindingsTreeProps) {
|
||||
const sorted = sortFindings(props.findings);
|
||||
const schematic = sorted.filter((f) => !isPcbExamFinding(f));
|
||||
const pcb = sorted.filter(isPcbExamFinding);
|
||||
const af = sorted.filter(isAfAiFinding);
|
||||
const pcb = sorted.filter((f) => isPcbExamFinding(f) && !isAfAiFinding(f));
|
||||
const schematic = sorted.filter(
|
||||
(f) => !isPcbExamFinding(f) && !isAfAiFinding(f),
|
||||
);
|
||||
const roots: { id: string; label: string; items: Finding[] }[] = [];
|
||||
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
|
||||
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
|
||||
if (af.length) roots.push({ id: "af_ai", label: "AF Board + AI", items: af });
|
||||
|
||||
const showRoots = roots.length > 1;
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ const PCB_STAGES = [
|
||||
{ id: "parse_pcb", title: "Parse PCB", description: "Build layout_graph.json from .kicad_pcb" },
|
||||
{ id: "classify", title: "Classify domains", description: "Domains and functional groups" },
|
||||
{ id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
|
||||
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, power traces, thermal, Kelvin" },
|
||||
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, HF lines, power traces, thermal, Kelvin" },
|
||||
{ id: "af_ai", title: "AF Board + AI", description: "AI flags HF issues, then deterministic investigate (does not replace SI/HF)" },
|
||||
{ id: "ai_review", title: "PCB AI exam", description: "Layout vs shared library extraction (no second PDF pass)" },
|
||||
{ id: "write_report", title: "Write report", description: "pcb_report.json findings" },
|
||||
] as const;
|
||||
|
||||
@@ -369,6 +369,7 @@ export interface LibraryIC {
|
||||
pin_count: number;
|
||||
has_ratings: boolean;
|
||||
has_datasheet: boolean;
|
||||
library_status?: string;
|
||||
}
|
||||
|
||||
export interface LibraryPassive {
|
||||
@@ -402,6 +403,7 @@ export interface LibraryDatasheet {
|
||||
hash: string | null;
|
||||
has_extraction: boolean;
|
||||
has_model: boolean;
|
||||
has_component?: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryCatalog {
|
||||
@@ -423,17 +425,24 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | nu
|
||||
return URL.createObjectURL(await res.blob());
|
||||
}
|
||||
|
||||
export async function importLibraryDatasheet(mpn: string, file: File): Promise<{
|
||||
export async function importLibraryDatasheet(
|
||||
mpn: string,
|
||||
file: File,
|
||||
kind: "ic" | "passive_part" | "simple" = "ic",
|
||||
): Promise<{
|
||||
mpn: string;
|
||||
blob_key: string;
|
||||
has_extraction: boolean;
|
||||
has_model: boolean;
|
||||
component_type?: string;
|
||||
status?: string;
|
||||
}> {
|
||||
const body = new FormData();
|
||||
body.append("mpn", mpn);
|
||||
body.append("file", file);
|
||||
body.append("kind", kind);
|
||||
const res = await send("/api/library/datasheets", { method: "POST", body });
|
||||
return jsonOrThrow(res, "Failed to add datasheet to the library");
|
||||
return jsonOrThrow(res, "Failed to add component to the library");
|
||||
}
|
||||
|
||||
export type LibraryComponentType = "ic" | "passive" | "passive_part" | "simple";
|
||||
|
||||
@@ -24,6 +24,7 @@ export function isLayoutFinding(f: {
|
||||
f.source === "usb_pd_check" ||
|
||||
f.source === "antenna_layout_check" ||
|
||||
f.source === "pdn_check" ||
|
||||
f.source === "af_ai_hf" ||
|
||||
rid.startsWith("PE-PLC") ||
|
||||
rid.startsWith("PE-LAY") ||
|
||||
rid.startsWith("PE-SI") ||
|
||||
@@ -47,6 +48,7 @@ export function isLayoutFinding(f: {
|
||||
rid.startsWith("PE-PD-") ||
|
||||
rid.startsWith("PE-ANT") ||
|
||||
rid.startsWith("PE-PDN") ||
|
||||
rid.startsWith("PE-AF") ||
|
||||
/^PE-BOM-01[0-4]$/.test(rid)
|
||||
);
|
||||
}
|
||||
@@ -85,3 +87,11 @@ export function isPcbExamFinding(f: {
|
||||
/^PE-BOM-01[0-4]$/.test(rid)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAfAiFinding(f: {
|
||||
source?: string | null;
|
||||
rule_id?: string | null;
|
||||
}): boolean {
|
||||
const rid = f.rule_id || "";
|
||||
return f.source === "af_ai_hf" || rid.startsWith("PE-AF");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
||||
export const APP_VERSION = "2.63.0";
|
||||
export const APP_VERSION = "2.63.1";
|
||||
export const APP_VERSION_DATE = "2026-09-21";
|
||||
|
||||
@@ -5,7 +5,12 @@ Does not replace run_pcb_checks. Not DRC. Never invents Z.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.af_ai_hf import HfHypothesis, investigate_hf_hypotheses
|
||||
from backend.periscopex.af_ai_hf import (
|
||||
HfHypothesis,
|
||||
append_investigated,
|
||||
hypotheses_from_rows,
|
||||
investigate_hf_hypotheses,
|
||||
)
|
||||
from backend.periscopex.hf_line_check import check_hf_lines
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
@@ -144,3 +149,57 @@ def test_z0_with_stackup_does_not_invent_ohm_number():
|
||||
for f in out:
|
||||
assert "50 Ω" not in (f.finding or "")
|
||||
assert "90 Ω" not in (f.finding or "")
|
||||
|
||||
|
||||
def test_append_investigated_keeps_pcb_checks_and_adds_pe_af():
|
||||
graph = _usb_graph()
|
||||
layout = _stub_layout()
|
||||
cons = _stub_constraints()
|
||||
pcb = run_pcb_checks(graph, cons, layout)
|
||||
n_si = sum(1 for f in pcb if f.rule_id == "PE-SI-007")
|
||||
assert n_si >= 1
|
||||
flags = [
|
||||
HfHypothesis(net="USB_D+", issue_class="stub", why="looks long"),
|
||||
HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm"),
|
||||
]
|
||||
added = append_investigated(pcb, graph, cons, layout, flags)
|
||||
assert any(f.rule_id == "PE-AF-001" for f in added)
|
||||
assert sum(1 for f in pcb if f.rule_id == "PE-SI-007") == n_si
|
||||
assert "si_check" in _pcb_checks_source()
|
||||
assert "hf_line" in _pcb_checks_source()
|
||||
|
||||
|
||||
def test_hypotheses_from_rows_drops_unknown_class():
|
||||
rows = [
|
||||
{"net": "USB_D+", "issue_class": "stub", "why": "x"},
|
||||
{"net": "USB_D+", "issue_class": "clearance", "why": "drc"},
|
||||
{"net": "", "issue_class": "z0", "why": "empty"},
|
||||
]
|
||||
hyps = hypotheses_from_rows(rows)
|
||||
assert len(hyps) == 1
|
||||
assert hyps[0].issue_class == "stub"
|
||||
|
||||
|
||||
def test_pcb_pipeline_af_ai_is_after_run_pcb_checks():
|
||||
import inspect
|
||||
|
||||
from backend.periscopex import pcb_checks
|
||||
from backend.services import pcb_pipeline
|
||||
|
||||
src = inspect.getsource(pcb_pipeline.run_pcb_pipeline)
|
||||
assert "run_pcb_checks" in src
|
||||
assert "_run_af_ai_section" in src
|
||||
assert src.find("run_pcb_checks") < src.find("_run_af_ai_section")
|
||||
checks = inspect.getsource(pcb_checks.run_pcb_checks)
|
||||
assert '("si_check"' in checks or '"si_check"' in checks
|
||||
assert '"hf_line"' in checks
|
||||
assert "af_ai" not in checks
|
||||
assert "investigate_hf_hypotheses" not in checks
|
||||
|
||||
|
||||
def _pcb_checks_source() -> str:
|
||||
import inspect
|
||||
|
||||
from backend.periscopex.pcb_checks import run_pcb_checks
|
||||
|
||||
return inspect.getsource(run_pcb_checks)
|
||||
|
||||
@@ -137,19 +137,33 @@ def test_library_import_pdf_without_project(tmp_path):
|
||||
before = client.get("/api/projects").json()
|
||||
resp = client.post(
|
||||
"/api/library/datasheets",
|
||||
data={"mpn": "CH340E"},
|
||||
data={"mpn": "CH340E", "kind": "ic"},
|
||||
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["mpn"] == "CH340E"
|
||||
assert body["has_extraction"] is False
|
||||
assert body["component_type"] == "ic"
|
||||
assert body["status"] == "needs_pintable"
|
||||
assert client.get("/api/projects").json() == before
|
||||
catalog = client.get("/api/library").json()
|
||||
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
|
||||
assert any(d["mpn"] == "CH340E" and d.get("has_component") for d in catalog["datasheets"])
|
||||
ics = [c for c in catalog["ics"] if c["mpn"] == "CH340E"]
|
||||
assert len(ics) == 1
|
||||
assert ics[0]["pin_count"] == 0
|
||||
assert ics[0]["library_status"] == "needs_pintable"
|
||||
pdf = client.get("/api/library/datasheet/CH340E")
|
||||
assert pdf.status_code == 200
|
||||
assert pdf.content.startswith(b"%PDF-")
|
||||
card = client.get("/api/library/components/ic/CH340E")
|
||||
assert card.status_code == 200
|
||||
assert card.json()["status"] == "needs_pintable"
|
||||
from backend.main import app
|
||||
from backend.services.library import library_has_extraction
|
||||
|
||||
assert library_has_extraction(app.state.storage, "CH340E") is None
|
||||
assert not app.state.storage.exists("library/extracted/CH340E.json")
|
||||
|
||||
|
||||
def test_library_import_rejects_non_pdf(tmp_path):
|
||||
@@ -210,3 +224,63 @@ def test_library_put_rejects_empty_pintable(tmp_path):
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert client.get("/api/library/components/ic/FAKEIC").status_code == 404
|
||||
|
||||
|
||||
def test_library_import_ic_put_pintable_promotes_out_of_inbox(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
assert client.post(
|
||||
"/api/library/datasheets",
|
||||
data={"mpn": "CH340E", "kind": "ic"},
|
||||
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||
).status_code == 200
|
||||
payload = {
|
||||
"mpn": "CH340E",
|
||||
"component_subtype": "ic.interface.usb_uart_bridge",
|
||||
"pintable": [
|
||||
{"number": "1", "name": "VCC"},
|
||||
{"number": "2", "name": "GND"},
|
||||
],
|
||||
"absolute_maximum_ratings": [],
|
||||
"rules": [],
|
||||
}
|
||||
saved = client.put("/api/library/components/ic/CH340E", json=payload)
|
||||
assert saved.status_code == 200
|
||||
from backend.main import app
|
||||
|
||||
assert app.state.storage.exists("library/extracted/CH340E.json")
|
||||
assert not app.state.storage.exists("library/inbox/CH340E.json")
|
||||
extracted = app.state.storage.read_json("library/extracted/CH340E.json")
|
||||
assert "status" not in extracted
|
||||
assert extracted["pintable"][0]["name"] == "VCC"
|
||||
cat = client.get("/api/library").json()
|
||||
ic = next(c for c in cat["ics"] if c["mpn"] == "CH340E")
|
||||
assert ic["pin_count"] == 2
|
||||
assert ic["library_status"] == "extracted"
|
||||
|
||||
|
||||
def test_library_import_passive_without_exam(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
resp = client.post(
|
||||
"/api/library/datasheets",
|
||||
data={"mpn": "CL10B474KA8NNNC", "kind": "passive_part"},
|
||||
files={"file": ("c.pdf", PDF, "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["component_type"] == "passive_part"
|
||||
assert client.get("/api/projects").json() == []
|
||||
cat = client.get("/api/library").json()
|
||||
assert any(p["mpn"] == "CL10B474KA8NNNC" for p in cat["passive_parts"])
|
||||
got = client.get("/api/library/components/passive_part/CL10B474KA8NNNC")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["specs"]["specs_type"] == "passive"
|
||||
|
||||
|
||||
def test_library_import_rejects_unknown_kind(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
resp = client.post(
|
||||
"/api/library/datasheets",
|
||||
data={"mpn": "CH340E", "kind": "project"},
|
||||
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert client.get("/api/library").json()["ics"] == []
|
||||
|
||||
Reference in New Issue
Block a user