Match BOM MPNs to longer LCSC/DigiKey orderable codes.
The lookup required an exact catalog string, so family codes like ESP32-S31-WROOM-3 never picked ESP32-S31-WROOM-3-N16R16V and underscore vs slash MPNs never hit DigiKey. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -81,20 +81,74 @@ def mpn_matches(query: str, candidate: str) -> bool:
|
|||||||
return bool(_PACKING_REMAINDER.match(longer[len(shorter):]))
|
return bool(_PACKING_REMAINDER.match(longer[len(shorter):]))
|
||||||
|
|
||||||
|
|
||||||
|
# Base MPNs this long are treated as a manufacturer family code: DigiKey/LCSC
|
||||||
|
# orderable strings may add package/temp (24AA025E64 vs 24AA025E64-I/SN).
|
||||||
|
# Keep this above short tokens like "10uF" / "CH340" so we do not steal a
|
||||||
|
# sibling die's datasheet.
|
||||||
|
_MIN_FAMILY_LEN = 7
|
||||||
|
|
||||||
|
|
||||||
|
def mpn_query_variants(mpn: str) -> list[str]:
|
||||||
|
"""Search strings to try when catalogs spell the same MPN differently."""
|
||||||
|
raw = (mpn or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
variants: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def add(value: str) -> None:
|
||||||
|
value = value.strip()
|
||||||
|
if value and value not in seen:
|
||||||
|
seen.add(value)
|
||||||
|
variants.append(value)
|
||||||
|
|
||||||
|
add(raw)
|
||||||
|
add(raw.replace("_", "/"))
|
||||||
|
add(raw.replace("/", "_"))
|
||||||
|
if "," in raw:
|
||||||
|
add(raw.split(",", 1)[0])
|
||||||
|
if len(_alnum(raw)) > 8 and raw[-1] in "Rr" and raw[-2].isalnum():
|
||||||
|
add(raw[:-1])
|
||||||
|
stripped = _strip_packing_alnum(raw)
|
||||||
|
if stripped:
|
||||||
|
add(stripped)
|
||||||
|
return variants
|
||||||
|
|
||||||
|
|
||||||
|
def mpn_catalog_match(query: str, candidate: str) -> bool:
|
||||||
|
"""Same part for datasheet lookup: punctuation, packing, or orderable suffix."""
|
||||||
|
if mpn_matches(query, candidate):
|
||||||
|
return True
|
||||||
|
q = _alnum(query)
|
||||||
|
c = _alnum(candidate)
|
||||||
|
if len(q) < _MIN_FAMILY_LEN or not c:
|
||||||
|
return False
|
||||||
|
return c.startswith(q) and len(c) > len(q)
|
||||||
|
|
||||||
|
|
||||||
def _pick_lcsc_product(mpn: str, products: list[dict]) -> dict | None:
|
def _pick_lcsc_product(mpn: str, products: list[dict]) -> dict | None:
|
||||||
exact: dict | None = None
|
exact: dict | None = None
|
||||||
loose: dict | None = None
|
loose: dict | None = None
|
||||||
|
family: dict | None = None
|
||||||
want = _alnum(mpn)
|
want = _alnum(mpn)
|
||||||
for p in products:
|
for p in products:
|
||||||
model = p.get("productModel") or ""
|
model = (
|
||||||
|
p.get("productModel")
|
||||||
|
or p.get("productName")
|
||||||
|
or p.get("productIntroEn")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
if not model:
|
if not model:
|
||||||
continue
|
continue
|
||||||
if _alnum(model) == want:
|
got = _alnum(model)
|
||||||
|
if got == want:
|
||||||
exact = p
|
exact = p
|
||||||
break
|
break
|
||||||
if loose is None and mpn_matches(mpn, model):
|
if loose is None and mpn_matches(mpn, model):
|
||||||
loose = p
|
loose = p
|
||||||
return exact or loose
|
elif family is None and mpn_catalog_match(mpn, model):
|
||||||
|
family = p
|
||||||
|
return exact or loose or family
|
||||||
|
|
||||||
|
|
||||||
async def _download_pdf(url: str) -> bytes:
|
async def _download_pdf(url: str) -> bytes:
|
||||||
@@ -118,7 +172,7 @@ async def _lcsc_search(keyword: str) -> list[dict]:
|
|||||||
) as client:
|
) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{_LCSC_BASE}/product/query/list",
|
f"{_LCSC_BASE}/product/query/list",
|
||||||
json={"keyword": keyword, "currentPage": 1, "pageSize": 15},
|
json={"keyword": keyword, "currentPage": 1, "pageSize": 30},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -163,10 +217,7 @@ async def _from_lcsc(mpn: str, lcsc_id: str | None) -> DatasheetHit | None:
|
|||||||
product = None
|
product = None
|
||||||
|
|
||||||
if product is None:
|
if product is None:
|
||||||
keywords = [mpn]
|
keywords = mpn_query_variants(mpn)
|
||||||
stripped = _strip_packing_alnum(mpn)
|
|
||||||
if stripped and stripped.upper() != _alnum(mpn):
|
|
||||||
keywords.append(stripped)
|
|
||||||
for keyword in keywords:
|
for keyword in keywords:
|
||||||
try:
|
try:
|
||||||
products = await _lcsc_search(keyword)
|
products = await _lcsc_search(keyword)
|
||||||
|
|||||||
+37
-12
@@ -12,6 +12,12 @@ from dataclasses import dataclass, field
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
|
from backend.services.datasheet_finder import (
|
||||||
|
_alnum,
|
||||||
|
mpn_catalog_match,
|
||||||
|
mpn_matches,
|
||||||
|
mpn_query_variants,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -103,31 +109,50 @@ async def _keyword_search(mpn: str) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _find_product(mpn: str, products: list[dict]) -> dict | None:
|
def _find_product(mpn: str, products: list[dict]) -> dict | None:
|
||||||
"""Find the product whose MPN exactly matches ``mpn`` (case/space-insensitive).
|
"""Pick a DigiKey product for ``mpn``.
|
||||||
|
|
||||||
Returns None when no result has a matching MPN. We intentionally do NOT
|
Prefers punctuation-insensitive equality, then packing suffixes, then a
|
||||||
fall back to ``products[0]`` — keyword-search hits without an MPN match
|
longer orderable code that starts with the BOM MPN. Does not fall back
|
||||||
are usually for a different part, and silently returning them has
|
to ``products[0]``.
|
||||||
polluted the library with wrong specs for non-MPN tokens like ``10uF``.
|
|
||||||
"""
|
"""
|
||||||
if not products:
|
if not products:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
mpn_upper = mpn.upper().replace(" ", "")
|
exact = None
|
||||||
|
loose = None
|
||||||
|
family = None
|
||||||
|
want = _alnum(mpn)
|
||||||
for product in products:
|
for product in products:
|
||||||
if _get_mpn(product).upper().replace(" ", "") == mpn_upper:
|
cand = _get_mpn(product)
|
||||||
return product
|
if not cand:
|
||||||
return None
|
continue
|
||||||
|
got = _alnum(cand)
|
||||||
|
if got == want:
|
||||||
|
exact = product
|
||||||
|
break
|
||||||
|
if loose is None and mpn_matches(mpn, cand):
|
||||||
|
loose = product
|
||||||
|
elif family is None and mpn_catalog_match(mpn, cand):
|
||||||
|
family = product
|
||||||
|
return exact or loose or family
|
||||||
|
|
||||||
|
|
||||||
async def _search_mpn(mpn: str) -> str | None:
|
async def _search_mpn(mpn: str) -> str | None:
|
||||||
"""Search DigiKey for an MPN and return the primary datasheet URL, or None."""
|
"""Search DigiKey for an MPN and return the primary datasheet URL, or None."""
|
||||||
products = await _keyword_search(mpn)
|
tried: set[str] = set()
|
||||||
|
for keyword in mpn_query_variants(mpn):
|
||||||
|
key = keyword.upper()
|
||||||
|
if key in tried:
|
||||||
|
continue
|
||||||
|
tried.add(key)
|
||||||
|
products = await _keyword_search(keyword)
|
||||||
product = _find_product(mpn, products)
|
product = _find_product(mpn, products)
|
||||||
if not product:
|
if not product:
|
||||||
return None
|
continue
|
||||||
url = _get_ds_url(product)
|
url = _get_ds_url(product)
|
||||||
return url or None
|
if url:
|
||||||
|
return url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ from backend.services.datasheet_finder import (
|
|||||||
_pick_lcsc_product,
|
_pick_lcsc_product,
|
||||||
_ti_slugs,
|
_ti_slugs,
|
||||||
find_datasheet,
|
find_datasheet,
|
||||||
|
mpn_catalog_match,
|
||||||
mpn_matches,
|
mpn_matches,
|
||||||
|
mpn_query_variants,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -18,12 +20,41 @@ def test_mpn_matches_exact_and_packing():
|
|||||||
assert mpn_matches("SPX3819M5-L-3-3", "SPX3819M5-L-3-3/TR")
|
assert mpn_matches("SPX3819M5-L-3-3", "SPX3819M5-L-3-3/TR")
|
||||||
assert mpn_matches("MSPM0G3507SPTR", "MSPM0G3507")
|
assert mpn_matches("MSPM0G3507SPTR", "MSPM0G3507")
|
||||||
assert mpn_matches("MSPM0G3507", "MSPM0G3507SPTR")
|
assert mpn_matches("MSPM0G3507", "MSPM0G3507SPTR")
|
||||||
|
assert mpn_matches("25AA1024-I_SM", "25AA1024-I/SM")
|
||||||
# Variant letter is a different die — must not match
|
# Variant letter is a different die — must not match
|
||||||
assert not mpn_matches("CH340", "CH340E")
|
assert not mpn_matches("CH340", "CH340E")
|
||||||
assert not mpn_matches("CH340E", "CH340G")
|
assert not mpn_matches("CH340E", "CH340G")
|
||||||
assert not mpn_matches("TLV9062", "TLV9002")
|
assert not mpn_matches("TLV9062", "TLV9002")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mpn_catalog_match_orderable_suffix():
|
||||||
|
assert mpn_catalog_match("ESP32-S31-WROOM-3", "ESP32-S31-WROOM-3-N16R16V")
|
||||||
|
assert mpn_catalog_match("24AA025E64", "24AA025E64-I/SN")
|
||||||
|
assert mpn_catalog_match("LAN8720A", "LAN8720A-CP-TR")
|
||||||
|
assert mpn_catalog_match("W25Q128JVS", "W25Q128JVSIQ")
|
||||||
|
assert not mpn_catalog_match("CH340", "CH340E")
|
||||||
|
assert not mpn_catalog_match("10uF", "GRM21BR61A106KE19L")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mpn_query_variants_underscore_and_reel():
|
||||||
|
variants = mpn_query_variants("25AA1024-I_SM")
|
||||||
|
assert "25AA1024-I/SM" in variants
|
||||||
|
variants = mpn_query_variants("ADAU1467WBCPZ300R")
|
||||||
|
assert "ADAU1467WBCPZ300" in variants
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_lcsc_family_orderable():
|
||||||
|
products = [
|
||||||
|
{
|
||||||
|
"productModel": "ESP32-S31-WROOM-3-N16R16V",
|
||||||
|
"pdfUrl": "http://esp.pdf",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
picked = _pick_lcsc_product("ESP32-S31-WROOM-3", products)
|
||||||
|
assert picked is not None
|
||||||
|
assert picked["pdfUrl"] == "http://esp.pdf"
|
||||||
|
|
||||||
|
|
||||||
def test_pick_lcsc_prefers_exact_model():
|
def test_pick_lcsc_prefers_exact_model():
|
||||||
products = [
|
products = [
|
||||||
{"productModel": "TPSPX3819M5-L-3-3", "pdfUrl": "http://a.pdf"},
|
{"productModel": "TPSPX3819M5-L-3-3", "pdfUrl": "http://a.pdf"},
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from backend.services.digikey import _find_product
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_product_accepts_underscore_vs_slash():
|
||||||
|
products = [{"ManufacturerProductNumber": "25AA1024-I/SM", "DatasheetUrl": "http://a.pdf"}]
|
||||||
|
picked = _find_product("25AA1024-I_SM", products)
|
||||||
|
assert picked is not None
|
||||||
|
assert picked["DatasheetUrl"] == "http://a.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_product_accepts_family_orderable():
|
||||||
|
products = [
|
||||||
|
{"ManufacturerProductNumber": "24AA025E64-I/SN", "DatasheetUrl": "http://b.pdf"},
|
||||||
|
]
|
||||||
|
picked = _find_product("24AA025E64", products)
|
||||||
|
assert picked is not None
|
||||||
|
assert picked["DatasheetUrl"] == "http://b.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_product_rejects_unrelated():
|
||||||
|
products = [{"ManufacturerProductNumber": "GRM21BR61A106KE19L", "DatasheetUrl": "http://c.pdf"}]
|
||||||
|
assert _find_product("10uF", products) is None
|
||||||
|
assert _find_product("CH340", [{"ManufacturerProductNumber": "CH340E"}]) is None
|
||||||
Reference in New Issue
Block a user