Resolve passives from catalog, BOM value, and Murata LQW18AN without an LLM.

Skip the model (and the credit gate) when LCSC/DigiKey already list C/V/Z, when the BOM value is a single R/C/L/FB token, or when the MPN is an LQW18AN inductor. Ferrite beads keep impedance, not invented henries; value-only specs stay out of the shared library.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 20:25:34 +02:00
co-authored by Cursor
parent 2113e3975e
commit edbb47a08b
14 changed files with 596 additions and 88 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ def build_bom_summary(
# Drop None values and internal numeric fields # Drop None values and internal numeric fields
raw = { raw = {
k: v for k, v in raw.items() k: v for k, v in raw.items()
if v is not None and k not in ("value_ohms", "value_farads", "value_henries") if v is not None and k not in ("value_ohms", "value_farads", "value_henries", "impedance_ohm")
} }
specs_dict = raw if raw else None specs_dict = raw if raw else None
+14 -3
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from enum import Enum from enum import Enum
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from pydantic import BaseModel, Discriminator, Field, Tag, field_validator from pydantic import BaseModel, Discriminator, Field, Tag, field_validator, model_validator
class Pin(BaseModel): class Pin(BaseModel):
@@ -131,20 +131,31 @@ class CapacitorSpecs(BaseModel):
class InductorSpecs(BaseModel): class InductorSpecs(BaseModel):
"""Standardised inductor parameters. Value always in henries.""" """Standardised inductor / ferrite-bead parameters."""
specs_type: Literal["inductor"] = "inductor" specs_type: Literal["inductor"] = "inductor"
component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead" component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead"
value_henries: float value_henries: float | None = None
value_formatted: str value_formatted: str
tolerance: str | None = None # "±5%" or "±0.1uH" tolerance: str | None = None # "±5%" or "±0.1uH"
package: str | None = None package: str | None = None
current_rating_a: str | None = None current_rating_a: str | None = None
dcr_ohms: float | None = None dcr_ohms: float | None = None
impedance_ohm: float | None = None # ferrite beads: Z at test frequency
_validate_subtype = field_validator("component_subtype", mode="before")( _validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype) staticmethod(_check_subtype)
) )
@model_validator(mode="after")
def _require_primary_value(self) -> InductorSpecs:
if self.component_subtype == "passive.ferrite_bead":
if self.impedance_ohm is None:
raise ValueError("ferrite bead requires impedance_ohm")
return self
if self.value_henries is None:
raise ValueError("inductor requires value_henries")
return self
class SimpleComponentSpecs(BaseModel): class SimpleComponentSpecs(BaseModel):
"""Specs for discrete/simple components. Schema defined in taxonomy JSON.""" """Specs for discrete/simple components. Schema defined in taxonomy JSON."""
+23 -1
View File
@@ -304,7 +304,29 @@ def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpec
dielectric=dielectric, dielectric=dielectric,
) )
if subtype.startswith("passive.inductor") or subtype == "passive.ferrite_bead": if subtype == "passive.ferrite_bead":
raw = vals.get("impedance_ohm") or vals.get("value_ohms")
if raw is None:
raise ValueError("Missing impedance_ohm in auto-resolved ferrite bead specs")
impedance_ohm = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw)
current_rating_a = str(vals.get("current_rating_a")) if vals.get("current_rating_a") else None
dcr_raw = vals.get("dcr_ohms")
dcr_ohms: float | None = None
if dcr_raw is not None:
dcr_ohms = _parse_spice_value(str(dcr_raw)) if isinstance(dcr_raw, str) else float(dcr_raw)
formatted = value_formatted or _format_value(impedance_ohm, "ohm")
return InductorSpecs(
component_subtype=subtype_for_specs,
value_henries=None,
value_formatted=formatted,
tolerance=tolerance,
package=package,
current_rating_a=current_rating_a,
dcr_ohms=dcr_ohms,
impedance_ohm=impedance_ohm,
)
if subtype.startswith("passive.inductor"):
raw = vals.get("value_henries") raw = vals.get("value_henries")
if raw is None: if raw is None:
raise ValueError(f"Missing value_henries in auto-resolved inductor specs") raise ValueError(f"Missing value_henries in auto-resolved inductor specs")
+31 -11
View File
@@ -808,12 +808,10 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
import asyncio import asyncio
from backend.services.digikey import fetch_params from backend.services.digikey import fetch_params
from backend.services.extraction import auto_resolve_specs from backend.services.extraction import CatalogResolveMiss, auto_resolve_specs
if not settings.use_digikey: if not settings.use_digikey:
raise HTTPException(400, "DigiKey API not configured") raise HTTPException(400, "DigiKey API not configured")
if not settings.has_llm_credentials():
raise HTTPException(400, "LLM API key not configured")
storage = get_storage(request) storage = get_storage(request)
sem = asyncio.Semaphore(10) sem = asyncio.Semaphore(10)
@@ -840,14 +838,18 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
if not result.ok or not result.params: if not result.ok or not result.params:
return {"mpn": item.mpn, "status": "failed", "error": result.error or "No parameters"} return {"mpn": item.mpn, "status": "failed", "error": result.error or "No parameters"}
# Map params to taxonomy via Haiku # Map params: catalog parse first, LLM only if needed.
model = await auto_resolve_specs( try:
mpn=item.mpn, model = await auto_resolve_specs(
digikey_params=result.params.parameters, mpn=item.mpn,
digikey_category=result.params.category, digikey_params=result.params.parameters,
digikey_description=result.params.description, digikey_category=result.params.category,
component_type=item.component_type, digikey_description=result.params.description,
) component_type=item.component_type,
use_llm=settings.has_llm_credentials(),
)
except CatalogResolveMiss as e:
return {"mpn": item.mpn, "status": "failed", "error": str(e)}
# Save to library # Save to library
storage.write_json(lib_key, model.model_dump()) storage.write_json(lib_key, model.model_dump())
@@ -963,6 +965,24 @@ async def lcsc_resolve_passive(
"cannot auto-resolve", "cannot auto-resolve",
) )
from backend.services.passive_from_distributor import specs_from_lcsc_payload
catalog_model = specs_from_lcsc_payload(mpn, payload)
if catalog_model is not None:
storage.write_json(project_model_key, catalog_model.model_dump())
proj_svc.save_to_library(
storage, project_model_key, "passives", f"{safe}.json",
)
return {
"mpn": mpn,
"safe_mpn": safe,
"model": catalog_model.model_dump(),
"cached": False,
"lcsc_id": req.lcsc_id,
}
# Catalog miss (ferrite, odd text): LLM path, charged if the logger has tokens.
# Download taxonomy to a temp dir so auto_resolve_specs can read/write it. # Download taxonomy to a temp dir so auto_resolve_specs can read/write it.
# Mirrors the PipelineWorkspace pattern: pinscopex operates on local paths. # Mirrors the PipelineWorkspace pattern: pinscopex operates on local paths.
api_logger = ApiLogger() api_logger = ApiLogger()
+30 -5
View File
@@ -871,6 +871,10 @@ Call save_resolved_specs with the mapped values.\
""" """
class CatalogResolveMiss(RuntimeError):
"""Distributor params did not parse and ``use_llm`` was false."""
async def auto_resolve_specs( async def auto_resolve_specs(
mpn: str, mpn: str,
digikey_params: list[dict[str, str]], digikey_params: list[dict[str, str]],
@@ -879,10 +883,13 @@ async def auto_resolve_specs(
component_type: str, component_type: str,
taxonomy_dir: Path | None = None, taxonomy_dir: Path | None = None,
api_logger: ApiLogger | None = None, api_logger: ApiLogger | None = None,
*,
use_llm: bool = True,
) -> ComponentModel: ) -> ComponentModel:
"""Map DigiKey product parameters to taxonomy specs using a lightweight model. """Map DigiKey/LCSC product parameters to taxonomy specs.
Returns a ComponentModel ready to persist. Raises on failure. Passives with a parseable value skip the model. ``use_llm=False`` returns
only that catalog parse or raises :class:`CatalogResolveMiss`.
""" """
tax_dir = taxonomy_dir or settings.taxonomy_dir tax_dir = taxonomy_dir or settings.taxonomy_dir
@@ -902,6 +909,9 @@ async def auto_resolve_specs(
) )
return direct return direct
if not use_llm:
raise CatalogResolveMiss(f"No catalog specs for {mpn}")
# Auto-generate type-level specs if none exist # Auto-generate type-level specs if none exist
if not has_specs(component_type, tax_dir): if not has_specs(component_type, tax_dir):
try: try:
@@ -1020,7 +1030,7 @@ _PASSIVE_PREFIX_HINT: dict[str, str] = {
"C": "capacitor — populate value_farads", "C": "capacitor — populate value_farads",
"R": "resistor — populate value_ohms", "R": "resistor — populate value_ohms",
"L": "inductor — populate value_henries", "L": "inductor — populate value_henries",
"FB": "ferrite bead — populate value_ohms (impedance)", "FB": "ferrite bead — populate impedance_ohm (Z at test frequency, not henries)",
} }
_VALUE_RESOLVE_SYSTEM = """\ _VALUE_RESOLVE_SYSTEM = """\
@@ -1041,8 +1051,9 @@ CRITICAL RULES:
dielectric, package, or power rating. Never invent these. dielectric, package, or power rating. Never invent these.
- Populate EXACTLY TWO fields: ``value_formatted`` (a normalized human-readable - Populate EXACTLY TWO fields: ``value_formatted`` (a normalized human-readable
string) and the matching primary numeric field string) and the matching primary numeric field
(``value_farads`` / ``value_ohms`` / ``value_henries``). Leave every other (``value_farads`` / ``value_ohms`` / ``value_henries`` / ``impedance_ohm``
parameter out (do not include a null entry — omit the key entirely). for ferrite beads). Leave every other parameter out (do not include a null
entry — omit the key entirely). Never invent henries for a ferrite bead.
- Express numeric values with SPICE multiplier prefixes and units - Express numeric values with SPICE multiplier prefixes and units
(u=1e-6, n=1e-9, p=1e-12, k=1e3, M=1e6). Examples: ``10uF``, ``4.7kohm``, ``100nH``. (u=1e-6, n=1e-9, p=1e-12, k=1e3, M=1e6). Examples: ``10uF``, ``4.7kohm``, ``100nH``.
- Pick the GENERIC parent subtype — e.g. ``passive.capacitor``, ``passive.resistor``, - Pick the GENERIC parent subtype — e.g. ``passive.capacitor``, ``passive.resistor``,
@@ -1076,6 +1087,20 @@ async def resolve_from_value(
""" """
tax_dir = taxonomy_dir or settings.taxonomy_dir tax_dir = taxonomy_dir or settings.taxonomy_dir
from backend.services.passive_from_value import (
is_placeholder_value,
specs_from_bom_value,
)
parsed = specs_from_bom_value(mpn, value, ref_prefix)
if parsed is not None:
logging.getLogger(__name__).info(
"Resolved from value %s=%r without LLM", mpn, value,
)
return parsed
if is_placeholder_value(value):
raise ValueError(f"Placeholder BOM value {value!r} for {mpn}")
if not has_specs(component_type, tax_dir): if not has_specs(component_type, tax_dir):
try: try:
await _generate_type_specs(component_type, tax_dir, api_logger=api_logger) await _generate_type_specs(component_type, tax_dir, api_logger=api_logger)
+40 -2
View File
@@ -25,6 +25,7 @@ _TOL = re.compile(r"±\s*(?P<num>\d+(?:\.\d+)?)\s*%")
_VOLT = re.compile(r"(?P<num>\d+(?:\.\d+)?)\s*V\b") _VOLT = re.compile(r"(?P<num>\d+(?:\.\d+)?)\s*V\b")
_PKG = re.compile(r"\b(?P<pkg>0201|0402|0603|0805|1206|1210|1812|2220|2512)\b") _PKG = re.compile(r"\b(?P<pkg>0201|0402|0603|0805|1206|1210|1812|2220|2512)\b")
_DIEL = re.compile(r"\b(?P<diel>C0G|NP0|X5R|X6S|X7R|X7S|X8R|Y5V|Z5U)\b", re.I) _DIEL = re.compile(r"\b(?P<diel>C0G|NP0|X5R|X6S|X7R|X7S|X8R|Y5V|Z5U)\b", re.I)
_IMP_FREQ = re.compile(r"@\s*\d+(?:\.\d+)?\s*(?:kHz|MHz|GHz|Hz)", re.I)
_MUL = { _MUL = {
"p": 1e-12, "n": 1e-9, "u": 1e-6, "μ": 1e-6, "µ": 1e-6, "p": 1e-12, "n": 1e-9, "u": 1e-6, "μ": 1e-6, "µ": 1e-6,
@@ -77,8 +78,8 @@ def _first(pmap: dict[str, str], *needles: str) -> str | None:
def _classify(category: str, description: str, pmap: dict[str, str]) -> str | None: def _classify(category: str, description: str, pmap: dict[str, str]) -> str | None:
blob = f"{category} {description} {' '.join(pmap.values())}".lower() blob = f"{category} {description} {' '.join(pmap.values())}".lower()
if "ferrite" in blob or "bead" in blob: if "ferrite" in blob or re.search(r"\bbead\b", blob):
return None # typed model wants henries; leave to the LLM return "passive.ferrite_bead"
if "capacitor" in blob or "mlcc" in blob or "ceramic" in blob: if "capacitor" in blob or "mlcc" in blob or "ceramic" in blob:
diel = _DIEL.search(description) or _DIEL.search(" ".join(pmap.values())) diel = _DIEL.search(description) or _DIEL.search(" ".join(pmap.values()))
if diel or "ceramic" in blob or "mlcc" in blob: if diel or "ceramic" in blob or "mlcc" in blob:
@@ -162,6 +163,21 @@ def specs_from_distributor(
power = _first(pmap, "power") power = _first(pmap, "power")
if power: if power:
values["power_rating_w"] = power values["power_rating_w"] = power
elif subtype == "passive.ferrite_bead":
imp_src = _first(pmap, "impedance") or text
m = _RES.search(imp_src) or _RES.search(text)
if not m:
return None
z = _spice(m.group("num"), m.group("mul"), "ohm")
values["impedance_ohm"] = z
freq = _IMP_FREQ.search(imp_src) or _IMP_FREQ.search(text)
values["value_formatted"] = f"{z}@{freq.group(0)[1:].strip()}" if freq else z
cur = _first(pmap, "current rating", "rated current")
if cur:
values["current_rating_a"] = cur
dcr = _first(pmap, "dc resistance", "dcr")
if dcr:
values["dcr_ohms"] = dcr
elif subtype.startswith("passive.inductor"): elif subtype.startswith("passive.inductor"):
if not ind: if not ind:
return None return None
@@ -197,3 +213,25 @@ def specs_from_distributor(
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
return ComponentModel(mpn=mpn, specs=typed) return ComponentModel(mpn=mpn, specs=typed)
def lcsc_payload_args(payload: dict) -> tuple[list[dict[str, str]], str, str]:
"""Turn a cached LCSC product dict into distributor mapper arguments."""
category = " / ".join(
p for p in (payload.get("category"), payload.get("subcategory")) if p
)
params: list[dict[str, str]] = []
if payload.get("package"):
params.append({"name": "Package / Case", "value": str(payload["package"])})
if payload.get("manufacturer"):
params.append({"name": "Manufacturer", "value": str(payload["manufacturer"])})
return params, category, str(payload.get("description") or "")
def specs_from_lcsc_payload(mpn: str, payload: dict) -> ComponentModel | None:
params, category, description = lcsc_payload_args(payload)
if not description:
return None
return specs_from_distributor(
mpn=mpn, params=params, category=category, description=description,
)
+41 -1
View File
@@ -1,4 +1,4 @@
"""Decode common chip R/C MPNs into typed specs without an LLM. """Decode common chip R/C/L MPNs into typed specs without an LLM.
Only encodings that carry package + value (and voltage for capacitors when Only encodings that carry package + value (and voltage for capacitors when
the manufacturer puts it in the code) are accepted. Incomplete BOM-value the manufacturer puts it in the code) are accepted. Incomplete BOM-value
@@ -51,6 +51,22 @@ _CHIP_R = re.compile(
rf"^(?:FRC)?({_SIZE})(?:W\d)?([{''.join('FJKG')}])(\d{{4}})", rf"^(?:FRC)?({_SIZE})(?:W\d)?([{''.join('FJKG')}])(\d{{4}})",
re.IGNORECASE, re.IGNORECASE,
) )
# Murata LQW18AN: 0603 wirewound. Inductance is three chars (12N, 2N2, R10)
# then EIA tolerance, then a two-digit spec (00/10) and packing.
_LQW18AN = re.compile(
r"^LQW18AN(?P<l>[0-9]N[0-9]|[0-9]{2}N|R[0-9]{2})(?P<tol>[BCSGHJKD])\d{2}",
re.IGNORECASE,
)
_LQW_TOL = {
"B": "±0.1nH",
"C": "±0.2nH",
"S": "±0.3nH",
"D": "±0.5nH",
"G": "±2%",
"H": "±3%",
"J": "±5%",
"K": "±10%",
}
def _eia3_pf(digits: str) -> float: def _eia3_pf(digits: str) -> float:
@@ -74,6 +90,17 @@ def _eia4_ohm(digits: str) -> float:
return float(int(digits[:3]) * (10 ** int(digits[3]))) return float(int(digits[:3]) * (10 ** int(digits[3])))
def _lqw_nh(code: str) -> float | None:
c = code.upper()
if re.fullmatch(r"[0-9]N[0-9]", c):
return float(f"{c[0]}.{c[2]}")
if re.fullmatch(r"[0-9]{2}N", c):
return float(c[:2])
if re.fullmatch(r"R[0-9]{2}", c):
return float(f"0.{c[1:]}") * 1000.0
return None
def _model(mpn: str, subtype: str, values: dict[str, str]) -> ComponentModel | None: def _model(mpn: str, subtype: str, values: dict[str, str]) -> ComponentModel | None:
specs = SimpleComponentSpecs( specs = SimpleComponentSpecs(
specs_type="passive", specs_type="passive",
@@ -136,4 +163,17 @@ def specs_from_mpn(mpn: str) -> ComponentModel | None:
} }
return _model(raw, "passive.resistor.thick_film", values) return _model(raw, "passive.resistor.thick_film", values)
m = _LQW18AN.match(raw)
if m:
nh = _lqw_nh(m.group("l"))
if nh is not None:
henries = _spice(str(nh), "n", "H")
values = {
"value_henries": henries,
"value_formatted": henries,
"package": "0603",
"tolerance": _LQW_TOL[m.group("tol").upper()],
}
return _model(raw, "passive.inductor", values)
return None return None
+119
View File
@@ -0,0 +1,119 @@
"""Parse a BOM Value string into typed passive specs without an LLM."""
from __future__ import annotations
import re
from backend.pinscopex.models import ComponentModel, SimpleComponentSpecs
from backend.pinscopex.resolve_passives import simple_to_typed_passive_specs
from backend.services.passive_from_distributor import _spice
_PLACEHOLDER = re.compile(
r"^(?:dnp|dni|dns|nc|n/?c|n/?a|np|nfs|tbd|todo|jumper|jmp|short|open|-|—|)$",
re.I,
)
_CAP = re.compile(
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmk])?\s*[fF]$",
)
_IND = re.compile(
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmk])?\s*H$",
re.I,
)
_RES_UNIT = re.compile(
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmkM])?\s*(?:ohms?|Ω|R)$",
re.I,
)
_RES_BARE_MUL = re.compile(
r"^(?P<num>\d+(?:\.\d+)?)(?P<mul>[kKmM])$",
)
_EURO_R = re.compile(
r"^(?P<a>\d+)[kK](?P<b>\d+)$",
)
_FB = re.compile(
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[kKmM])?\s*(?:ohms?|Ω|R)"
r"(?:@\s*(?P<freq>\d+(?:\.\d+)?\s*(?:kHz|MHz|GHz|Hz)))?$",
re.I,
)
def is_placeholder_value(value: str) -> bool:
return bool(_PLACEHOLDER.match((value or "").strip()))
def _model(mpn: str, subtype: str, values: dict[str, str]) -> ComponentModel | None:
specs = SimpleComponentSpecs(
specs_type="passive",
component_subtype=subtype,
values=values,
)
try:
typed = simple_to_typed_passive_specs(specs)
except (ValueError, TypeError):
return None
return ComponentModel(mpn=mpn, specs=typed)
def specs_from_bom_value(
mpn: str, value: str, ref_prefix: str,
) -> ComponentModel | None:
"""Map ``18pF`` / ``4.7k`` / ``10uH`` / ``600R@100MHz`` to typed specs.
Returns None for placeholders (DNP, NC, JUMPER) and for strings that
are not a single passive value. Callers must not save the result to the
shared library.
"""
raw = (value or "").strip()
prefix = (ref_prefix or "").upper()
if not raw or is_placeholder_value(raw):
return None
compact = re.sub(r"\s+", "", raw)
if prefix == "FB" or "@" in compact:
m = _FB.match(compact) or _FB.match(raw)
if m:
z = _spice(m.group("num"), m.group("mul"), "ohm")
freq = re.sub(r"\s+", "", m.group("freq") or "")
values = {
"impedance_ohm": z,
"value_formatted": f"{z}@{freq}" if freq else z,
}
return _model(mpn, "passive.ferrite_bead", values)
if prefix in {"C", ""}:
m = _CAP.match(compact) or _CAP.match(raw)
if m:
farads = _spice(m.group("num"), m.group("mul"), "F")
return _model(mpn, "passive.capacitor", {
"value_farads": farads,
"value_formatted": farads,
})
if prefix in {"L", ""}:
m = _IND.match(compact) or _IND.match(raw)
if m:
henries = _spice(m.group("num"), m.group("mul"), "H")
return _model(mpn, "passive.inductor", {
"value_henries": henries,
"value_formatted": henries,
})
if prefix in {"R", ""}:
m = _EURO_R.match(compact)
if m:
ohms = float(f"{m.group('a')}.{m.group('b')}") * 1e3
formatted = _spice(str(ohms), None, "ohm")
return _model(mpn, "passive.resistor", {
"value_ohms": formatted,
"value_formatted": formatted,
})
m = _RES_UNIT.match(compact) or _RES_BARE_MUL.match(compact)
if m:
formatted = _spice(m.group("num"), m.group("mul"), "ohm")
return _model(mpn, "passive.resistor", {
"value_ohms": formatted,
"value_formatted": formatted,
})
return None
+91 -56
View File
@@ -1082,67 +1082,65 @@ async def _catalog_resolve_unresolved_passives(
"detail": "specs from library"}) "detail": "specs from library"})
return return
if not _check_credit_gate(ctx, "passive_extraction", mpn,
estimate_stage_cost_usd("digikey_resolve")):
await _paused_stage_publish(ctx, "passive_extraction", "out of credits")
return
private = ApiLogger(free=ctx.api_logger.free) private = ApiLogger(free=ctx.api_logger.free)
model = None model = None
resolved_via: str | None = None resolved_via: str | None = None
first_error: str | None = None first_error: str | None = None
llm_args: tuple[list[dict[str, str]], str, str, str] | None = None
try: try:
from backend.services.passive_from_distributor import (
specs_from_distributor,
specs_from_lcsc_payload,
lcsc_payload_args,
)
async def _gate_llm() -> bool:
if not _check_credit_gate(
ctx, "passive_extraction", mpn,
estimate_stage_cost_usd("digikey_resolve"),
):
await _paused_stage_publish(
ctx, "passive_extraction", "out of credits",
)
return False
return True
lcsc = ctx.lcsc_data.get(mpn) lcsc = ctx.lcsc_data.get(mpn)
if lcsc and lcsc.get("description"): if lcsc and lcsc.get("description"):
try: broker.publish(ctx.project_id, "step_update",
broker.publish(ctx.project_id, "step_update", {"stage": "passive_extraction",
{"stage": "passive_extraction", "substep": mpn, "status": "running",
"substep": mpn, "status": "running", "detail": "resolving from LCSC catalog"})
"detail": "auto-resolving via LCSC"}) model = specs_from_lcsc_payload(mpn, lcsc)
synth_category = " / ".join( if model is not None:
p for p in (lcsc.get("category"), lcsc.get("subcategory")) if p resolved_via = "lcsc"
) else:
synth_params: list[dict[str, str]] = [] params, category, description = lcsc_payload_args(lcsc)
if lcsc.get("package"): llm_args = (params, category, description, "lcsc")
synth_params.append(
{"name": "Package / Case", "value": lcsc["package"]},
)
if lcsc.get("manufacturer"):
synth_params.append(
{"name": "Manufacturer", "value": lcsc["manufacturer"]},
)
model = await extraction.auto_resolve_specs(
mpn=mpn,
digikey_params=synth_params,
digikey_category=synth_category or "",
digikey_description=lcsc["description"],
component_type="passive",
taxonomy_dir=ctx.ws.taxonomy_dir,
api_logger=private,
)
if model is not None:
resolved_via = "lcsc"
except Exception as e:
first_error = str(e)
if model is None and settings.use_digikey: if model is None and settings.use_digikey:
try: try:
broker.publish(ctx.project_id, "step_update", broker.publish(ctx.project_id, "step_update",
{"stage": "passive_extraction", {"stage": "passive_extraction",
"substep": mpn, "status": "running", "substep": mpn, "status": "running",
"detail": "auto-resolving via DigiKey"}) "detail": "resolving from DigiKey catalog"})
result = await fetch_params(mpn) result = await fetch_params(mpn)
if result.ok and result.params: if result.ok and result.params:
model = await extraction.auto_resolve_specs( model = specs_from_distributor(
mpn=mpn, mpn=mpn,
digikey_params=result.params.parameters, params=result.params.parameters,
digikey_category=result.params.category, category=result.params.category,
digikey_description=result.params.description, description=result.params.description,
component_type="passive",
taxonomy_dir=ctx.ws.taxonomy_dir,
api_logger=private,
) )
resolved_via = "digikey" if model is not None:
resolved_via = "digikey"
else:
llm_args = (
result.params.parameters,
result.params.category,
result.params.description,
"digikey",
)
else: else:
first_error = result.error or "no DigiKey parameters" first_error = result.error or "no DigiKey parameters"
except Exception as e: except Exception as e:
@@ -1158,27 +1156,64 @@ async def _catalog_resolve_unresolved_passives(
"substep": mpn, "status": "running", "substep": mpn, "status": "running",
"detail": "decoded from MPN"}) "detail": "decoded from MPN"})
if model is None and llm_args is not None:
if not await _gate_llm():
return
params, category, description, via = llm_args
try:
broker.publish(ctx.project_id, "step_update",
{"stage": "passive_extraction",
"substep": mpn, "status": "running",
"detail": f"auto-resolving via {via} (LLM)"})
model = await extraction.auto_resolve_specs(
mpn=mpn,
digikey_params=params,
digikey_category=category,
digikey_description=description,
component_type="passive",
taxonomy_dir=ctx.ws.taxonomy_dir,
api_logger=private,
)
if model is not None:
resolved_via = via
except Exception as e:
first_error = str(e)
if model is None: if model is None:
from backend.services.passive_from_value import (
is_placeholder_value,
specs_from_bom_value,
)
bom_value = ctx.passive_values.get(mpn, "").strip() bom_value = ctx.passive_values.get(mpn, "").strip()
refs = ctx.passive_mpns.get(mpn, []) refs = ctx.passive_mpns.get(mpn, [])
pref_match = re.match(r"^[A-Za-z]+", refs[0]) if refs else None pref_match = re.match(r"^[A-Za-z]+", refs[0]) if refs else None
ref_prefix = pref_match.group(0).upper() if pref_match else "" ref_prefix = pref_match.group(0).upper() if pref_match else ""
if bom_value and ref_prefix in {"C", "R", "L", "FB"}: if bom_value and ref_prefix in {"C", "R", "L", "FB"}:
try: model = specs_from_bom_value(mpn, bom_value, ref_prefix)
if model is not None:
resolved_via = "value"
broker.publish(ctx.project_id, "step_update", broker.publish(ctx.project_id, "step_update",
{"stage": "passive_extraction", {"stage": "passive_extraction",
"substep": mpn, "status": "running", "substep": mpn, "status": "running",
"detail": f"resolving from BOM value {bom_value!r}"}) "detail": f"parsed BOM value {bom_value!r}"})
model = await extraction.resolve_from_value( elif not is_placeholder_value(bom_value):
mpn=mpn, value=bom_value, if not await _gate_llm():
ref_prefix=ref_prefix, return
component_type="passive", try:
taxonomy_dir=ctx.ws.taxonomy_dir, broker.publish(ctx.project_id, "step_update",
api_logger=private, {"stage": "passive_extraction",
) "substep": mpn, "status": "running",
resolved_via = "value" "detail": f"resolving from BOM value {bom_value!r}"})
except Exception as e: model = await extraction.resolve_from_value(
first_error = first_error or str(e) mpn=mpn, value=bom_value,
ref_prefix=ref_prefix,
component_type="passive",
taxonomy_dir=ctx.ws.taxonomy_dir,
api_logger=private,
)
resolved_via = "value"
except Exception as e:
first_error = first_error or str(e)
if model is None: if model is None:
err = first_error or "no LCSC/DigiKey hit and no usable BOM value" err = first_error or "no LCSC/DigiKey hit and no usable BOM value"
+1 -1
View File
@@ -62,7 +62,7 @@
"passive.ferrite_bead": { "passive.ferrite_bead": {
"description": "Ferrite bead", "description": "Ferrite bead",
"extra_specs": [ "extra_specs": [
{"name": "value_henries", "description": "Impedance at test frequency (stored as inductance proxy)", "unit": "H"}, {"name": "impedance_ohm", "description": "Impedance at the test frequency", "unit": "ohm", "required": true},
{"name": "current_rating_a", "description": "Rated current", "unit": "A"}, {"name": "current_rating_a", "description": "Rated current", "unit": "A"},
{"name": "dcr_ohms", "description": "DC resistance", "unit": "ohm"} {"name": "dcr_ohms", "description": "DC resistance", "unit": "ohm"}
] ]
+91
View File
@@ -40,3 +40,94 @@ def test_skips_when_no_value():
category="", category="",
description="some module", description="some module",
) is None ) is None
def test_ferrite_bead_from_impedance():
model = specs_from_distributor(
mpn="BLM18PG121SN1D",
params=[
{"name": "Impedance @ Frequency", "value": "120 Ohms @ 100 MHz"},
{"name": "Package / Case", "value": "0603"},
{"name": "Current Rating", "value": "2 A"},
],
category="Filters / Ferrite Beads",
description="FERRITE BEAD 120 OHM 0603 1LN",
)
assert model is not None
specs = model.specs
assert specs.component_subtype == "passive.ferrite_bead"
assert specs.impedance_ohm == 120
assert specs.value_henries is None
assert "120ohm" in specs.value_formatted
assert "100" in specs.value_formatted
assert specs.package == "0603"
assert specs.current_rating_a == "2 A"
def test_specs_from_lcsc_payload():
from backend.services.passive_from_distributor import specs_from_lcsc_payload
model = specs_from_lcsc_payload(
"CL21B225KPFNNNE",
{
"package": "0805",
"manufacturer": "Samsung",
"category": "Capacitors",
"subcategory": "MLCC",
"description": "2.2uF ±10% 10V X7R 0805",
},
)
assert model is not None
assert abs(model.specs.value_farads - 2.2e-6) < 1e-12
def test_auto_resolve_skips_llm_when_catalog_parses(monkeypatch):
import asyncio
from pathlib import Path
from backend.services.extraction import auto_resolve_specs
async def boom(*_a, **_k):
raise AssertionError("LLM must not run")
monkeypatch.setattr("backend.services.extraction.call_with_fallback", boom)
tax = Path(__file__).resolve().parents[1] / "taxonomy"
model = asyncio.run(
auto_resolve_specs(
mpn="CL21B225KPFNNNE",
digikey_params=[{"name": "Package / Case", "value": "0805"}],
digikey_category="Capacitors / Ceramic Capacitors",
digikey_description="2.2uF ±10% 10V X7R 0805",
component_type="passive",
taxonomy_dir=tax,
)
)
assert abs(model.specs.value_farads - 2.2e-6) < 1e-12
def test_auto_resolve_use_llm_false_on_ferrite(monkeypatch):
import asyncio
from pathlib import Path
from backend.services.extraction import auto_resolve_specs
async def boom(*_a, **_k):
raise AssertionError("LLM must not run")
monkeypatch.setattr("backend.services.extraction.call_with_fallback", boom)
tax = Path(__file__).resolve().parents[1] / "taxonomy"
model = asyncio.run(
auto_resolve_specs(
mpn="BLM18PG121SN1D",
digikey_params=[
{"name": "Impedance @ Frequency", "value": "120 Ohms @ 100 MHz"},
],
digikey_category="Filters / Ferrite Beads",
digikey_description="FERRITE BEAD 120 OHM 0603 1LN",
component_type="passive",
taxonomy_dir=tax,
use_llm=False,
)
)
assert model.specs.impedance_ohm == 120
assert model.specs.component_subtype == "passive.ferrite_bead"
+21 -1
View File
@@ -38,7 +38,27 @@ def test_chip_resistor_from_mpn():
assert abs(model.specs.value_ohms - 2200) < 0.1 assert abs(model.specs.value_ohms - 2200) < 0.1
def test_skips_murata_and_bare_value(): def test_murata_lqw18an():
model = specs_from_mpn("LQW18AN12NG00D")
assert model is not None
assert model.specs.specs_type == "inductor"
assert abs(model.specs.value_henries - 12e-9) < 1e-15
assert model.specs.package == "0603"
assert model.specs.tolerance == "±2%"
model = specs_from_mpn("LQW18AN18NJ00D")
assert abs(model.specs.value_henries - 18e-9) < 1e-15
assert model.specs.tolerance == "±5%"
model = specs_from_mpn("LQW18AN2N2D00D")
assert abs(model.specs.value_henries - 2.2e-9) < 1e-15
assert model.specs.tolerance == "±0.5nH"
model = specs_from_mpn("LQW18ANR10G00D")
assert abs(model.specs.value_henries - 100e-9) < 1e-15
def test_skips_opaque_murata_and_bare_value():
assert specs_from_mpn("GRM21A5C2J200JA01") is None assert specs_from_mpn("GRM21A5C2J200JA01") is None
assert specs_from_mpn("18pF") is None assert specs_from_mpn("18pF") is None
assert specs_from_mpn("CH340E") is None assert specs_from_mpn("CH340E") is None
+88
View File
@@ -0,0 +1,88 @@
import asyncio
from pathlib import Path
import pytest
from backend.services.passive_from_value import (
is_placeholder_value,
specs_from_bom_value,
)
def test_capacitor_picofarads():
model = specs_from_bom_value("18pF", "18pF", "C")
assert model is not None
assert model.specs.specs_type == "capacitor"
assert abs(model.specs.value_farads - 18e-12) < 1e-18
def test_resistor_kilo_and_euro():
k = specs_from_bom_value("4.7k", "4.7k", "R")
assert k is not None
assert abs(k.specs.value_ohms - 4700) < 1e-6
euro = specs_from_bom_value("4k7", "4k7", "R")
assert euro is not None
assert abs(euro.specs.value_ohms - 4700) < 1e-6
def test_inductor_uh():
model = specs_from_bom_value("10uH", "10uH", "L")
assert model is not None
assert abs(model.specs.value_henries - 10e-6) < 1e-12
def test_ferrite_impedance_at_freq():
model = specs_from_bom_value("600R@100MHz", "600R@100MHz", "FB")
assert model is not None
assert model.specs.component_subtype == "passive.ferrite_bead"
assert model.specs.impedance_ohm == 600
assert model.specs.value_henries is None
assert "100MHz" in model.specs.value_formatted
def test_placeholders_skip():
for raw in ("DNP", "NC", "JUMPER", "TBD", "-"):
assert is_placeholder_value(raw)
assert specs_from_bom_value(raw, raw, "R") is None
def test_ambiguous_string_returns_none():
assert specs_from_bom_value("mystery", "do not stuff", "R") is None
def test_resolve_from_value_skips_llm_when_parseable(monkeypatch):
from backend.services.extraction import resolve_from_value
async def boom(*_a, **_k):
raise AssertionError("LLM must not run")
monkeypatch.setattr("backend.services.extraction.call_with_fallback", boom)
tax = Path(__file__).resolve().parents[1] / "taxonomy"
model = asyncio.run(
resolve_from_value(
mpn="18pF",
value="18pF",
ref_prefix="C",
taxonomy_dir=tax,
)
)
assert abs(model.specs.value_farads - 18e-12) < 1e-18
def test_resolve_from_value_placeholder_no_llm(monkeypatch):
from backend.services.extraction import resolve_from_value
async def boom(*_a, **_k):
raise AssertionError("LLM must not run")
monkeypatch.setattr("backend.services.extraction.call_with_fallback", boom)
tax = Path(__file__).resolve().parents[1] / "taxonomy"
with pytest.raises(ValueError, match="Placeholder"):
asyncio.run(
resolve_from_value(
mpn="DNP",
value="DNP",
ref_prefix="R",
taxonomy_dir=tax,
)
)
+5 -6
View File
@@ -447,9 +447,8 @@ def test_lcsc_resolve_passive_404_when_lcsc_id_missing(tmp_path, monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch): async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch):
"""First call resolves via auto_resolve_specs (mocked) and writes the """First call parses the LCSC description without the LLM. Second call
project model + library copy. Second call short-circuits with cached=True short-circuits with cached=True and does not invoke auto_resolve_specs."""
and does not invoke auto_resolve_specs again."""
from backend.config import settings from backend.config import settings
from backend.pinscopex.models import CapacitorSpecs, ComponentModel from backend.pinscopex.models import CapacitorSpecs, ComponentModel
from backend.services import purple_parts from backend.services import purple_parts
@@ -526,7 +525,7 @@ async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch):
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
# First call: resolves via mocked auto_resolve_specs. # First call: catalog parse, no LLM.
resp = client.post( resp = client.post(
f"/api/projects/{project_id}/lcsc/resolve-passive", f"/api/projects/{project_id}/lcsc/resolve-passive",
json={"lcsc_id": "C15850"}, json={"lcsc_id": "C15850"},
@@ -537,7 +536,7 @@ async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch):
assert body["lcsc_id"] == "C15850" assert body["lcsc_id"] == "C15850"
assert body["cached"] is False assert body["cached"] is False
assert body["model"]["mpn"] == "CL21A106KAYNNNE" assert body["model"]["mpn"] == "CL21A106KAYNNNE"
assert call_count["n"] == 1 assert call_count["n"] == 0
# Library copy should exist for cross-project reuse. # Library copy should exist for cross-project reuse.
from backend.pinscopex.utils import safe_mpn from backend.pinscopex.utils import safe_mpn
@@ -553,7 +552,7 @@ async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch):
body = resp.json() body = resp.json()
assert body["cached"] is True assert body["cached"] is True
assert body["model"]["mpn"] == "CL21A106KAYNNNE" assert body["model"]["mpn"] == "CL21A106KAYNNNE"
assert call_count["n"] == 1 # not invoked again assert call_count["n"] == 0 # still no LLM
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------