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:
@@ -45,7 +45,7 @@ def build_bom_summary(
|
||||
# Drop None values and internal numeric fields
|
||||
raw = {
|
||||
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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from enum import Enum
|
||||
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):
|
||||
@@ -131,20 +131,31 @@ class CapacitorSpecs(BaseModel):
|
||||
|
||||
|
||||
class InductorSpecs(BaseModel):
|
||||
"""Standardised inductor parameters. Value always in henries."""
|
||||
"""Standardised inductor / ferrite-bead parameters."""
|
||||
specs_type: Literal["inductor"] = "inductor"
|
||||
component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead"
|
||||
value_henries: float
|
||||
value_henries: float | None = None
|
||||
value_formatted: str
|
||||
tolerance: str | None = None # "±5%" or "±0.1uH"
|
||||
package: str | None = None
|
||||
current_rating_a: str | 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")(
|
||||
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):
|
||||
"""Specs for discrete/simple components. Schema defined in taxonomy JSON."""
|
||||
|
||||
@@ -304,7 +304,29 @@ def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpec
|
||||
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")
|
||||
if raw is None:
|
||||
raise ValueError(f"Missing value_henries in auto-resolved inductor specs")
|
||||
|
||||
+31
-11
@@ -808,12 +808,10 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
|
||||
import asyncio
|
||||
|
||||
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:
|
||||
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)
|
||||
sem = asyncio.Semaphore(10)
|
||||
@@ -840,14 +838,18 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
|
||||
if not result.ok or not result.params:
|
||||
return {"mpn": item.mpn, "status": "failed", "error": result.error or "No parameters"}
|
||||
|
||||
# Map params to taxonomy via Haiku
|
||||
model = await auto_resolve_specs(
|
||||
mpn=item.mpn,
|
||||
digikey_params=result.params.parameters,
|
||||
digikey_category=result.params.category,
|
||||
digikey_description=result.params.description,
|
||||
component_type=item.component_type,
|
||||
)
|
||||
# Map params: catalog parse first, LLM only if needed.
|
||||
try:
|
||||
model = await auto_resolve_specs(
|
||||
mpn=item.mpn,
|
||||
digikey_params=result.params.parameters,
|
||||
digikey_category=result.params.category,
|
||||
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
|
||||
storage.write_json(lib_key, model.model_dump())
|
||||
@@ -963,6 +965,24 @@ async def lcsc_resolve_passive(
|
||||
"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.
|
||||
# Mirrors the PipelineWorkspace pattern: pinscopex operates on local paths.
|
||||
api_logger = ApiLogger()
|
||||
|
||||
@@ -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(
|
||||
mpn: str,
|
||||
digikey_params: list[dict[str, str]],
|
||||
@@ -879,10 +883,13 @@ async def auto_resolve_specs(
|
||||
component_type: str,
|
||||
taxonomy_dir: Path | None = None,
|
||||
api_logger: ApiLogger | None = None,
|
||||
*,
|
||||
use_llm: bool = True,
|
||||
) -> 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
|
||||
|
||||
@@ -902,6 +909,9 @@ async def auto_resolve_specs(
|
||||
)
|
||||
return direct
|
||||
|
||||
if not use_llm:
|
||||
raise CatalogResolveMiss(f"No catalog specs for {mpn}")
|
||||
|
||||
# Auto-generate type-level specs if none exist
|
||||
if not has_specs(component_type, tax_dir):
|
||||
try:
|
||||
@@ -1020,7 +1030,7 @@ _PASSIVE_PREFIX_HINT: dict[str, str] = {
|
||||
"C": "capacitor — populate value_farads",
|
||||
"R": "resistor — populate value_ohms",
|
||||
"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 = """\
|
||||
@@ -1041,8 +1051,9 @@ CRITICAL RULES:
|
||||
dielectric, package, or power rating. Never invent these.
|
||||
- Populate EXACTLY TWO fields: ``value_formatted`` (a normalized human-readable
|
||||
string) and the matching primary numeric field
|
||||
(``value_farads`` / ``value_ohms`` / ``value_henries``). Leave every other
|
||||
parameter out (do not include a null entry — omit the key entirely).
|
||||
(``value_farads`` / ``value_ohms`` / ``value_henries`` / ``impedance_ohm``
|
||||
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
|
||||
(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``,
|
||||
@@ -1076,6 +1087,20 @@ async def resolve_from_value(
|
||||
"""
|
||||
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):
|
||||
try:
|
||||
await _generate_type_specs(component_type, tax_dir, api_logger=api_logger)
|
||||
|
||||
@@ -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")
|
||||
_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)
|
||||
_IMP_FREQ = re.compile(r"@\s*\d+(?:\.\d+)?\s*(?:kHz|MHz|GHz|Hz)", re.I)
|
||||
|
||||
_MUL = {
|
||||
"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:
|
||||
blob = f"{category} {description} {' '.join(pmap.values())}".lower()
|
||||
if "ferrite" in blob or "bead" in blob:
|
||||
return None # typed model wants henries; leave to the LLM
|
||||
if "ferrite" in blob or re.search(r"\bbead\b", blob):
|
||||
return "passive.ferrite_bead"
|
||||
if "capacitor" in blob or "mlcc" in blob or "ceramic" in blob:
|
||||
diel = _DIEL.search(description) or _DIEL.search(" ".join(pmap.values()))
|
||||
if diel or "ceramic" in blob or "mlcc" in blob:
|
||||
@@ -162,6 +163,21 @@ def specs_from_distributor(
|
||||
power = _first(pmap, "power")
|
||||
if 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"):
|
||||
if not ind:
|
||||
return None
|
||||
@@ -197,3 +213,25 @@ def specs_from_distributor(
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
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}})",
|
||||
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:
|
||||
@@ -74,6 +90,17 @@ def _eia4_ohm(digits: str) -> float:
|
||||
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:
|
||||
specs = SimpleComponentSpecs(
|
||||
specs_type="passive",
|
||||
@@ -136,4 +163,17 @@ def specs_from_mpn(mpn: str) -> ComponentModel | None:
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -1082,67 +1082,65 @@ async def _catalog_resolve_unresolved_passives(
|
||||
"detail": "specs from library"})
|
||||
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)
|
||||
model = None
|
||||
resolved_via: str | None = None
|
||||
first_error: str | None = None
|
||||
llm_args: tuple[list[dict[str, str]], str, str, str] | None = None
|
||||
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)
|
||||
if lcsc and lcsc.get("description"):
|
||||
try:
|
||||
broker.publish(ctx.project_id, "step_update",
|
||||
{"stage": "passive_extraction",
|
||||
"substep": mpn, "status": "running",
|
||||
"detail": "auto-resolving via LCSC"})
|
||||
synth_category = " / ".join(
|
||||
p for p in (lcsc.get("category"), lcsc.get("subcategory")) if p
|
||||
)
|
||||
synth_params: list[dict[str, str]] = []
|
||||
if lcsc.get("package"):
|
||||
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)
|
||||
broker.publish(ctx.project_id, "step_update",
|
||||
{"stage": "passive_extraction",
|
||||
"substep": mpn, "status": "running",
|
||||
"detail": "resolving from LCSC catalog"})
|
||||
model = specs_from_lcsc_payload(mpn, lcsc)
|
||||
if model is not None:
|
||||
resolved_via = "lcsc"
|
||||
else:
|
||||
params, category, description = lcsc_payload_args(lcsc)
|
||||
llm_args = (params, category, description, "lcsc")
|
||||
|
||||
if model is None and settings.use_digikey:
|
||||
try:
|
||||
broker.publish(ctx.project_id, "step_update",
|
||||
{"stage": "passive_extraction",
|
||||
"substep": mpn, "status": "running",
|
||||
"detail": "auto-resolving via DigiKey"})
|
||||
"detail": "resolving from DigiKey catalog"})
|
||||
result = await fetch_params(mpn)
|
||||
if result.ok and result.params:
|
||||
model = await extraction.auto_resolve_specs(
|
||||
model = specs_from_distributor(
|
||||
mpn=mpn,
|
||||
digikey_params=result.params.parameters,
|
||||
digikey_category=result.params.category,
|
||||
digikey_description=result.params.description,
|
||||
component_type="passive",
|
||||
taxonomy_dir=ctx.ws.taxonomy_dir,
|
||||
api_logger=private,
|
||||
params=result.params.parameters,
|
||||
category=result.params.category,
|
||||
description=result.params.description,
|
||||
)
|
||||
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:
|
||||
first_error = result.error or "no DigiKey parameters"
|
||||
except Exception as e:
|
||||
@@ -1158,27 +1156,64 @@ async def _catalog_resolve_unresolved_passives(
|
||||
"substep": mpn, "status": "running",
|
||||
"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:
|
||||
from backend.services.passive_from_value import (
|
||||
is_placeholder_value,
|
||||
specs_from_bom_value,
|
||||
)
|
||||
bom_value = ctx.passive_values.get(mpn, "").strip()
|
||||
refs = ctx.passive_mpns.get(mpn, [])
|
||||
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 ""
|
||||
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",
|
||||
{"stage": "passive_extraction",
|
||||
"substep": mpn, "status": "running",
|
||||
"detail": f"resolving from BOM value {bom_value!r}"})
|
||||
model = await extraction.resolve_from_value(
|
||||
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)
|
||||
"detail": f"parsed BOM value {bom_value!r}"})
|
||||
elif not is_placeholder_value(bom_value):
|
||||
if not await _gate_llm():
|
||||
return
|
||||
try:
|
||||
broker.publish(ctx.project_id, "step_update",
|
||||
{"stage": "passive_extraction",
|
||||
"substep": mpn, "status": "running",
|
||||
"detail": f"resolving from BOM value {bom_value!r}"})
|
||||
model = await extraction.resolve_from_value(
|
||||
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:
|
||||
err = first_error or "no LCSC/DigiKey hit and no usable BOM value"
|
||||
|
||||
Reference in New Issue
Block a user