Overlay native taxonomy JSON and local DeepSeek skills.

repo_paths and compose mount src/taxonomy first. Docker copies dependency then src for taxonomy and skills. SKILL.md prose is rewritten; schema IDs and validate contracts stay. Manifest 1.14.0. Do not run upload_skills.py.
This commit is contained in:
2026-09-20 20:30:25 +02:00
parent 28a4dc8255
commit ee160a5df9
26 changed files with 1775 additions and 14 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./periscope/dependency/taxonomy:/app/taxonomy - ./periscope/src/taxonomy:/app/taxonomy
ports: ports:
- "8080:8080" - "8080:8080"
+2
View File
@@ -14,7 +14,9 @@ COPY periscope/dependency/backend/ /app/backend/
COPY periscope/src/backend/ /app/backend/ COPY periscope/src/backend/ /app/backend/
COPY periscope/dependency/taxonomy/ /app/taxonomy/ COPY periscope/dependency/taxonomy/ /app/taxonomy/
COPY periscope/src/taxonomy/ /app/taxonomy/
COPY periscope/dependency/skills/ /app/skills/ COPY periscope/dependency/skills/ /app/skills/
COPY periscope/src/skills/ /app/skills/
COPY periscope/dependency/frontend/content/changelog.md /app/changelog.md COPY periscope/dependency/frontend/content/changelog.md /app/changelog.md
COPY vendor/ /app/vendor/ COPY vendor/ /app/vendor/
+2 -2
View File
@@ -1,7 +1,7 @@
"""Periscope living taxonomy: JSON files on disk, dotted subtype keys. """Periscope living taxonomy: JSON files on disk, dotted subtype keys.
JSON lives under ``repo_paths.taxonomy_dir()`` (Docker ``/app/taxonomy``, else JSON lives under ``repo_paths.taxonomy_dir()``: ``periscope/src/taxonomy``
``periscope/dependency/taxonomy`` until a src tree exists). first, then Docker ``/app/taxonomy``, then the inherited tree.
""" """
from __future__ import annotations from __future__ import annotations
+7 -7
View File
@@ -4,7 +4,7 @@ Physical split:
periscope/src native Periscope periscope/src native Periscope
periscope/dependency inherited PinScope (in-tree dependency) periscope/dependency inherited PinScope (in-tree dependency)
Docker overlays both trees into /app/backend, /app/taxonomy, /app/skills. Docker copies dependency then src for backend, taxonomy, and skills.
""" """
from __future__ import annotations from __future__ import annotations
@@ -48,22 +48,22 @@ def src_root() -> Path:
def taxonomy_dir() -> Path: def taxonomy_dir() -> Path:
app = Path("/app/taxonomy")
if app.is_dir() and any(app.glob("*.json")):
return app
src = src_root() / "taxonomy" src = src_root() / "taxonomy"
if src.is_dir() and any(src.glob("*.json")): if src.is_dir() and any(src.glob("*.json")):
return src return src
app = Path("/app/taxonomy")
if app.is_dir() and any(app.glob("*.json")):
return app
return dependency_root() / "taxonomy" return dependency_root() / "taxonomy"
def skills_dir() -> Path: def skills_dir() -> Path:
app = Path("/app/skills")
if app.is_dir() and (app / "extract-pintable" / "SKILL.md").is_file():
return app
src = src_root() / "skills" src = src_root() / "skills"
if src.is_dir() and (src / "extract-pintable" / "SKILL.md").is_file(): if src.is_dir() and (src / "extract-pintable" / "SKILL.md").is_file():
return src return src
app = Path("/app/skills")
if app.is_dir() and (app / "extract-pintable" / "SKILL.md").is_file():
return app
return dependency_root() / "skills" return dependency_root() / "skills"
@@ -0,0 +1,18 @@
{
"default_model_version": "1.14.0",
"extract-pintable": {
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
"latest_version": "1784798970179642",
"display_title": "Extract Pin Table"
},
"extract-pattern": {
"skill_id": "skill_01JuA5xdSJsz2V4dcwzpTRpe",
"latest_version": "1784798971057751",
"display_title": "Extract Passive Pattern"
},
"extract-specs": {
"skill_id": "skill_01NHZY6K3tvdbAzBo7eGT8qD",
"latest_version": "1784798971971891",
"display_title": "Extract Component Specs"
}
}
@@ -0,0 +1,66 @@
---
skill_name: extract-pattern
description: Pull a resistor/capacitor/inductor MPN pattern from a datasheet PDF. Return the payload through save_pattern.
---
# Passive MPN pattern extraction (Periscope)
Read the passive datasheet and call `save_pattern`. Do not write files.
## Where the numbering lives
Find **Part Numbering System**, **Ordering Information**, or **Explanation of Part No.** It shows field positions, code tables, and a worked example.
From the front page also take manufacturer, `component_type` (`resistor` | `capacitor` | `inductor`), and series / product name.
## Fields
Each field: snake_case `name` (regex group), 0-based `position`, `length`, `description`, and `lookup` (code → meaning). Prefer these names: `size`, `tolerance`, `resistance`, `capacitance`, `inductance`, `voltage`, `wattage`, `dielectric`, `packing_type`, `packing_qty`, `series`, `special`, `thickness`, `reserved`.
The primary value field (`resistance` / `capacitance` / `inductance`) has `lookup: {}` — it is decoded by algorithm.
## Value decoder
Capacitors, 3-digit EIA in pF (`106` → 10 µF):
```json
{"type": "eia3_pf", "base_unit": "pF", "output_unit": "F", "letter_multipliers": {}, "zero_code": null, "conditional_on": null}
```
Resistors, 4-digit code that depends on tolerance:
```json
{
"type": "eia4_ohm_conditional",
"base_unit": "ohm",
"output_unit": "ohm",
"letter_multipliers": {"J": -1, "K": -2, "L": -3, "M": -4, "N": -5, "P": -6},
"zero_code": "0000",
"conditional_on": {
"field": "tolerance",
"high_tolerance": ["J"],
"high_tolerance_layout": {"significant_start": 1, "significant_count": 2, "multiplier_index": 3},
"low_tolerance_layout": {"significant_start": 0, "significant_count": 3, "multiplier_index": 3}
}
}
```
Read which tolerance codes use 2 vs 3 significant digits, letter multipliers, and jumper/zero codes. Adapt if the PDF uses another scheme.
## Regex
Python regex, `^…$`, named groups `(?P<name>…)`. Enumerate known codes (`(?P<size>0603|0805|1206)`), not a loose `\d{4}` for size. Value field: digits plus any letter multipliers.
## Subtype
Pick the most specific path from `EXISTING PASSIVE TAXONOMY SUBTYPES`. If none fit, propose `passive.{type}.{specific}`.
## Checks
Regex matches every example MPN in the system prompt. Positions + lengths cover the MPN without overlap. Primary value field lookup is empty; other fields have lookups from the PDF. Decoder type matches the part class.
```bash
python3 /skills/extract-pattern/validate.py '<your JSON here>'
```
If that passes, call `save_pattern`.
@@ -0,0 +1,77 @@
{
"type": "object",
"properties": {
"manufacturer": {
"type": "string"
},
"series": {
"type": "string"
},
"component_type": {
"type": "string",
"enum": [
"resistor",
"capacitor",
"inductor"
]
},
"component_subtype": {
"type": "string"
},
"description": {
"type": "string"
},
"regex": {
"type": "string"
},
"fields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"position": {
"type": "integer"
},
"length": {
"type": "integer"
},
"description": {
"type": "string"
},
"lookup": {
"type": "object"
}
},
"required": [
"name",
"position",
"length",
"description"
]
}
},
"value_decoder": {
"type": "object"
},
"example_mpns": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"manufacturer",
"series",
"component_type",
"component_subtype",
"description",
"regex",
"fields",
"value_decoder",
"example_mpns"
]
}
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Check save_pattern payloads against schema.json and example MPNs."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
SCHEMA_PATH = Path(__file__).resolve().parent / "schema.json"
_TYPES = frozenset({"resistor", "capacitor", "inductor"})
def validate(data: dict) -> list[str]:
errors: list[str] = []
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
for field in schema.get("required", []):
if field not in data:
errors.append(f"Missing required field: {field}")
kind = data.get("component_type")
if "component_type" in data and kind not in _TYPES:
errors.append(f"component_type must be resistor/capacitor/inductor, got: {kind!r}")
compiled = None
if "regex" in data:
try:
compiled = re.compile(data["regex"])
except re.error as exc:
errors.append(f"Invalid regex: {exc}")
if compiled is not None:
for mpn in data.get("example_mpns") or []:
if not compiled.match(mpn):
errors.append(f"Regex does not match example MPN: {mpn!r}")
fields = data.get("fields")
if "fields" in data:
if not isinstance(fields, list) or not fields:
errors.append("fields must be a non-empty array")
else:
for i, field in enumerate(fields):
if not isinstance(field, dict):
errors.append(f"fields[{i}] must be an object")
continue
for key in ("name", "position", "length", "description"):
if key not in field:
errors.append(f"fields[{i}] missing: {key}")
decoder = data.get("value_decoder")
if "value_decoder" in data and (not isinstance(decoder, dict) or "type" not in decoder):
errors.append("value_decoder must be an object with a 'type' field")
return errors
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 validate.py '<json string>'")
sys.exit(1)
try:
payload = json.loads(sys.argv[1])
except json.JSONDecodeError as exc:
print(f"INVALID JSON: {exc}")
sys.exit(1)
problems = validate(payload)
if problems:
print("VALIDATION FAILED:")
for item in problems:
print(f" - {item}")
sys.exit(1)
print("VALIDATION PASSED")
@@ -0,0 +1,76 @@
---
skill_name: extract-pintable
description: Pull pin table, package, abs-max, layout_rules, and IC subtype from a datasheet PDF. Return the payload through save_pintable.
---
# IC pin table extraction (Periscope)
Read the IC datasheet and call `save_pintable` with structured fields. Do not write files.
Work in this order: (1) complete pin table for the MPNs package, (2) `layout_rules` from layout / typical-application pages, (3) package, absolute-maximum ratings, subtype.
## Datasheet pages to prefer
Pin configuration, ordering / decoder, package outline, PCB layout / land pattern, typical application (caps, vias, keepouts), absolute maximum ratings.
## Pin table
Every pin needs `number` (int, string, or BGA id such as `"A3"`), `name` (verbatim), optional `description`, optional `functions` (mux list).
Include power, ground, NC, and exposed pad / EP. Do not rename pins. For muxed pins, primary name in `name`, extras in `functions`. If several package tables exist, use the one that matches the MPN. Off-by-one pin numbers break the graph.
**Modules vs die.** MPNs containing `WROOM`, `WROVER`, `MODULE`, `MOD-`, or `SIP` are modules. Extract the **module landing-pad** table. Do not take the nested SoC/QFN ball map. On Espressif WROOM, pad 1 is GND. Pin 1 named `ANT`, `CHIP_PU`, or `XTAL_*` means the die table — invalid. Crystal, antenna, and flash inside a WROOM can are not schematic pin numbers.
Optional, only from the block diagram: `internal_features.pullup_pins`, `esd_clamp_pins`, `analog_switch`.
## layout_rules
Always scan layout / application / thermal pages. Emit a list. Use `[]` only when those pages have no placement guidance.
Look under “PCB Layout”, “Layout Guidelines”, “Board Layout”, “Land Pattern”, “Typical Application”, thermal/EP via notes, and figure callouts (“CIN within 2 mm of VIN”).
Closed `kind` set: `decoupling_proximity`, `thermal_via`, `keepout`, `length_match`, `impedance`, `max_length`, `spacing`, `ref_plane`, `si_via`, `layer`, `series_resistor`, `return_path`, `si`, `emi`, `common_mode`, `shield`.
Do not invent 50 Ω / 90 Ω rules for I2C, GPIO, EN, analog REGN, or USB CC. Do not invent USB 90 Ω unless this PDF states it.
SI kinds (`impedance`, `length_match`, `max_length`, `spacing`, `ref_plane`, `si_via`, `layer`, `series_resistor`, `return_path`, `si`) **require** `net_class`: `usb2`, `usb3`, `eth_mdi`, `rgmii`, `sgmii`, `ddr3`, `hdmi`, `pcie`, or `lvds`. PHY+RJ45 → `eth_mdi`; MACPHY → `rgmii`/`sgmii`; USB-C SuperSpeed → `usb3`; USB D+/D`usb2`.
`series_resistor` is HS series/termination on that net (USB 22 Ω, RGMII 22 Ω). It is not CHIP_PU / EN / RESET RC, ILIM, or a strap divider.
Fields: `pin` as printed; `cap_value_hint` only if shown; `max_distance_mm` only when the PDF gives millimetres (“within 2 mm” → `2`). “As close as possible” → `max_distance_mm: null` plus a `note`. Never invent JEDEC/USB/IPC distances. `same_layer` only if the text says same/opposite side. `min_via_count` only if stated. `note` is a short quote. `source_page` is 1-based and required when emitting a rule.
Example proximity with a millimetre:
```json
{"kind": "decoupling_proximity", "pin": "VIN", "cap_value_hint": "10uF", "max_distance_mm": 2.0, "same_layer": true, "note": "Place CIN within 2 mm of VIN", "source_page": 14}
```
Without a millimetre, still emit the rule with `max_distance_mm` null. Thermal vias use `kind: thermal_via` and `min_via_count` when the PDF gives a count.
Do not invent land-pattern pad sizes from the mechanical drawing. Do not emit SI kinds without `net_class`. One rule per distinct pin/guidance.
## Package
`base_family` (e.g. `MSPM0G3507` from `MSPM0G3507SPTR`), `package` (`LQFP-48`), `pin_count` (int), `description` from ordering / device-information tables.
## Absolute maximum ratings
Copy the Absolute Maximum Ratings table (not Recommended Operating Conditions): `parameter`, `min` / `max`, `unit`, `source_page` (1-based). Supplies, pin voltages, input current, temperature. Skip HBM/IEC kV ESD unless that is the only voltage limit. Do not invent numbers.
For `ic.protection.esd` (and similar), also take Vrwm and polarity/topology from Electrical Characteristics (`unit: "—"` for topology rows).
## Subtype
Choose the best dotted path from `EXISTING IC TAXONOMY SUBTYPES` (`ic.mcu`, `ic.power.ldo`, …). If none fit, propose `ic.{category}.{specific}`.
## Checks
Pin count matches this MPNs package. No duplicate or missing numbers. `layout_rules` is present (`[]` only if truly empty). Numeric `max_distance_mm` comes from the PDF. Names are not OCR junk.
Then:
```bash
python3 /skills/extract-pintable/validate.py '<your JSON here>'
```
If that passes, call `save_pintable`.
@@ -0,0 +1,266 @@
{
"type": "object",
"properties": {
"component_subtype": {
"type": "string",
"description": "Dotted taxonomy path (ic.mcu, ic.power.ldo)"
},
"package_info": {
"type": "object",
"properties": {
"base_family": {
"type": "string"
},
"package": {
"type": "string"
},
"pin_count": {
"type": "integer"
},
"description": {
"type": "string"
}
},
"required": [
"base_family",
"package",
"pin_count"
]
},
"pintable": {
"type": "array",
"items": {
"type": "object",
"properties": {
"number": {},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"functions": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"number",
"name"
]
}
},
"absolute_maximum_ratings": {
"type": "array",
"description": "Abs-max rows; ESD/TVS also include Vrwm and polarity.",
"items": {
"type": "object",
"properties": {
"parameter": {
"type": "string"
},
"min": {
"type": [
"number",
"null"
]
},
"max": {
"type": [
"number",
"null"
]
},
"unit": {
"type": "string"
},
"source_page": {
"type": "integer"
}
},
"required": [
"parameter",
"unit",
"source_page"
]
}
},
"internal_features": {
"type": "object",
"properties": {
"esd_clamp_pins": {
"type": "array",
"items": {
"type": "string"
}
},
"pullup_pins": {
"type": "array",
"items": {
"type": "string"
}
},
"analog_switch": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"layout_rules": {
"type": "array",
"description": "Layout constraints quoted from the datasheet. Empty list if none.",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"decoupling_proximity",
"thermal_via",
"keepout",
"length_match",
"impedance",
"max_length",
"spacing",
"ref_plane",
"si_via",
"layer",
"series_resistor",
"return_path",
"si",
"emi",
"common_mode",
"shield"
]
},
"pin": {
"type": [
"string",
"null"
]
},
"cap_value_hint": {
"type": [
"string",
"null"
]
},
"max_distance_mm": {
"type": [
"number",
"null"
]
},
"same_layer": {
"type": [
"boolean",
"null"
]
},
"min_via_count": {
"type": [
"integer",
"null"
]
},
"max_via_count": {
"type": [
"integer",
"null"
]
},
"net_class": {
"type": [
"string",
"null"
]
},
"note": {
"type": [
"string",
"null"
]
},
"source_page": {
"type": [
"integer",
"null"
]
},
"z0_ohm": {
"type": [
"number",
"null"
]
},
"zdiff_ohm": {
"type": [
"number",
"null"
]
},
"tolerance_pct": {
"type": [
"number",
"null"
]
},
"z_min_ohm": {
"type": [
"number",
"null"
]
},
"z_max_ohm": {
"type": [
"number",
"null"
]
},
"topology": {
"type": [
"string",
"null"
]
},
"min_spacing_mm": {
"type": [
"number",
"null"
]
},
"value_ohms": {
"type": [
"number",
"null"
]
},
"ref_plane": {
"type": [
"string",
"null"
]
},
"parameter": {
"type": [
"string",
"null"
]
}
},
"required": [
"kind"
]
}
}
},
"required": [
"component_subtype",
"package_info",
"pintable"
]
}
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Check save_pintable payloads against schema.json and module-footprint rules."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
SCHEMA_PATH = Path(__file__).resolve().parent / "schema.json"
_LAYOUT_KINDS = frozenset({
"decoupling_proximity", "thermal_via", "keepout", "length_match",
"impedance", "max_length", "spacing", "ref_plane", "si_via",
"layer", "series_resistor", "return_path", "si",
"emi", "common_mode", "shield",
})
_SI_KINDS = frozenset({
"length_match", "impedance", "max_length", "spacing",
"ref_plane", "si_via", "layer", "series_resistor",
"return_path", "si",
})
_MODULE_MPN = re.compile(r"WROOM|WROVER|\bMODULE\b|\bSIP\b", re.I)
_STRAP_NOTE = re.compile(r"\b(EN|CHIP_PU|CHIP_EN|STRAP|ILIM)\b", re.I)
def _positive_number(value, label: str, errors: list[str]) -> None:
if value is None or value is False:
return
if isinstance(value, bool):
errors.append(f"{label} must be a number or null")
return
if isinstance(value, (int, float)):
if float(value) <= 0:
errors.append(f"{label} must be > 0")
return
try:
parsed = float(str(value).strip())
except (TypeError, ValueError):
errors.append(
f"{label} must be numeric or null (got {value!r}) — "
"do not invent distances; use null when the PDF only says 'close'"
)
return
if parsed <= 0:
errors.append(f"{label} must be > 0")
def _positive_int(value, label: str, errors: list[str]) -> None:
if value is None or value is False or isinstance(value, bool):
return
if isinstance(value, int):
if value <= 0:
errors.append(f"{label} must be > 0")
return
try:
parsed = int(float(str(value).strip()))
except (TypeError, ValueError):
errors.append(f"{label} must be an integer or null (got {value!r})")
return
if parsed <= 0:
errors.append(f"{label} must be > 0")
def validate(data: dict) -> list[str]:
errors: list[str] = []
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
for field in schema.get("required", []):
if field not in data:
errors.append(f"Missing required field: {field}")
subtype = data.get("component_subtype")
if "component_subtype" in data and (not isinstance(subtype, str) or "." not in subtype):
errors.append(f"component_subtype must be dotted path, got: {subtype!r}")
pkg = data.get("package_info")
if "package_info" in data:
if not isinstance(pkg, dict):
errors.append("package_info must be an object")
else:
for key in ("base_family", "package", "pin_count"):
if key not in pkg:
errors.append(f"package_info missing required field: {key}")
if "pin_count" in pkg and not isinstance(pkg["pin_count"], int):
errors.append(
f"package_info.pin_count must be integer, got: {type(pkg['pin_count']).__name__}"
)
pins = data.get("pintable")
if "pintable" in data:
if not isinstance(pins, list) or not pins:
errors.append("pintable must be a non-empty array")
else:
numbers: list = []
names: dict[str, str] = {}
for i, pin in enumerate(pins):
if not isinstance(pin, dict):
errors.append(f"pintable[{i}] must be an object")
continue
if "number" not in pin:
errors.append(f"pintable[{i}] missing required field: number")
if "name" not in pin:
errors.append(f"pintable[{i}] missing required field: name")
if "number" in pin:
numbers.append(pin["number"])
names[str(pin["number"])] = str(pin.get("name") or "").upper()
dupes = [n for n in set(numbers) if numbers.count(n) > 1]
if dupes:
errors.append(f"Duplicate pin numbers: {dupes}")
pin1 = names.get("1", "")
die_like = bool(re.search(r"\bANT\b|^CHIP_PU$|^XTAL", pin1) and any("XTAL" in n for n in names.values()))
mpn = str(data.get("mpn") or "")
if die_like and _MODULE_MPN.search(mpn):
errors.append(
"Pin 1 looks like a bare RF SoC ball (ANT/CHIP_PU) with XTAL "
"pins in the table. Module footprints (WROOM) use pad 1 = GND; "
"extract the module landing-pad table, not the die map."
)
ratings = data.get("absolute_maximum_ratings")
if "absolute_maximum_ratings" in data and ratings is not None:
if not isinstance(ratings, list):
errors.append("absolute_maximum_ratings must be an array")
else:
for i, row in enumerate(ratings):
if not isinstance(row, dict):
errors.append(f"absolute_maximum_ratings[{i}] must be an object")
continue
for key in ("parameter", "unit", "source_page"):
if key not in row:
errors.append(f"absolute_maximum_ratings[{i}] missing required field: {key}")
rules = data.get("layout_rules")
if "layout_rules" in data and rules is not None:
if not isinstance(rules, list):
errors.append("layout_rules must be an array")
else:
for i, row in enumerate(rules):
label = f"layout_rules[{i}]"
if not isinstance(row, dict):
errors.append(f"{label} must be an object")
continue
kind = row.get("kind")
if kind not in _LAYOUT_KINDS:
errors.append(f"{label} unknown kind: {kind!r}")
continue
_positive_number(row.get("max_distance_mm"), f"{label}.max_distance_mm", errors)
_positive_int(row.get("min_via_count"), f"{label}.min_via_count", errors)
page = row.get("source_page")
if page is not None and not isinstance(page, int):
errors.append(f"{label}.source_page must be an integer or null")
same = row.get("same_layer")
if same is not None and not isinstance(same, bool):
errors.append(f"{label}.same_layer must be a boolean or null")
net_class = row.get("net_class")
if kind in _SI_KINDS and not (isinstance(net_class, str) and net_class.strip()):
errors.append(
f"{label} SI kind {kind!r} requires net_class "
"(usb2|usb3|eth_mdi|rgmii|sgmii|ddr3|hdmi|pcie|lvds)"
)
note = str(row.get("note") or "")
pin = str(row.get("pin") or "")
if kind == "series_resistor" and (
re.search(r"[µu]F", note, re.I) or _STRAP_NOTE.search(f"{note} {pin}")
):
errors.append(
f"{label} series_resistor is HS termination, not EN/CHIP_PU RC or strap"
)
return errors
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 validate.py '<json string>'")
sys.exit(1)
try:
payload = json.loads(sys.argv[1])
except json.JSONDecodeError as exc:
print(f"INVALID JSON: {exc}")
sys.exit(1)
problems = validate(payload)
if problems:
print("VALIDATION FAILED:")
for item in problems:
print(f" - {item}")
sys.exit(1)
print("VALIDATION PASSED")
@@ -0,0 +1,40 @@
---
skill_name: extract-specs
description: Pull pin table, package, and listed electrical specs from a discrete/simple-part datasheet. Return the payload through save_specs.
---
# Discrete / simple-part specs (Periscope)
Read the datasheet and call `save_specs`. Do not write files.
Prefer pin configuration, package info, electrical characteristics, and absolute maximum ratings.
## Subtype
Choose the best dotted path from the taxonomy list in the system prompt. If none fit, propose a new dotted name.
## Pin table
Each pin: `number`, `name` (verbatim, e.g. `"A"`, `"K"`, `"G"`), optional `description`, optional `functions`. Include tab / exposed pad. Match the package variant (SOT-23 pinouts differ). Off-by-one numbers break review.
## Package
`base_family` (e.g. `BAT54` from `BAT54S`), `package` (`SOT-23`, `SOD-123`, `TO-220`), `pin_count` (int), `description` decoding the MPN.
## Specifications
Extract **only** the names listed under `PARAMETERS TO EXTRACT`. Drop contact material, insulator, processing temperature, orientation, mounting, plating, and anything else not on that list.
Search electrical tables, abs-max, and application notes. Prefer typical operating values; keep maxima for ratings.
Use SPICE prefixes with units: `T` `G` `M` `k` `m` `u` `n` `p`. Good: `"30V"`, `"240mV"`, `"500mA"`, `"47mohm"`, `"18pF"`, `"8MHz"`. Bad: `"0.24V"`, `"0.5A"`. Unitless parameters may be numbers (turns ratio, pin count, hFE). Use `null` when the parameter does not apply or is missing.
Do not infer or calculate. If min/typ/max all matter, include them in the string (`"550mV typ, 850mV max"`). Extra keys are discarded.
## save_specs payload
- `component_subtype` — dotted path (`discrete.diode.schottky`)
- `component_subtype_description` — short text (needed for a new subtype)
- `package_info` — family, package, pin_count, description
- `pintable` — all pins
- `values` — map of listed parameter names to extracted values
@@ -0,0 +1,77 @@
{
"type": "object",
"properties": {
"component_subtype": {
"type": "string",
"description": "Dotted taxonomy path (discrete.diode.schottky, connector.usb)",
"pattern": "^[a-z][a-z0-9_]+(\\.[a-z][a-z0-9_]+)*$"
},
"component_subtype_description": {
"type": "string"
},
"package_info": {
"type": "object",
"properties": {
"base_family": {
"type": "string"
},
"package": {
"type": "string"
},
"pin_count": {
"type": "integer"
},
"description": {
"type": "string"
}
},
"required": [
"base_family",
"package",
"pin_count"
]
},
"pintable": {
"type": "array",
"items": {
"type": "object",
"properties": {
"number": {},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"functions": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"number",
"name"
]
}
},
"values": {
"type": "object",
"additionalProperties": {
"type": [
"string",
"number",
"null"
]
}
}
},
"required": [
"component_subtype",
"component_subtype_description",
"package_info",
"pintable",
"values"
]
}
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Check save_specs payloads against schema.json."""
from __future__ import annotations
import json
import sys
from pathlib import Path
SCHEMA_PATH = Path(__file__).resolve().parent / "schema.json"
def validate(data: dict) -> list[str]:
errors: list[str] = []
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
for field in schema.get("required", []):
if field not in data:
errors.append(f"Missing required field: {field}")
subtype = data.get("component_subtype")
if "component_subtype" in data and (not isinstance(subtype, str) or "." not in subtype):
errors.append(f"component_subtype must be dotted path, got: {subtype!r}")
pkg = data.get("package_info")
if "package_info" in data:
if not isinstance(pkg, dict):
errors.append("package_info must be an object")
else:
for key in ("base_family", "package", "pin_count"):
if key not in pkg:
errors.append(f"package_info missing required field: {key}")
if "pin_count" in pkg and not isinstance(pkg["pin_count"], int):
errors.append(
f"package_info.pin_count must be integer, got: {type(pkg['pin_count']).__name__}"
)
pins = data.get("pintable")
if "pintable" in data:
if not isinstance(pins, list) or not pins:
errors.append("pintable must be a non-empty array")
else:
numbers: list = []
for i, pin in enumerate(pins):
if not isinstance(pin, dict):
errors.append(f"pintable[{i}] must be an object")
continue
if "number" not in pin:
errors.append(f"pintable[{i}] missing required field: number")
if "name" not in pin:
errors.append(f"pintable[{i}] missing required field: name")
if "number" in pin:
numbers.append(pin["number"])
dupes = [n for n in set(numbers) if numbers.count(n) > 1]
if dupes:
errors.append(f"Duplicate pin numbers: {dupes}")
values = data.get("values")
if "values" in data:
if not isinstance(values, dict):
errors.append(f"values must be an object, got: {type(values).__name__}")
else:
for key, value in values.items():
if value is not None and not isinstance(value, (str, int, float)):
errors.append(
f"values[{key!r}] must be string, number, or null, got: {type(value).__name__}"
)
return errors
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 validate.py '<json string>'")
sys.exit(1)
try:
payload = json.loads(sys.argv[1])
except json.JSONDecodeError as exc:
print(f"INVALID JSON: {exc}")
sys.exit(1)
problems = validate(payload)
if problems:
print("VALIDATION FAILED:")
for item in problems:
print(f" - {item}")
sys.exit(1)
print("VALIDATION PASSED")
+59
View File
@@ -0,0 +1,59 @@
{
"type": "connector",
"specs": [
{
"name": "pin_count",
"description": "Contact count",
"required": true
},
{
"name": "voltage_rating_v",
"description": "Voltage rating",
"unit": "V"
},
{
"name": "current_rating_a",
"description": "Current per contact",
"unit": "A"
}
],
"subtypes": {
"connector.header": {
"description": "Pin header",
"extra_specs": [
{
"name": "pitch_mm",
"description": "Contact pitch",
"unit": "mm"
},
{
"name": "rows",
"description": "Row count"
},
{
"name": "positions_per_row",
"description": "Positions per row"
}
]
},
"connector.usb": {
"description": "USB connector",
"extra_specs": [
{
"name": "usb_standard",
"description": "USB generation (2.0, 3.x, Type-C)"
}
]
},
"connector.fpc": {
"description": "FPC / FFC connector",
"extra_specs": [
{
"name": "pitch_mm",
"description": "Contact pitch",
"unit": "mm"
}
]
}
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"type": "crystal",
"specs": [
{
"name": "frequency_hz",
"description": "Nominal frequency",
"unit": "Hz",
"required": true
},
{
"name": "load_capacitance_f",
"description": "Load capacitance CL",
"unit": "F"
},
{
"name": "esr_ohm",
"description": "ESR",
"unit": "ohm"
}
],
"subtypes": {
"crystal": {
"description": "Crystal or crystal oscillator",
"extra_specs": [
{
"name": "frequency_stability_ppm",
"description": "Stability / tolerance",
"unit": "ppm"
},
{
"name": "drive_level_w",
"description": "Max drive level",
"unit": "W"
},
{
"name": "shunt_capacitance_f",
"description": "Shunt C0",
"unit": "F"
}
]
},
"crystal.crystal": {
"description": "Crystal or crystal oscillator",
"example_mpn": "ABM8-19.200MHZ-10-1-U-T"
}
}
}
+238
View File
@@ -0,0 +1,238 @@
{
"type": "discrete",
"specs": [
{
"name": "package",
"description": "Package (SOD-123, SOT-23, TO-220)"
},
{
"name": "power_dissipation_w",
"description": "Maximum dissipation",
"unit": "W"
}
],
"subtypes": {
"discrete.diode.rectifier": {
"description": "Rectifier diode",
"extra_specs": [
{
"name": "reverse_voltage_v",
"description": "Vr / Vrrm",
"unit": "V",
"required": true
},
{
"name": "forward_voltage_v",
"description": "Typical Vf",
"unit": "V"
},
{
"name": "forward_current_a",
"description": "Continuous If",
"unit": "A"
}
]
},
"discrete.diode.schottky": {
"description": "Schottky diode",
"extra_specs": [
{
"name": "reverse_voltage_v",
"description": "Vr",
"unit": "V",
"required": true
},
{
"name": "forward_voltage_v",
"description": "Typical Vf",
"unit": "V"
},
{
"name": "forward_current_a",
"description": "Continuous If",
"unit": "A"
}
]
},
"discrete.diode.zener": {
"description": "Zener regulator diode",
"extra_specs": [
{
"name": "zener_voltage_v",
"description": "Nominal Vz",
"unit": "V",
"required": true
},
{
"name": "zener_impedance_ohm",
"description": "Zzt",
"unit": "ohm"
}
]
},
"discrete.diode.tvs": {
"description": "TVS diode",
"extra_specs": [
{
"name": "standoff_voltage_v",
"description": "Vrwm",
"unit": "V",
"required": true
},
{
"name": "clamping_voltage_v",
"description": "Clamp at Ipp",
"unit": "V"
},
{
"name": "peak_pulse_current_a",
"description": "Ipp",
"unit": "A"
}
]
},
"discrete.diode.esd": {
"description": "ESD array for data lines",
"example_mpn": "USBLC6-2SC6",
"extra_specs": [
{
"name": "standoff_voltage_v",
"description": "Vrwm",
"unit": "V",
"required": true
},
{
"name": "clamping_voltage_v",
"description": "Clamp voltage",
"unit": "V"
},
{
"name": "io_capacitance_f",
"description": "I/O capacitance Cio",
"unit": "F"
},
{
"name": "leakage_current_a",
"description": "Reverse leakage IR",
"unit": "A"
}
]
},
"discrete.transistor.mosfet.n_channel": {
"description": "N-channel MOSFET",
"extra_specs": [
{
"name": "vds_max_v",
"description": "Vds max",
"unit": "V",
"required": true
},
{
"name": "id_max_a",
"description": "Id max",
"unit": "A"
},
{
"name": "rds_on_ohm",
"description": "Rds(on)",
"unit": "ohm"
},
{
"name": "vgs_th_v",
"description": "Vgs(th)",
"unit": "V"
},
{
"name": "qg_c",
"description": "Total Qg",
"unit": "C"
}
]
},
"discrete.transistor.mosfet.p_channel": {
"description": "P-channel MOSFET",
"extra_specs": [
{
"name": "vds_max_v",
"description": "Vds max",
"unit": "V",
"required": true
},
{
"name": "id_max_a",
"description": "Id max",
"unit": "A"
},
{
"name": "rds_on_ohm",
"description": "Rds(on)",
"unit": "ohm"
},
{
"name": "vgs_th_v",
"description": "Vgs(th)",
"unit": "V"
}
]
},
"discrete.transistor.bjt.npn": {
"description": "NPN BJT",
"extra_specs": [
{
"name": "vce_max_v",
"description": "Vce max",
"unit": "V",
"required": true
},
{
"name": "ic_max_a",
"description": "Ic max",
"unit": "A"
},
{
"name": "hfe",
"description": "hFE"
}
]
},
"discrete.transistor.bjt.pnp": {
"description": "PNP BJT",
"extra_specs": [
{
"name": "vce_max_v",
"description": "Vce max",
"unit": "V",
"required": true
},
{
"name": "ic_max_a",
"description": "Ic max",
"unit": "A"
},
{
"name": "hfe",
"description": "hFE"
}
]
},
"discrete.led": {
"description": "LED",
"extra_specs": [
{
"name": "forward_voltage_v",
"description": "Typical Vf",
"unit": "V"
},
{
"name": "forward_current_a",
"description": "Typical or max If",
"unit": "A"
},
{
"name": "color",
"description": "Color or wavelength"
}
]
}
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"type": "fuse",
"specs": [
{
"name": "current_rating_a",
"description": "Rated current",
"unit": "A",
"required": true
},
{
"name": "voltage_rating_v",
"description": "Voltage rating",
"unit": "V"
},
{
"name": "breaking_capacity_a",
"description": "Breaking / interrupting capacity",
"unit": "A"
}
],
"subtypes": {
"fuse": {
"description": "Generic fuse"
},
"fuse.standard": {
"description": "One-shot fuse"
},
"fuse.ptc_resettable": {
"description": "PTC / polyfuse",
"extra_specs": [
{
"name": "hold_current_a",
"description": "Ihold",
"unit": "A"
},
{
"name": "trip_current_a",
"description": "Itrip",
"unit": "A"
},
{
"name": "resistance_ohm",
"description": "Typical R at 25 C",
"unit": "ohm"
}
]
}
}
}
+57
View File
@@ -0,0 +1,57 @@
{
"type": "ic",
"subtypes": {
"ic.mcu": {
"description": "Microcontroller",
"example_mpn": "MSPM0G3507SPTR"
},
"ic.power.ldo": {
"description": "Low-dropout linear regulator",
"example_mpn": "SPX3819M5-L-3-3"
},
"ic.power.switching_regulator": {
"description": "Switching regulator (buck, boost, or buck-boost)"
},
"ic.power.pmic": {
"description": "Multi-rail power-management IC with sequencing"
},
"ic.interface.usb_uart_bridge": {
"description": "USB\u2013UART bridge",
"example_mpn": "CH340E"
},
"ic.interface.level_shifter": {
"description": "Logic-level translator"
},
"ic.interface.can_transceiver": {
"description": "CAN transceiver"
},
"ic.interface.rs485_transceiver": {
"description": "RS-485 / RS-422 transceiver"
},
"ic.protection.esd": {
"description": "ESD / TVS protection IC",
"example_mpn": "USBLC6-2SC6"
},
"ic.sensor.accelerometer": {
"description": "Accelerometer or IMU"
},
"ic.sensor.temperature": {
"description": "Temperature-sensor IC"
},
"ic.memory.flash": {
"description": "NOR or NAND flash"
},
"ic.memory.eeprom": {
"description": "EEPROM"
},
"ic.logic.buffer": {
"description": "Buffer or line driver"
},
"ic.logic.gate": {
"description": "Combinational logic gate IC"
},
"ic.amplifier.opamp": {
"description": "Operational amplifier"
}
}
}
+163
View File
@@ -0,0 +1,163 @@
{
"type": "passive",
"specs": [
{
"name": "value_formatted",
"description": "SI-prefixed value string (4.7 kohm, 100 nF)"
},
{
"name": "tolerance",
"description": "Tolerance as printed (\u00b11%, \u00b110%)"
},
{
"name": "package",
"description": "Chip size code (0603, 0805, 1206)"
}
],
"subtypes": {
"passive.resistor": {
"description": "Chip resistor",
"example_mpn": "0603WAF5101T5E",
"extra_specs": [
{
"name": "value_ohms",
"description": "Resistance",
"unit": "ohm",
"required": true
},
{
"name": "power_rating_w",
"description": "Power rating",
"unit": "W"
}
]
},
"passive.resistor.thick_film": {
"description": "Thick-film chip resistor",
"extra_specs": [
{
"name": "value_ohms",
"description": "Resistance",
"unit": "ohm",
"required": true
},
{
"name": "power_rating_w",
"description": "Power rating",
"unit": "W"
}
]
},
"passive.resistor.thin_film": {
"description": "Thin-film chip resistor",
"extra_specs": [
{
"name": "value_ohms",
"description": "Resistance",
"unit": "ohm",
"required": true
},
{
"name": "power_rating_w",
"description": "Power rating",
"unit": "W"
}
]
},
"passive.capacitor.ceramic": {
"description": "MLCC",
"example_mpn": "CL10B474KA8NNNC",
"extra_specs": [
{
"name": "value_farads",
"description": "Capacitance",
"unit": "F",
"required": true
},
{
"name": "voltage_rating_v",
"description": "DC voltage rating",
"unit": "V"
},
{
"name": "dielectric",
"description": "Dielectric (X7R, C0G, X5R)"
}
]
},
"passive.capacitor.tantalum": {
"description": "Tantalum capacitor",
"extra_specs": [
{
"name": "value_farads",
"description": "Capacitance",
"unit": "F",
"required": true
},
{
"name": "voltage_rating_v",
"description": "DC voltage rating",
"unit": "V"
}
]
},
"passive.capacitor.electrolytic": {
"description": "Aluminum electrolytic capacitor",
"extra_specs": [
{
"name": "value_farads",
"description": "Capacitance",
"unit": "F",
"required": true
},
{
"name": "voltage_rating_v",
"description": "DC voltage rating",
"unit": "V"
}
]
},
"passive.inductor": {
"description": "Inductor or choke",
"extra_specs": [
{
"name": "value_henries",
"description": "Inductance",
"unit": "H",
"required": true
},
{
"name": "current_rating_a",
"description": "Saturation or rated current",
"unit": "A"
},
{
"name": "dcr_ohms",
"description": "DC resistance",
"unit": "ohm"
}
]
},
"passive.ferrite_bead": {
"description": "Ferrite bead",
"extra_specs": [
{
"name": "impedance_ohm",
"description": "Impedance at the stated test frequency",
"unit": "ohm",
"required": true
},
{
"name": "current_rating_a",
"description": "Rated current",
"unit": "A"
},
{
"name": "dcr_ohms",
"description": "DC resistance",
"unit": "ohm"
}
]
}
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"type": "switch",
"specs": [
{
"name": "voltage_rating_v",
"description": "Voltage rating",
"unit": "V"
},
{
"name": "current_rating_a",
"description": "Current rating",
"unit": "A"
}
],
"subtypes": {
"switch.tactile": {
"description": "Tactile push-button",
"extra_specs": [
{
"name": "contact_configuration",
"description": "Contact form (SPST-NO, SPST-NC)"
}
]
},
"switch.dip": {
"description": "DIP switch",
"extra_specs": [
{
"name": "positions",
"description": "Independent positions"
},
{
"name": "contact_configuration",
"description": "Form per position (SPST)"
}
]
},
"switch.slide": {
"description": "Slide switch",
"extra_specs": [
{
"name": "contact_configuration",
"description": "Form (SPDT, DPDT)"
}
]
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"type": "test_point",
"subtypes": {
"test_point": {
"description": "Test point"
}
}
}
+61
View File
@@ -0,0 +1,61 @@
{
"type": "transformer",
"specs": [
{
"name": "turns_ratio",
"description": "Primary:secondary turns"
},
{
"name": "voltage_primary_v",
"description": "Primary voltage",
"unit": "V"
},
{
"name": "voltage_secondary_v",
"description": "Secondary voltage",
"unit": "V"
},
{
"name": "current_rating_a",
"description": "Current rating",
"unit": "A"
}
],
"subtypes": {
"transformer.power": {
"description": "Power transformer",
"extra_specs": [
{
"name": "power_rating_w",
"description": "Power rating",
"unit": "W"
},
{
"name": "isolation_voltage_v",
"description": "Isolation between windings",
"unit": "V"
}
]
},
"transformer.signal": {
"description": "Signal / isolation transformer",
"extra_specs": [
{
"name": "isolation_voltage_v",
"description": "Isolation between windings",
"unit": "V"
},
{
"name": "insertion_loss_db",
"description": "Insertion loss",
"unit": "dB"
},
{
"name": "bandwidth_hz",
"description": "-3 dB bandwidth",
"unit": "Hz"
}
]
}
}
}
+1 -1
View File
@@ -6,4 +6,4 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
SIMPLE_PROJECT = REPO_ROOT / "periscope" / "dependency" / "simple_project" SIMPLE_PROJECT = REPO_ROOT / "periscope" / "dependency" / "simple_project"
TAXONOMY = REPO_ROOT / "periscope" / "dependency" / "taxonomy" TAXONOMY = REPO_ROOT / "periscope" / "src" / "taxonomy"
+2 -3
View File
@@ -28,12 +28,11 @@ def test_taxonomy_module_is_src():
assert "Native Periscope overlay" not in text assert "Native Periscope overlay" not in text
def test_taxonomy_dir_points_at_dependency_json(): def test_taxonomy_dir_points_at_src_json():
d = taxonomy_dir() d = taxonomy_dir()
assert d.parts[-2:] == ("dependency", "taxonomy") assert d.parts[-2:] == ("src", "taxonomy")
assert (d / "ic.json").is_file() assert (d / "ic.json").is_file()
assert taxonomy.TAXONOMY_DIR == d assert taxonomy.TAXONOMY_DIR == d
assert not (Path(taxonomy.__file__).resolve().parent.parent.parent / "taxonomy" / "ic.json").is_file()
def test_emmaforo_ref_prefixes(): def test_emmaforo_ref_prefixes():
@@ -0,0 +1,66 @@
"""Native taxonomy JSON and local DeepSeek skills live under periscope/src."""
from __future__ import annotations
from pathlib import Path
from backend.repo_paths import skills_dir, taxonomy_dir
from backend.services.llm.local_skill import load_skill_markdown, load_skill_validator
ROOT = Path(__file__).resolve().parents[1]
def test_taxonomy_json_is_src():
d = taxonomy_dir()
assert d.parts[-2:] == ("src", "taxonomy")
for name in (
"ic.json",
"passive.json",
"discrete.json",
"connector.json",
"crystal.json",
"fuse.json",
"switch.json",
"test_point.json",
"transformer.json",
):
assert (d / name).is_file(), name
def test_skills_dir_is_src_and_local():
d = skills_dir()
assert d.parts[-2:] == ("src", "skills")
for name in ("extract-pintable", "extract-pattern", "extract-specs"):
assert (d / name / "SKILL.md").is_file()
assert (d / name / "validate.py").is_file()
assert (d / name / "schema.json").is_file()
text = (d / name / "SKILL.md").read_text(encoding="utf-8")
assert "Native Periscope overlay" not in text[:400]
assert "upload_skills" not in text
def test_pintable_skill_mentions_save_tool_and_wroom():
md = load_skill_markdown("extract-pintable")
assert "save_pintable" in md
assert "layout_rules" in md
assert "WROOM" in md
assert "net_class" in md
def test_docker_copies_src_taxonomy_and_skills():
docker = (ROOT / "periscope" / "src" / "backend" / "Dockerfile").read_text(encoding="utf-8")
assert "COPY periscope/src/taxonomy/ /app/taxonomy/" in docker
assert "COPY periscope/src/skills/ /app/skills/" in docker
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "./periscope/src/taxonomy:/app/taxonomy" in compose
assert "dependency/taxonomy:/app/taxonomy" not in compose
def test_skills_manifest_bumped_for_prompt_change():
text = (ROOT / "periscope" / "src" / "backend" / "skills_manifest.json").read_text(
encoding="utf-8"
)
assert '"default_model_version": "1.14.0"' in text
validate = load_skill_validator("extract-pintable")
assert validate is not None