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:
2026-09-21 22:33:30 +02:00
parent 4df04df5d4
commit 6d9d21a023
18 changed files with 451 additions and 51 deletions
+9 -5
View File
@@ -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 lanalisi, 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 lanalisi, 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)
```
+3 -1
View File
@@ -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 lempty 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
View File
@@ -1,27 +1,25 @@
# Libreria componenti — porta prodotto
La libreria **non** vive dentro lesame. È una porta a sé: pagina `/library` + API `/api/library`. Lanalisi (preliminare datasheet, schematico, PCB) *usa* la libreria; non è lunico modo per riempirla.
La libreria **non** vive dentro lesame. È una porta a sé: pagina `/library` + API `/api/library`. Lanalisi *usa* la libreria; non è lunico 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 nellanalisi, o un job futuro). Il PDF c’è comunque.
- Non sostituisce `library_has_*` usati dalla pipeline.
- Non inventa pin dal PDF. LLLM 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
+4 -1
View File
@@ -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
+119 -4
View File
@@ -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)
+38 -2
View File
@@ -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 -1
View File
@@ -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;
+11 -2
View File
@@ -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 -1
View File
@@ -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";
+60 -1
View File
@@ -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)
+76 -2
View File
@@ -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"] == []