diff --git a/docs/albero-analisi.md b/docs/albero-analisi.md index 0066fb5..526f6d9 100644 --- a/docs/albero-analisi.md +++ b/docs/albero-analisi.md @@ -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) ``` diff --git a/docs/conformita-coding.md b/docs/conformita-coding.md index 988065e..c3df778 100644 --- a/docs/conformita-coding.md +++ b/docs/conformita-coding.md @@ -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) diff --git a/docs/library.md b/docs/library.md index da62b73..27e4f3c 100644 --- a/docs/library.md +++ b/docs/library.md @@ -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. diff --git a/periscope/src/backend/periscopex/af_ai_hf.py b/periscope/src/backend/periscopex/af_ai_hf.py index be54b6c..319917e 100644 --- a/periscope/src/backend/periscopex/af_ai_hf.py +++ b/periscope/src/backend/periscopex/af_ai_hf.py @@ -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 diff --git a/periscope/src/backend/routers/library.py b/periscope/src/backend/routers/library.py index 9264585..f266fc8 100644 --- a/periscope/src/backend/routers/library.py +++ b/periscope/src/backend/routers/library.py @@ -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 diff --git a/periscope/src/backend/services/library.py b/periscope/src/backend/services/library.py index 7aea2a1..31cf4c1 100644 --- a/periscope/src/backend/services/library.py +++ b/periscope/src/backend/services/library.py @@ -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) diff --git a/periscope/src/backend/services/pcb_pipeline.py b/periscope/src/backend/services/pcb_pipeline.py index 3c24f6f..44ca5fb 100644 --- a/periscope/src/backend/services/pcb_pipeline.py +++ b/periscope/src/backend/services/pcb_pipeline.py @@ -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 diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 4312942..bda8953 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -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. diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index 05bfd94..133e47e 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -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", diff --git a/periscope/src/frontend/src/app/(app)/library/library-import.tsx b/periscope/src/frontend/src/app/(app)/library/library-import.tsx index 0996cbf..4b0d819 100644 --- a/periscope/src/frontend/src/app/(app)/library/library-import.tsx +++ b/periscope/src/frontend/src/app/(app)/library/library-import.tsx @@ -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(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(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 }) {

Add a component from a datasheet

- 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.

@@ -55,6 +68,20 @@ export function LibraryImport({ onImported }: { onImported: () => void }) { className="sm:w-56 font-mono text-sm" autoComplete="off" /> +

Component library

- 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.

{ + onImported={(row) => { + setEditor(row); reload(); }} /> @@ -169,9 +170,9 @@ export default function LibraryPage() {

Library is empty

- 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.

) : ( @@ -264,7 +265,12 @@ export default function LibraryPage() { {ic.pin_count}
- pin table + {ic.library_status === "needs_pintable" || + ic.pin_count === 0 ? ( + needs pin table + ) : ( + pin table + )} {ic.has_datasheet && ( pin table ready ) : d.has_model ? ( specs ready + ) : d.has_component ? ( + in library ) : ( - PDF saved — extract on next review + PDF saved )} diff --git a/periscope/src/frontend/src/components/report/findings-tree.tsx b/periscope/src/frontend/src/components/report/findings-tree.tsx index a0faba5..d47df23 100644 --- a/periscope/src/frontend/src/components/report/findings-tree.tsx +++ b/periscope/src/frontend/src/components/report/findings-tree.tsx @@ -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; diff --git a/periscope/src/frontend/src/hooks/use-pcb-progress.ts b/periscope/src/frontend/src/hooks/use-pcb-progress.ts index c6de1c0..bd96544 100644 --- a/periscope/src/frontend/src/hooks/use-pcb-progress.ts +++ b/periscope/src/frontend/src/hooks/use-pcb-progress.ts @@ -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; diff --git a/periscope/src/frontend/src/lib/api.ts b/periscope/src/frontend/src/lib/api.ts index 96620e3..a366be4 100644 --- a/periscope/src/frontend/src/lib/api.ts +++ b/periscope/src/frontend/src/lib/api.ts @@ -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 { 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"; diff --git a/periscope/src/frontend/src/lib/layout-finding.ts b/periscope/src/frontend/src/lib/layout-finding.ts index e9204ec..6632cbf 100644 --- a/periscope/src/frontend/src/lib/layout-finding.ts +++ b/periscope/src/frontend/src/lib/layout-finding.ts @@ -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"); +} diff --git a/periscope/src/frontend/src/lib/version.ts b/periscope/src/frontend/src/lib/version.ts index d195e74..aa5a91a 100644 --- a/periscope/src/frontend/src/lib/version.ts +++ b/periscope/src/frontend/src/lib/version.ts @@ -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"; diff --git a/tests/af_ai/test_af_ai_hf.py b/tests/af_ai/test_af_ai_hf.py index 1365999..dd79034 100644 --- a/tests/af_ai/test_af_ai_hf.py +++ b/tests/af_ai/test_af_ai_hf.py @@ -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) diff --git a/tests/library/test_library.py b/tests/library/test_library.py index 8928c04..f94ecde 100644 --- a/tests/library/test_library.py +++ b/tests/library/test_library.py @@ -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"] == []