diff --git a/backend/services/datasheet_finder.py b/backend/services/datasheet_finder.py index 371717b..3c353db 100644 --- a/backend/services/datasheet_finder.py +++ b/backend/services/datasheet_finder.py @@ -81,20 +81,74 @@ def mpn_matches(query: str, candidate: str) -> bool: 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: exact: dict | None = None loose: dict | None = None + family: dict | None = None want = _alnum(mpn) 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: continue - if _alnum(model) == want: + got = _alnum(model) + if got == want: exact = p break if loose is None and mpn_matches(mpn, model): 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: @@ -118,7 +172,7 @@ async def _lcsc_search(keyword: str) -> list[dict]: ) as client: resp = await client.post( f"{_LCSC_BASE}/product/query/list", - json={"keyword": keyword, "currentPage": 1, "pageSize": 15}, + json={"keyword": keyword, "currentPage": 1, "pageSize": 30}, ) resp.raise_for_status() data = resp.json() @@ -163,10 +217,7 @@ async def _from_lcsc(mpn: str, lcsc_id: str | None) -> DatasheetHit | None: product = None if product is None: - keywords = [mpn] - stripped = _strip_packing_alnum(mpn) - if stripped and stripped.upper() != _alnum(mpn): - keywords.append(stripped) + keywords = mpn_query_variants(mpn) for keyword in keywords: try: products = await _lcsc_search(keyword) diff --git a/backend/services/digikey.py b/backend/services/digikey.py index fca52ae..3e67b7f 100644 --- a/backend/services/digikey.py +++ b/backend/services/digikey.py @@ -12,6 +12,12 @@ from dataclasses import dataclass, field import httpx from backend.config import settings +from backend.services.datasheet_finder import ( + _alnum, + mpn_catalog_match, + mpn_matches, + mpn_query_variants, +) 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: - """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 - fall back to ``products[0]`` — keyword-search hits without an MPN match - are usually for a different part, and silently returning them has - polluted the library with wrong specs for non-MPN tokens like ``10uF``. + Prefers punctuation-insensitive equality, then packing suffixes, then a + longer orderable code that starts with the BOM MPN. Does not fall back + to ``products[0]``. """ if not products: return None - mpn_upper = mpn.upper().replace(" ", "") + exact = None + loose = None + family = None + want = _alnum(mpn) for product in products: - if _get_mpn(product).upper().replace(" ", "") == mpn_upper: - return product - return None + cand = _get_mpn(product) + if not cand: + 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: """Search DigiKey for an MPN and return the primary datasheet URL, or None.""" - products = await _keyword_search(mpn) - product = _find_product(mpn, products) - if not product: - return None - url = _get_ds_url(product) - return url or None + 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) + if not product: + continue + url = _get_ds_url(product) + if url: + return url + return None # --------------------------------------------------------------------------- diff --git a/tests/test_datasheet_finder.py b/tests/test_datasheet_finder.py index 79b86fc..863a5f1 100644 --- a/tests/test_datasheet_finder.py +++ b/tests/test_datasheet_finder.py @@ -9,7 +9,9 @@ from backend.services.datasheet_finder import ( _pick_lcsc_product, _ti_slugs, find_datasheet, + mpn_catalog_match, 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("MSPM0G3507SPTR", "MSPM0G3507") assert mpn_matches("MSPM0G3507", "MSPM0G3507SPTR") + assert mpn_matches("25AA1024-I_SM", "25AA1024-I/SM") # Variant letter is a different die — must not match assert not mpn_matches("CH340", "CH340E") assert not mpn_matches("CH340E", "CH340G") 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(): products = [ {"productModel": "TPSPX3819M5-L-3-3", "pdfUrl": "http://a.pdf"}, diff --git a/tests/test_digikey_match.py b/tests/test_digikey_match.py new file mode 100644 index 0000000..5de19cb --- /dev/null +++ b/tests/test_digikey_match.py @@ -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