Rename the core package to periscopex, update UI/docs/Docker/deploy defaults to periscope.michelebigi.it, and keep legacy version/storage key aliases so existing projects keep working. Co-authored-by: Cursor <cursoragent@cursor.com>
314 lines
10 KiB
Python
314 lines
10 KiB
Python
"""Living component taxonomy: load, query, and grow the subtype tree.
|
|
|
|
Storage: one JSON file per top-level type in ``taxonomy/``.
|
|
Each file is a self-contained document that maps 1:1 to a Firestore
|
|
document, so only the relevant branch needs to be fetched/injected
|
|
into extraction prompts.
|
|
|
|
::
|
|
|
|
taxonomy/
|
|
├── ic.json # all IC subtypes
|
|
├── passive.json # all passive subtypes
|
|
├── discrete.json # diodes, transistors, LEDs
|
|
├── connector.json
|
|
├── crystal.json
|
|
└── ...
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
TAXONOMY_DIR = Path(__file__).resolve().parent.parent.parent / "taxonomy"
|
|
|
|
# Reference-designator prefix -> taxonomy top-level type.
|
|
# Used by extraction skills: "I see 'U' so I only need the ic branch."
|
|
REF_PREFIX_TO_TYPE: dict[str, str] = {
|
|
"U": "ic",
|
|
"IC": "ic",
|
|
"R": "passive",
|
|
"C": "passive",
|
|
"L": "passive",
|
|
"FB": "passive",
|
|
"J": "connector",
|
|
"X": "crystal",
|
|
"Y": "crystal",
|
|
"D": "discrete",
|
|
"LED": "discrete",
|
|
"Q": "discrete",
|
|
"T": "transformer",
|
|
"F": "fuse",
|
|
"SW": "switch",
|
|
"TP": "test_point",
|
|
"FM": "fiducial",
|
|
"MH": "mechanical",
|
|
}
|
|
|
|
# Canonical format for dotted subtype keys.
|
|
SUBTYPE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")
|
|
|
|
# All valid top-level taxonomy types (derived from ref-prefix mapping).
|
|
KNOWN_TYPES: frozenset[str] = frozenset(REF_PREFIX_TO_TYPE.values())
|
|
|
|
|
|
def validate_subtype(value: str) -> str:
|
|
"""Validate and normalize a component_subtype string.
|
|
|
|
Lowercases, replaces hyphens/spaces with underscores, then checks
|
|
the dotted format and that the top-level segment is a known type.
|
|
|
|
Returns the normalized value. Raises ``ValueError`` if invalid.
|
|
"""
|
|
v = value.strip().lower().replace("-", "_").replace(" ", "_")
|
|
if not SUBTYPE_PATTERN.match(v):
|
|
raise ValueError(
|
|
f"Invalid component_subtype format: {value!r}. "
|
|
f"Expected dotted lowercase path like 'ic.mcu' or 'passive.resistor'"
|
|
)
|
|
top = v.split(".")[0]
|
|
if top not in KNOWN_TYPES:
|
|
raise ValueError(
|
|
f"Unknown top-level taxonomy type: {top!r} (from {value!r}). "
|
|
f"Known types: {sorted(KNOWN_TYPES)}"
|
|
)
|
|
return v
|
|
|
|
|
|
def type_for_ref(ref: str) -> str | None:
|
|
"""Map a reference designator (e.g. 'U3', 'C12') to a taxonomy type."""
|
|
prefix = re.match(r"^[A-Za-z]+", ref)
|
|
if not prefix:
|
|
return None
|
|
return REF_PREFIX_TO_TYPE.get(prefix.group().upper())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _load_type_file(top_type: str, directory: Path = TAXONOMY_DIR) -> dict:
|
|
"""Load a single type file, returning its raw JSON."""
|
|
path = directory / f"{top_type}.json"
|
|
if not path.exists():
|
|
return {"type": top_type, "subtypes": {}}
|
|
return json.loads(path.read_text())
|
|
|
|
|
|
def _save_type_file(top_type: str, data: dict, directory: Path = TAXONOMY_DIR) -> None:
|
|
"""Write a type file back to disk."""
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
path = directory / f"{top_type}.json"
|
|
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
|
|
|
|
def load_subtypes(
|
|
top_type: str | None = None,
|
|
directory: Path = TAXONOMY_DIR,
|
|
) -> dict[str, dict]:
|
|
"""Return subtypes as ``{dotted_key: {description, example_mpn?}}``.
|
|
|
|
If *top_type* is given (e.g. ``"ic"``), only that file is loaded —
|
|
keeping prompt injection small. If ``None``, all files are merged.
|
|
"""
|
|
if top_type is not None:
|
|
return dict(_load_type_file(top_type, directory).get("subtypes", {}))
|
|
|
|
merged: dict[str, dict] = {}
|
|
for f in sorted(directory.glob("*.json")):
|
|
data = json.loads(f.read_text())
|
|
merged.update(data.get("subtypes", {}))
|
|
return merged
|
|
|
|
|
|
def list_subtypes(
|
|
prefix: str | None = None,
|
|
directory: Path = TAXONOMY_DIR,
|
|
) -> list[str]:
|
|
"""List subtype keys, optionally filtered by dotted prefix.
|
|
|
|
Efficient: if *prefix* starts with a known top-level type, only that
|
|
single file is loaded.
|
|
|
|
Examples::
|
|
|
|
list_subtypes() # all subtypes (loads every file)
|
|
list_subtypes("ic") # only ic.json loaded
|
|
list_subtypes("ic.power") # only ic.json loaded, filtered
|
|
list_subtypes("passive") # only passive.json loaded
|
|
"""
|
|
# Determine which top-level type file to load
|
|
top_type: str | None = None
|
|
if prefix is not None:
|
|
top_type = prefix.split(".")[0]
|
|
|
|
subtypes = load_subtypes(top_type, directory)
|
|
|
|
if prefix is None:
|
|
return sorted(subtypes.keys())
|
|
|
|
prefix_dot = prefix if prefix.endswith(".") else prefix + "."
|
|
return sorted(k for k in subtypes if k == prefix or k.startswith(prefix_dot))
|
|
|
|
|
|
def get_subtype(key: str, directory: Path = TAXONOMY_DIR) -> dict | None:
|
|
"""Get a single subtype entry by its dotted key, or None."""
|
|
top_type = key.split(".")[0]
|
|
subtypes = load_subtypes(top_type, directory)
|
|
return subtypes.get(key)
|
|
|
|
|
|
def set_type_specs(
|
|
top_type: str,
|
|
specs: list[dict],
|
|
directory: Path = TAXONOMY_DIR,
|
|
) -> None:
|
|
"""Set type-level specs on a taxonomy file."""
|
|
data = _load_type_file(top_type, directory)
|
|
data["specs"] = specs
|
|
_save_type_file(top_type, data, directory)
|
|
|
|
|
|
def set_extra_specs(
|
|
subtype_key: str,
|
|
extra_specs: list[dict],
|
|
directory: Path = TAXONOMY_DIR,
|
|
) -> None:
|
|
"""Set extra_specs on an existing subtype entry."""
|
|
top_type = subtype_key.split(".")[0]
|
|
data = _load_type_file(top_type, directory)
|
|
subtypes = data.get("subtypes", {})
|
|
if subtype_key not in subtypes:
|
|
return
|
|
subtypes[subtype_key]["extra_specs"] = extra_specs
|
|
_save_type_file(top_type, data, directory)
|
|
|
|
|
|
def has_specs(top_type: str, directory: Path = TAXONOMY_DIR) -> bool:
|
|
"""Check if a taxonomy type has any specs defined (type-level or extra)."""
|
|
data = _load_type_file(top_type, directory)
|
|
if data.get("specs"):
|
|
return True
|
|
for entry in data.get("subtypes", {}).values():
|
|
if entry.get("extra_specs"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def add_subtype(
|
|
key: str,
|
|
description: str,
|
|
example_mpn: str | None = None,
|
|
directory: Path = TAXONOMY_DIR,
|
|
) -> None:
|
|
"""Add a new subtype. Creates the type file if needed. No-op if exists."""
|
|
key = validate_subtype(key)
|
|
top_type = key.split(".")[0]
|
|
data = _load_type_file(top_type, directory)
|
|
subtypes = data.setdefault("subtypes", {})
|
|
|
|
if key in subtypes:
|
|
return
|
|
|
|
entry: dict[str, str] = {"description": description}
|
|
if example_mpn:
|
|
entry["example_mpn"] = example_mpn
|
|
subtypes[key] = entry
|
|
|
|
data["type"] = top_type
|
|
_save_type_file(top_type, data, directory)
|
|
|
|
|
|
def get_specs_schema(
|
|
top_type: str,
|
|
subtype_key: str | None = None,
|
|
directory: Path = TAXONOMY_DIR,
|
|
) -> list[dict]:
|
|
"""Return merged specs list: type-level ``specs`` + subtype ``extra_specs``."""
|
|
data = _load_type_file(top_type, directory)
|
|
specs = list(data.get("specs", []))
|
|
if subtype_key:
|
|
entry = data.get("subtypes", {}).get(subtype_key, {})
|
|
specs.extend(entry.get("extra_specs", []))
|
|
return specs
|
|
|
|
|
|
def format_specs_for_prompt(top_type: str, directory: Path = TAXONOMY_DIR) -> str:
|
|
"""Format type-level + all subtype extra_specs as prompt text.
|
|
|
|
Includes all possible parameters across subtypes so the extraction
|
|
skill knows the full set of fields it might encounter.
|
|
"""
|
|
data = _load_type_file(top_type, directory)
|
|
base_specs = data.get("specs", [])
|
|
# Collect all extra_specs across subtypes (deduplicate by name)
|
|
all_extra: dict[str, dict] = {}
|
|
for entry in data.get("subtypes", {}).values():
|
|
for s in entry.get("extra_specs", []):
|
|
all_extra[s["name"]] = s
|
|
all_specs = list(base_specs) + list(all_extra.values())
|
|
if not all_specs:
|
|
return ""
|
|
lines = [
|
|
"PARAMETERS TO EXTRACT (include all that are relevant to this component):",
|
|
"",
|
|
"Use SPICE multiplier prefixes for values: "
|
|
"T=1e12, G=1e9, M=1e6, k=1e3, m=1e-3, u=1e-6, n=1e-9, p=1e-12.",
|
|
"Examples: 30V, 240mV, 500mA, 47mohm, 18pF, 8MHz, 10nC.",
|
|
"Always include the unit with the multiplier in the value string.",
|
|
"",
|
|
]
|
|
for s in all_specs:
|
|
req = " (REQUIRED)" if s.get("required") else ""
|
|
unit = f" [{s['unit']}]" if s.get("unit") else ""
|
|
lines.append(f"- {s['name']}{unit}: {s['description']}{req}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def format_for_prompt(top_type: str, directory: Path = TAXONOMY_DIR) -> str:
|
|
"""Format a type's subtypes as a compact string for LLM prompt injection.
|
|
|
|
Returns something like::
|
|
|
|
ic.mcu — Microcontroller (e.g. MSPM0G3507SPTR)
|
|
ic.power.ldo — Low-dropout voltage regulator (e.g. SPX3819M5-L-3-3)
|
|
ic.power.switching_regulator — Switching voltage regulator (buck, boost, buck-boost)
|
|
...
|
|
"""
|
|
subtypes = load_subtypes(top_type, directory)
|
|
lines: list[str] = []
|
|
for key in sorted(subtypes):
|
|
entry = subtypes[key]
|
|
line = f"{key} — {entry['description']}"
|
|
if "example_mpn" in entry:
|
|
line += f" (e.g. {entry['example_mpn']})"
|
|
lines.append(line)
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Simple types (taxonomy-driven specs extraction via PDF)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _compute_simple_types(directory: Path = TAXONOMY_DIR) -> frozenset[str]:
|
|
"""Types that have a ``specs`` schema and use PDF-based extraction.
|
|
|
|
Excludes ``ic`` (pintable + rules) and ``passive`` (pattern-based).
|
|
"""
|
|
result: set[str] = set()
|
|
if not directory.is_dir():
|
|
return frozenset(result)
|
|
for f in directory.glob("*.json"):
|
|
data = json.loads(f.read_text())
|
|
t = data.get("type", "")
|
|
if t not in ("ic", "passive") and data.get("specs"):
|
|
result.add(t)
|
|
return frozenset(result)
|
|
|
|
|
|
SIMPLE_TYPES: frozenset[str] = _compute_simple_types()
|