Files
periscope/backend/periscopex/taxonomy.py
T
micheleandCursor 8d2b85600f Rebrand Pinscope to Periscope across product and codebase.
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>
2026-09-13 20:02:04 +02:00

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()