From ee160a5df9eed237e6e2b2e1f3a641af3612f368 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 20 Sep 2026 20:30:25 +0200 Subject: [PATCH] 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. --- docker-compose.yml | 2 +- periscope/src/backend/Dockerfile | 2 + periscope/src/backend/periscopex/taxonomy.py | 4 +- periscope/src/backend/repo_paths.py | 14 +- periscope/src/backend/skills_manifest.json | 18 ++ periscope/src/skills/extract-pattern/SKILL.md | 66 +++++ .../src/skills/extract-pattern/schema.json | 77 +++++ .../src/skills/extract-pattern/validate.py | 71 +++++ .../src/skills/extract-pintable/SKILL.md | 76 +++++ .../src/skills/extract-pintable/schema.json | 266 ++++++++++++++++++ .../src/skills/extract-pintable/validate.py | 188 +++++++++++++ periscope/src/skills/extract-specs/SKILL.md | 40 +++ .../src/skills/extract-specs/schema.json | 77 +++++ .../src/skills/extract-specs/validate.py | 85 ++++++ periscope/src/taxonomy/connector.json | 59 ++++ periscope/src/taxonomy/crystal.json | 47 ++++ periscope/src/taxonomy/discrete.json | 238 ++++++++++++++++ periscope/src/taxonomy/fuse.json | 49 ++++ periscope/src/taxonomy/ic.json | 57 ++++ periscope/src/taxonomy/passive.json | 163 +++++++++++ periscope/src/taxonomy/switch.json | 48 ++++ periscope/src/taxonomy/test_point.json | 8 + periscope/src/taxonomy/transformer.json | 61 ++++ tests/paths.py | 2 +- tests/test_periscope_taxonomy_rewrite.py | 5 +- tests/test_periscope_taxonomy_skills_src.py | 66 +++++ 26 files changed, 1775 insertions(+), 14 deletions(-) create mode 100644 periscope/src/backend/skills_manifest.json create mode 100644 periscope/src/skills/extract-pattern/SKILL.md create mode 100644 periscope/src/skills/extract-pattern/schema.json create mode 100644 periscope/src/skills/extract-pattern/validate.py create mode 100644 periscope/src/skills/extract-pintable/SKILL.md create mode 100644 periscope/src/skills/extract-pintable/schema.json create mode 100644 periscope/src/skills/extract-pintable/validate.py create mode 100644 periscope/src/skills/extract-specs/SKILL.md create mode 100644 periscope/src/skills/extract-specs/schema.json create mode 100644 periscope/src/skills/extract-specs/validate.py create mode 100644 periscope/src/taxonomy/connector.json create mode 100644 periscope/src/taxonomy/crystal.json create mode 100644 periscope/src/taxonomy/discrete.json create mode 100644 periscope/src/taxonomy/fuse.json create mode 100644 periscope/src/taxonomy/ic.json create mode 100644 periscope/src/taxonomy/passive.json create mode 100644 periscope/src/taxonomy/switch.json create mode 100644 periscope/src/taxonomy/test_point.json create mode 100644 periscope/src/taxonomy/transformer.json create mode 100644 tests/test_periscope_taxonomy_skills_src.py diff --git a/docker-compose.yml b/docker-compose.yml index 20b76b1..fb10e62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: volumes: - ./data:/app/data - - ./periscope/dependency/taxonomy:/app/taxonomy + - ./periscope/src/taxonomy:/app/taxonomy ports: - "8080:8080" diff --git a/periscope/src/backend/Dockerfile b/periscope/src/backend/Dockerfile index c3e9ec3..57967a4 100644 --- a/periscope/src/backend/Dockerfile +++ b/periscope/src/backend/Dockerfile @@ -14,7 +14,9 @@ COPY periscope/dependency/backend/ /app/backend/ COPY periscope/src/backend/ /app/backend/ COPY periscope/dependency/taxonomy/ /app/taxonomy/ +COPY periscope/src/taxonomy/ /app/taxonomy/ COPY periscope/dependency/skills/ /app/skills/ +COPY periscope/src/skills/ /app/skills/ COPY periscope/dependency/frontend/content/changelog.md /app/changelog.md COPY vendor/ /app/vendor/ diff --git a/periscope/src/backend/periscopex/taxonomy.py b/periscope/src/backend/periscopex/taxonomy.py index e279979..4a0bb0a 100644 --- a/periscope/src/backend/periscopex/taxonomy.py +++ b/periscope/src/backend/periscopex/taxonomy.py @@ -1,7 +1,7 @@ """Periscope living taxonomy: JSON files on disk, dotted subtype keys. -JSON lives under ``repo_paths.taxonomy_dir()`` (Docker ``/app/taxonomy``, else -``periscope/dependency/taxonomy`` until a src tree exists). +JSON lives under ``repo_paths.taxonomy_dir()``: ``periscope/src/taxonomy`` +first, then Docker ``/app/taxonomy``, then the inherited tree. """ from __future__ import annotations diff --git a/periscope/src/backend/repo_paths.py b/periscope/src/backend/repo_paths.py index 92fdb46..a9e28ff 100644 --- a/periscope/src/backend/repo_paths.py +++ b/periscope/src/backend/repo_paths.py @@ -4,7 +4,7 @@ Physical split: periscope/src native Periscope 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 @@ -48,22 +48,22 @@ def src_root() -> Path: def taxonomy_dir() -> Path: - app = Path("/app/taxonomy") - if app.is_dir() and any(app.glob("*.json")): - return app src = src_root() / "taxonomy" if src.is_dir() and any(src.glob("*.json")): return src + app = Path("/app/taxonomy") + if app.is_dir() and any(app.glob("*.json")): + return app return dependency_root() / "taxonomy" 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" if src.is_dir() and (src / "extract-pintable" / "SKILL.md").is_file(): return src + app = Path("/app/skills") + if app.is_dir() and (app / "extract-pintable" / "SKILL.md").is_file(): + return app return dependency_root() / "skills" diff --git a/periscope/src/backend/skills_manifest.json b/periscope/src/backend/skills_manifest.json new file mode 100644 index 0000000..89877fb --- /dev/null +++ b/periscope/src/backend/skills_manifest.json @@ -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" + } +} diff --git a/periscope/src/skills/extract-pattern/SKILL.md b/periscope/src/skills/extract-pattern/SKILL.md new file mode 100644 index 0000000..fc03d2d --- /dev/null +++ b/periscope/src/skills/extract-pattern/SKILL.md @@ -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…)`. Enumerate known codes (`(?P0603|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 '' +``` + +If that passes, call `save_pattern`. diff --git a/periscope/src/skills/extract-pattern/schema.json b/periscope/src/skills/extract-pattern/schema.json new file mode 100644 index 0000000..116306c --- /dev/null +++ b/periscope/src/skills/extract-pattern/schema.json @@ -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" + ] +} diff --git a/periscope/src/skills/extract-pattern/validate.py b/periscope/src/skills/extract-pattern/validate.py new file mode 100644 index 0000000..9c2f290 --- /dev/null +++ b/periscope/src/skills/extract-pattern/validate.py @@ -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 ''") + 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") diff --git a/periscope/src/skills/extract-pintable/SKILL.md b/periscope/src/skills/extract-pintable/SKILL.md new file mode 100644 index 0000000..a215ec9 --- /dev/null +++ b/periscope/src/skills/extract-pintable/SKILL.md @@ -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 MPN’s 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`; MAC–PHY → `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 MPN’s 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 '' +``` + +If that passes, call `save_pintable`. diff --git a/periscope/src/skills/extract-pintable/schema.json b/periscope/src/skills/extract-pintable/schema.json new file mode 100644 index 0000000..9e06362 --- /dev/null +++ b/periscope/src/skills/extract-pintable/schema.json @@ -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" + ] +} diff --git a/periscope/src/skills/extract-pintable/validate.py b/periscope/src/skills/extract-pintable/validate.py new file mode 100644 index 0000000..2d5f948 --- /dev/null +++ b/periscope/src/skills/extract-pintable/validate.py @@ -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 ''") + 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") diff --git a/periscope/src/skills/extract-specs/SKILL.md b/periscope/src/skills/extract-specs/SKILL.md new file mode 100644 index 0000000..d262cfb --- /dev/null +++ b/periscope/src/skills/extract-specs/SKILL.md @@ -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 diff --git a/periscope/src/skills/extract-specs/schema.json b/periscope/src/skills/extract-specs/schema.json new file mode 100644 index 0000000..bfb0c62 --- /dev/null +++ b/periscope/src/skills/extract-specs/schema.json @@ -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" + ] +} diff --git a/periscope/src/skills/extract-specs/validate.py b/periscope/src/skills/extract-specs/validate.py new file mode 100644 index 0000000..f2f5650 --- /dev/null +++ b/periscope/src/skills/extract-specs/validate.py @@ -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 ''") + 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") diff --git a/periscope/src/taxonomy/connector.json b/periscope/src/taxonomy/connector.json new file mode 100644 index 0000000..303edd2 --- /dev/null +++ b/periscope/src/taxonomy/connector.json @@ -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" + } + ] + } + } +} diff --git a/periscope/src/taxonomy/crystal.json b/periscope/src/taxonomy/crystal.json new file mode 100644 index 0000000..89f22a5 --- /dev/null +++ b/periscope/src/taxonomy/crystal.json @@ -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" + } + } +} diff --git a/periscope/src/taxonomy/discrete.json b/periscope/src/taxonomy/discrete.json new file mode 100644 index 0000000..87355b6 --- /dev/null +++ b/periscope/src/taxonomy/discrete.json @@ -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" + } + ] + } + } +} diff --git a/periscope/src/taxonomy/fuse.json b/periscope/src/taxonomy/fuse.json new file mode 100644 index 0000000..29f8c05 --- /dev/null +++ b/periscope/src/taxonomy/fuse.json @@ -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" + } + ] + } + } +} diff --git a/periscope/src/taxonomy/ic.json b/periscope/src/taxonomy/ic.json new file mode 100644 index 0000000..3888133 --- /dev/null +++ b/periscope/src/taxonomy/ic.json @@ -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" + } + } +} diff --git a/periscope/src/taxonomy/passive.json b/periscope/src/taxonomy/passive.json new file mode 100644 index 0000000..f91302e --- /dev/null +++ b/periscope/src/taxonomy/passive.json @@ -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" + } + ] + } + } +} diff --git a/periscope/src/taxonomy/switch.json b/periscope/src/taxonomy/switch.json new file mode 100644 index 0000000..60c4d89 --- /dev/null +++ b/periscope/src/taxonomy/switch.json @@ -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)" + } + ] + } + } +} diff --git a/periscope/src/taxonomy/test_point.json b/periscope/src/taxonomy/test_point.json new file mode 100644 index 0000000..7d8320d --- /dev/null +++ b/periscope/src/taxonomy/test_point.json @@ -0,0 +1,8 @@ +{ + "type": "test_point", + "subtypes": { + "test_point": { + "description": "Test point" + } + } +} diff --git a/periscope/src/taxonomy/transformer.json b/periscope/src/taxonomy/transformer.json new file mode 100644 index 0000000..3543db1 --- /dev/null +++ b/periscope/src/taxonomy/transformer.json @@ -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" + } + ] + } + } +} diff --git a/tests/paths.py b/tests/paths.py index 4baac96..3db43df 100644 --- a/tests/paths.py +++ b/tests/paths.py @@ -6,4 +6,4 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] SIMPLE_PROJECT = REPO_ROOT / "periscope" / "dependency" / "simple_project" -TAXONOMY = REPO_ROOT / "periscope" / "dependency" / "taxonomy" +TAXONOMY = REPO_ROOT / "periscope" / "src" / "taxonomy" diff --git a/tests/test_periscope_taxonomy_rewrite.py b/tests/test_periscope_taxonomy_rewrite.py index 2989ee9..5fb59c1 100644 --- a/tests/test_periscope_taxonomy_rewrite.py +++ b/tests/test_periscope_taxonomy_rewrite.py @@ -28,12 +28,11 @@ def test_taxonomy_module_is_src(): 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() - assert d.parts[-2:] == ("dependency", "taxonomy") + assert d.parts[-2:] == ("src", "taxonomy") assert (d / "ic.json").is_file() assert taxonomy.TAXONOMY_DIR == d - assert not (Path(taxonomy.__file__).resolve().parent.parent.parent / "taxonomy" / "ic.json").is_file() def test_emmaforo_ref_prefixes(): diff --git a/tests/test_periscope_taxonomy_skills_src.py b/tests/test_periscope_taxonomy_skills_src.py new file mode 100644 index 0000000..2a8c3af --- /dev/null +++ b/tests/test_periscope_taxonomy_skills_src.py @@ -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