Просмотр исходного кода

more or less completed category inference

n2749 1 неделя назад
Родитель
Сommit
54445476f7

+ 9 - 1
bom_assistant/ai/client.py

@@ -21,5 +21,13 @@ def complete(prompt: str, max_tokens: int = 200) -> str:
         model=model,
         max_tokens=max_tokens,
         messages=[{"role": "user", "content": prompt}],
+        extra_body={"reasoning": {"enabled": False}},
     )
-    return resp.choices[0].message.content.strip()
+    content = resp.choices[0].message.content
+    if content is None:
+        finish_reason = resp.choices[0].finish_reason
+        raise RuntimeError(
+            f"OpenRouter returned empty content for model {model!r} "
+            f"(finish_reason={finish_reason!r}, raw={resp.model_dump_json()})"
+        )
+    return content.strip()

+ 0 - 2
bom_assistant/suppliers/category_resolver.py

@@ -84,8 +84,6 @@ def _ai_resolve(row: BomRow, subcats: list[dict], supplier: str) -> list[Resolve
     )
     try:
         text = complete(prompt, max_tokens=150)
-        print(f"prompt\n\n{prompt}\n\n")
-        print(f"text\n\n{text}\n\n")
         m = re.search(r'"subcategories"\s*:\s*\[([^\]]+)\]', text, re.DOTALL)
         if not m:
             return []

+ 960 - 0
bom_assistant/suppliers/lcsc/category_cache.json

@@ -0,0 +1,960 @@
+{
+  "resistor||10k": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "capacitor||330uF": [
+    {
+      "name": "Aluminum Electrolytic Capacitors",
+      "id": 1140
+    },
+    {
+      "name": "Aluminum - Polymer Capacitors",
+      "id": 1139
+    },
+    {
+      "name": "Film Capacitors",
+      "id": 1144
+    }
+  ],
+  "capacitor||2.2uF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Aluminum Electrolytic Capacitors",
+      "id": 1140
+    },
+    {
+      "name": "Aluminum - Polymer Capacitors",
+      "id": 1139
+    }
+  ],
+  "capacitor||220nF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Film Capacitors",
+      "id": 1144
+    },
+    {
+      "name": "Aluminum Electrolytic Capacitors",
+      "id": 1140
+    }
+  ],
+  "capacitor||15pF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Capacitor Networks, Arrays",
+      "id": 1141
+    },
+    {
+      "name": "Mica and PTFE Capacitors",
+      "id": 1145
+    }
+  ],
+  "capacitor||6.8nF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Thin Film Capacitors",
+      "id": 1151
+    },
+    {
+      "name": "Silicon Capacitors",
+      "id": 1148
+    }
+  ],
+  "capacitor||1nF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Silicon Capacitors",
+      "id": 1148
+    },
+    {
+      "name": "Thin Film Capacitors",
+      "id": 1151
+    }
+  ],
+  "capacitor||100nF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Film Capacitors",
+      "id": 1144
+    },
+    {
+      "name": "Capacitor Networks, Arrays",
+      "id": 1141
+    }
+  ],
+  "capacitor||0.01uF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Film Capacitors",
+      "id": 1144
+    },
+    {
+      "name": "Capacitor Networks, Arrays",
+      "id": 1141
+    }
+  ],
+  "capacitor||150pF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Capacitor Networks, Arrays",
+      "id": 1141
+    },
+    {
+      "name": "Mica and PTFE Capacitors",
+      "id": 1145
+    }
+  ],
+  "capacitor||15nF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Film Capacitors",
+      "id": 1144
+    },
+    {
+      "name": "Silicon Capacitors",
+      "id": 1148
+    }
+  ],
+  "capacitor||22nF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Film Capacitors",
+      "id": 1144
+    },
+    {
+      "name": "Thin Film Capacitors",
+      "id": 1151
+    }
+  ],
+  "capacitor||22uF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Aluminum Electrolytic Capacitors",
+      "id": 1140
+    },
+    {
+      "name": "Aluminum - Polymer Capacitors",
+      "id": 1139
+    }
+  ],
+  "capacitor||4.7nF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Thin Film Capacitors",
+      "id": 1151
+    },
+    {
+      "name": "Silicon Capacitors",
+      "id": 1148
+    }
+  ],
+  "capacitor||4.7uF": [
+    {
+      "name": "Aluminum Electrolytic Capacitors",
+      "id": 1140
+    },
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Aluminum - Polymer Capacitors",
+      "id": 1139
+    }
+  ],
+  "capacitor||10uF": [
+    {
+      "name": "Aluminum Electrolytic Capacitors",
+      "id": 1140
+    },
+    {
+      "name": "Aluminum - Polymer Capacitors",
+      "id": 1139
+    },
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    }
+  ],
+  "resistor||2k2": [
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "resistor||1k": [
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "resistor||100R": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "resistor||4.7k": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "resistor||39k": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "resistor||22R": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Current Sense Resistors",
+      "id": 1336
+    }
+  ],
+  "resistor||56R": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Chassis Mount Resistors",
+      "id": 1198
+    }
+  ],
+  "resistor||20k": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "resistor||3.3k": [
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "resistor||220k": [
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "resistor||15k": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "resistor||18k": [
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    }
+  ],
+  "resistor||10R": [
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Current Sense Resistors",
+      "id": 1336
+    }
+  ],
+  "resistor||0.001R": [
+    {
+      "name": "Current Sense Resistors",
+      "id": 1336
+    },
+    {
+      "name": "Specialized Resistors",
+      "id": 1202
+    },
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    }
+  ],
+  "inductor||22uH": [
+    {
+      "name": "Fixed Inductors",
+      "id": 1181
+    },
+    {
+      "name": "Arrays, Signal Transformers",
+      "id": 1179
+    },
+    {
+      "name": "Adjustable Inductors",
+      "id": 1178
+    }
+  ],
+  "diode|SMA|100v, 1A": [
+    {
+      "name": "Rectifiers",
+      "id": 1427
+    },
+    {
+      "name": "Diodes",
+      "id": 185
+    },
+    {
+      "name": "Bridge Rectifiers",
+      "id": 856
+    }
+  ],
+  "led||RED": [
+    {
+      "name": "LED Indication - Discrete",
+      "id": 412
+    },
+    {
+      "name": "Circuit Board Indicators, Arrays, Light Bars, Bar Graphs",
+      "id": 386
+    },
+    {
+      "name": "LED Character and Numeric",
+      "id": 407
+    }
+  ],
+  "led||GRN": [
+    {
+      "name": "LED Indication - Discrete",
+      "id": 412
+    },
+    {
+      "name": "Circuit Board Indicators, Arrays, Light Bars, Bar Graphs",
+      "id": 386
+    },
+    {
+      "name": "LED Color Lighting",
+      "id": 409
+    }
+  ],
+  "led||BLU": [
+    {
+      "name": "LED Indication - Discrete",
+      "id": 412
+    },
+    {
+      "name": "LED Color Lighting",
+      "id": 409
+    },
+    {
+      "name": "Circuit Board Indicators, Arrays, Light Bars, Bar Graphs",
+      "id": 386
+    }
+  ],
+  "diode|SOT-363|SMF05CT1G": [
+    {
+      "name": "Diodes",
+      "id": 185
+    },
+    {
+      "name": "Zener",
+      "id": 1428
+    },
+    {
+      "name": "RF Diodes",
+      "id": 859
+    }
+  ],
+  "ic|LGA-14|BMI160": [
+    {
+      "name": "Sensor and Detector Interfaces",
+      "id": 957
+    },
+    {
+      "name": "Analog Front End (AFE)",
+      "id": 931
+    },
+    {
+      "name": "Data Acquisition",
+      "id": 257
+    }
+  ],
+  "ic|HTSSOP-56-1EP|DRV8301": [
+    {
+      "name": "Motor Drivers, Controllers",
+      "id": 1016
+    },
+    {
+      "name": "Gate Drivers",
+      "id": 1011
+    },
+    {
+      "name": "Full Half-Bridge (H Bridge) Drivers",
+      "id": 1010
+    }
+  ],
+  "ic|LQFP-64|STM32F405RGT6": [
+    {
+      "name": "Microcontrollers",
+      "id": 941
+    },
+    {
+      "name": "Application Specific Microcontrollers",
+      "id": 936
+    },
+    {
+      "name": "Embedded",
+      "id": 258
+    }
+  ],
+  "ic|SOIC-8|TP2604-SR": [
+    {
+      "name": "Specialized ICs",
+      "id": 264
+    },
+    {
+      "name": "Special Purpose Regulators",
+      "id": 1024
+    },
+    {
+      "name": "Power Management - Specialized",
+      "id": 1020
+    }
+  ],
+  "ic|SOIC-8|TJA1051T/3/1J": [
+    {
+      "name": "Drivers, Receivers, Transceivers",
+      "id": 951
+    },
+    {
+      "name": "Interface",
+      "id": 259
+    },
+    {
+      "name": "Sensor and Detector Interfaces",
+      "id": 957
+    }
+  ],
+  "ic|SOT-223-3|AMS1117-3.3": [
+    {
+      "name": "Voltage Regulators - Linear, Low Drop Out (LDO) Regulators",
+      "id": 1032
+    },
+    {
+      "name": "Power Management (PMIC)",
+      "id": 263
+    },
+    {
+      "name": "Linear",
+      "id": 260
+    }
+  ],
+  "crystal||8MHz": [
+    {
+      "name": "Crystals",
+      "id": 1155
+    },
+    {
+      "name": "Resonators",
+      "id": 1160
+    },
+    {
+      "name": "Oscillators",
+      "id": 1157
+    }
+  ],
+  "sensor||10k": [
+    {
+      "name": "NTC Thermistors",
+      "id": 1272
+    },
+    {
+      "name": "Temperature Sensors",
+      "id": 627
+    },
+    {
+      "name": "PTC Thermistors",
+      "id": 1273
+    }
+  ],
+  "connector||USB Micro  U-D-M5DD-W-1": [
+    {
+      "name": "USB, DVI, HDMI Connectors",
+      "id": 178
+    },
+    {
+      "name": "USB, DVI, HDMI Connector Assemblies",
+      "id": 842
+    },
+    {
+      "name": "USB, DVI, HDMI Connector Accessories",
+      "id": 840
+    }
+  ],
+  "connector||Conn_01x02": [
+    {
+      "name": "Headers, Male Pins",
+      "id": 792
+    },
+    {
+      "name": "Rectangular Connectors",
+      "id": 170
+    },
+    {
+      "name": "Board In, Direct Wire to Board",
+      "id": 789
+    }
+  ],
+  "connector||On/Off": [
+    {
+      "name": "AC Power Connectors",
+      "id": 150
+    },
+    {
+      "name": "Power Entry Modules (PEM)",
+      "id": 705
+    },
+    {
+      "name": "Plugs and Receptacles",
+      "id": 703
+    }
+  ],
+  "connector||Conn_01x03": [
+    {
+      "name": "Headers, Receptacles, Female Sockets",
+      "id": 793
+    },
+    {
+      "name": "Headers, Male Pins",
+      "id": 792
+    },
+    {
+      "name": "Rectangular Connectors",
+      "id": 170
+    }
+  ],
+  "connector||Conn_01x07": [
+    {
+      "name": "Headers, Receptacles, Female Sockets",
+      "id": 793
+    },
+    {
+      "name": "Rectangular Connectors",
+      "id": 170
+    },
+    {
+      "name": "Headers, Male Pins",
+      "id": 792
+    }
+  ],
+  "connector||UNUSED MCU PINS": [
+    {
+      "name": "Headers, Male Pins",
+      "id": 792
+    },
+    {
+      "name": "Headers, Receptacles, Female Sockets",
+      "id": 793
+    },
+    {
+      "name": "Shunts, Jumpers",
+      "id": 171
+    }
+  ],
+  "connector||HALL/Encoder": [
+    {
+      "name": "Circular Connectors",
+      "id": 157
+    },
+    {
+      "name": "Rectangular Connectors",
+      "id": 170
+    },
+    {
+      "name": "Specialized Connectors",
+      "id": 713
+    }
+  ],
+  "connector||SWD": [
+    {
+      "name": "Headers, Male Pins",
+      "id": 792
+    },
+    {
+      "name": "Rectangular Connectors",
+      "id": 170
+    }
+  ],
+  "mosfet|TO-220-3|NCEP60T20": [
+    {
+      "name": "FETs, MOSFETs",
+      "id": 1433
+    },
+    {
+      "name": "Transistors",
+      "id": 1420
+    },
+    {
+      "name": "Special Purpose Transistors",
+      "id": 193
+    }
+  ],
+  "capacitor|Elec_6.3x5.8|22uF": [
+    {
+      "name": "Aluminum Electrolytic Capacitors",
+      "id": 1140
+    },
+    {
+      "name": "Aluminum - Polymer Capacitors",
+      "id": 1139
+    },
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    }
+  ],
+  "capacitor|Elec_6.3x5.8|100uF": [
+    {
+      "name": "Aluminum Electrolytic Capacitors",
+      "id": 1140
+    },
+    {
+      "name": "Aluminum - Polymer Capacitors",
+      "id": 1139
+    },
+    {
+      "name": "Tantalum Capacitors",
+      "id": 1150
+    }
+  ],
+  "capacitor|0805|100nF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Capacitor Networks, Arrays",
+      "id": 1141
+    },
+    {
+      "name": "Film Capacitors",
+      "id": 1144
+    }
+  ],
+  "led|0603|LED": [
+    {
+      "name": "LED Indication - Discrete",
+      "id": 412
+    },
+    {
+      "name": "LED Color Lighting",
+      "id": 409
+    },
+    {
+      "name": "LED Emitters - Infrared, UV, Visible",
+      "id": 411
+    }
+  ],
+  "connector||Raspberry_Pi_2_3": [
+    {
+      "name": "Headers, Male Pins",
+      "id": 792
+    },
+    {
+      "name": "Pluggable Connectors",
+      "id": 169
+    }
+  ],
+  "connector||Conn_01x04": [
+    {
+      "name": "Headers, Receptacles, Female Sockets",
+      "id": 793
+    },
+    {
+      "name": "Rectangular Connectors",
+      "id": 170
+    },
+    {
+      "name": "Board In, Direct Wire to Board",
+      "id": 789
+    }
+  ],
+  "connector||Screw_Terminal_01x02": [
+    {
+      "name": "Terminal Blocks",
+      "id": 174
+    },
+    {
+      "name": "Screw Connectors",
+      "id": 830
+    },
+    {
+      "name": "Wire to Board",
+      "id": 819
+    }
+  ],
+  "resistor|0603|30k": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    }
+  ],
+  "resistor|0603|10k": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    }
+  ],
+  "resistor|0603|150k": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    }
+  ],
+  "resistor|0603|33k": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    }
+  ],
+  "resistor|0603|120": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Resistor Networks, Arrays",
+      "id": 1200
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    }
+  ],
+  "ic||ESP32-S3-DEVKITC-1 Mod": [
+    {
+      "name": "Microcontrollers, Microprocessor, FPGA Modules",
+      "id": 942
+    },
+    {
+      "name": "Modules",
+      "id": 956
+    },
+    {
+      "name": "Embedded",
+      "id": 258
+    }
+  ],
+  "ic|SO-8|VN5E160S-E": [
+    {
+      "name": "Power Distribution Switches, Load Drivers",
+      "id": 1019
+    },
+    {
+      "name": "Current Regulation/Management",
+      "id": 1006
+    },
+    {
+      "name": "Motor Drivers, Controllers",
+      "id": 1016
+    }
+  ],
+  "ic|HTSOP-8-1EP|DRV8871DDA": [
+    {
+      "name": "Motor Drivers, Controllers",
+      "id": 1016
+    },
+    {
+      "name": "Full Half-Bridge (H Bridge) Drivers",
+      "id": 1010
+    },
+    {
+      "name": "Power Management (PMIC)",
+      "id": 263
+    }
+  ],
+  "ic||ZXDN10": [
+    {
+      "name": "Specialized ICs",
+      "id": 264
+    },
+    {
+      "name": "Special Purpose Regulators",
+      "id": 1024
+    },
+    {
+      "name": "Power Management - Specialized",
+      "id": 1020
+    }
+  ]
+}

+ 101 - 0
bom_assistant/suppliers/lcsc/eval_ground_truth.py

@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""
+One-off: measure current LCSC category-resolver accuracy against
+ground_truth.ndjson (built by scout_ground_truth.py).
+
+For each ground-truth row, re-runs the normal ingestion pipeline on its
+source BOM file to get the same BomRow the app would produce, calls the
+real resolver, and compares its top-3 guess against the real LCSC category.
+
+Also flags cases where the real category isn't even in the candidate list
+`categories.get_subcategories_for()` offers for that row's ComponentCategory —
+those are candidate-list gaps, not resolver mistakes.
+
+Usage:
+    python -m bom_assistant.suppliers.lcsc.eval_ground_truth
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from bom_assistant.ingestion.column_mapper import map_columns
+from bom_assistant.ingestion.normalizer import normalize
+from bom_assistant.ingestion.parser import parse_bom
+from bom_assistant.session.models import BomRow
+from bom_assistant.suppliers.lcsc.categories import get_subcategories_for
+from bom_assistant.suppliers.lcsc.category_resolver import resolve
+
+_EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "examples" / "full"
+_GT_PATH = Path(__file__).parent / "ground_truth.ndjson"
+
+
+def _rows_for(source: str) -> list[BomRow]:
+    path = _EXAMPLES_DIR / source
+    raw = parse_bom(path.read_bytes(), path.name)
+    mapped = map_columns(raw)
+    return normalize(mapped)
+
+
+def main() -> None:
+    if not _GT_PATH.exists():
+        print(f"no ground truth found at {_GT_PATH} — run scout_ground_truth.py first")
+        return
+
+    gt_entries = [json.loads(line) for line in _GT_PATH.read_text(encoding="utf-8").splitlines() if line.strip()]
+
+    rows_by_source: dict[str, list[BomRow]] = {}
+    for entry in gt_entries:
+        src = entry["source"]
+        if src not in rows_by_source:
+            rows_by_source[src] = _rows_for(src)
+
+    top1 = top3 = candidate_gap = total = 0
+    mismatches: list[str] = []
+
+    for entry in gt_entries:
+        rows = rows_by_source[entry["source"]]
+        row = next((r for r in rows if entry["designator"] in r.designators), None)
+        if row is None:
+            print(f"warn: no matching row for designator {entry['designator']!r} in {entry['source']}")
+            continue
+
+        total += 1
+        real_id = entry["category_id"]
+        real_name = entry["category_name"]
+
+        candidates = get_subcategories_for(row.category)
+        candidate_ids = {c["id"] for c in candidates}
+        in_candidates = real_id in candidate_ids
+        if not in_candidates:
+            candidate_gap += 1
+
+        guesses = resolve(row)
+        guess_ids = [g.id for g in guesses]
+
+        hit1 = bool(guess_ids) and guess_ids[0] == real_id
+        hit3 = real_id in guess_ids
+        top1 += hit1
+        top3 += hit3
+
+        if not hit3:
+            guess_str = " | ".join(f"{g.name}({g.id})" for g in guesses) if guesses else "???"
+            gap_note = " [NOT IN CANDIDATE LIST]" if not in_candidates else ""
+            mismatches.append(
+                f"  {entry['designator']:6} [{row.category.value:10}] val={entry['value']!r:20} "
+                f"real={real_name}({real_id}){gap_note} guessed={guess_str}"
+            )
+
+    print(f"\n=== Eval results ({total} ground-truth rows) ===")
+    print(f"top-1 accuracy: {top1}/{total} ({top1 / total:.0%})" if total else "no rows")
+    print(f"top-3 accuracy: {top3}/{total} ({top3 / total:.0%})" if total else "")
+    print(f"real category missing from candidate list: {candidate_gap}/{total}")
+
+    if mismatches:
+        print(f"\n=== Mismatches ({len(mismatches)}) ===")
+        for m in mismatches:
+            print(m)
+
+
+if __name__ == "__main__":
+    main()

+ 60 - 0
bom_assistant/suppliers/lcsc/ground_truth.ndjson

@@ -0,0 +1,60 @@
+{"source": "bom (1).csv", "designator": "C38", "designators": "C38, C39", "value": "330u, 63V", "footprint": "CP_10x20mm_hor_with_model_brd_edge", "part": "C437643", "category_id": 1140, "category_name": "Aluminum Electrolytic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP ALUM 330uF ±20% 63V TH"}
+{"source": "bom (1).csv", "designator": "C5", "designators": "C5, C8, C19, C25, C26, C28, C30, C45, C46, C47, C48, C49, C53, C54, C55, C56", "value": "2.2u", "footprint": "C_0603_1608Metric", "part": "C23630", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 2.2uF 16V X5R 0603"}
+{"source": "bom (1).csv", "designator": "C16", "designators": "C16, C29, C33, C34, C44, C57, C58, C61, C62, C63, C65, C66, C67", "value": "1nF", "footprint": "C_0603_1608Metric", "part": "C1588", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 1nF 50V X7R 0603"}
+{"source": "bom (1).csv", "designator": "C1", "designators": "C1, C6, C7, C17, C32, C43, C52, C59, C60, C64", "value": "100n", "footprint": "C_0603_1608Metric", "part": "C14663", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 100nF 50V X7R 0603"}
+{"source": "bom (1).csv", "designator": "C2", "designators": "C2, C3, C4", "value": "0.01uF", "footprint": "C_0603_1608Metric", "part": "C57112", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 10nF 50V X7R 0603"}
+{"source": "bom (1).csv", "designator": "C18", "designators": "C18, C24, C27", "value": "220n", "footprint": "C_0603_1608Metric", "part": "C21120", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 220nF 25V X7R 0603"}
+{"source": "bom (1).csv", "designator": "C50", "designators": "C50, C51", "value": "15p", "footprint": "C_0603_1608Metric", "part": "C1644", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 15pF 50V C0G 0603"}
+{"source": "bom (1).csv", "designator": "C12", "designators": "C12", "value": "6.8n", "footprint": "C_0603_1608Metric", "part": "C1631", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 6.8nF 50V X7R 0603"}
+{"source": "bom (1).csv", "designator": "C13", "designators": "C13", "value": "150p", "footprint": "C_0603_1608Metric", "part": "C1594", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 150pF 50V X7R 0603"}
+{"source": "bom (1).csv", "designator": "C14", "designators": "C14", "value": "15nF", "footprint": "C_0603_1608Metric", "part": "C1596", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 15nF 50V X7R 0603"}
+{"source": "bom (1).csv", "designator": "C23", "designators": "C23", "value": "22n", "footprint": "C_0603_1608Metric", "part": "C21122", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 22nF 50V X7R 0603"}
+{"source": "bom (1).csv", "designator": "C9", "designators": "C9, C10, C11, C20, C21, C22", "value": "22uf", "footprint": "C_0805_2012Metric", "part": "C45783", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 22uF 25V X5R 0805"}
+{"source": "bom (1).csv", "designator": "C40", "designators": "C40, C41, C42", "value": "4.7nF, 100V", "footprint": "C_0805_2012Metric", "part": "C21217", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 4.7nF 100V X7R 0805"}
+{"source": "bom (1).csv", "designator": "C15", "designators": "C15, C31", "value": "4.7uF, 100V", "footprint": "C_1210_3225Metric", "part": "C385986", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 4.7uF 100V X7S 1210"}
+{"source": "bom (1).csv", "designator": "C35", "designators": "C35, C36, C37", "value": "10uF, 100V", "footprint": "C_2220_5650Metric_Pad1.97x5.40mm_HandSolder", "part": "C153035", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 10uF 100V X7R 2220"}
+{"source": "bom (1).csv", "designator": "R2", "designators": "R2, R12, R15, R16, R17, R25, R29, R32, R38", "value": "2k2", "footprint": "R_0603_1608Metric", "part": "C4190", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 2.2kΩ ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R24", "designators": "R24, R26, R28, R30, R66, R67, R69, R70", "value": "1k", "footprint": "R_0603_1608Metric", "part": "C21190", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 1kΩ ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R5", "designators": "R5, R6, R7, R8, R9, R10, R11", "value": "100R", "footprint": "R_0603_1608Metric", "part": "C22775", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 100Ω ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R13", "designators": "R13, R14, R22, R23, R34, R35", "value": "10k", "footprint": "R_0603_1608Metric", "part": "C25804", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 10kΩ ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R44", "designators": "R44, R45, R46, R50, R51, R52", "value": "4.7k", "footprint": "R_0603_1608Metric", "part": "C23162", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 4.7kΩ ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R1", "designators": "R1, R27, R31, R36", "value": "39k", "footprint": "R_0603_1608Metric", "part": "C23153", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 39kΩ ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R3", "designators": "R3, R4", "value": "22R", "footprint": "R_0603_1608Metric", "part": "C23345", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 22Ω ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R62", "designators": "R62, R63", "value": "56R", "footprint": "R_0603_1608Metric", "part": "C25196", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 56Ω ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R64", "designators": "R64, R65", "value": "20k", "footprint": "R_0603_1608Metric", "part": "C4184", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 20kΩ ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R18", "designators": "R18", "value": "3.3k", "footprint": "R_0603_1608Metric", "part": "C22978", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 3.3kΩ ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R19", "designators": "R19", "value": "220k", "footprint": "R_0603_1608Metric", "part": "C22961", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 220kΩ ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R20", "designators": "R20", "value": "15k", "footprint": "R_0603_1608Metric", "part": "C22809", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 15kΩ ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R21", "designators": "R21", "value": "18k", "footprint": "R_0603_1608Metric", "part": "C25810", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 18kΩ ±1% 100mW 0603"}
+{"source": "bom (1).csv", "designator": "R33", "designators": "R33, R37, R39, R41, R42, R43, R47, R48, R49, R53, R54, R55", "value": "10R", "footprint": "R_0805_2012Metric", "part": "C17415", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 10Ω ±1% 125mW 0805"}
+{"source": "bom (1).csv", "designator": "L1", "designators": "L1", "value": "22u", "footprint": "L_Taiyo-Yuden_NR-60xx_HandSoldering", "part": "C266430", "category_id": 1181, "category_name": "Fixed Inductors", "parent_category_name": "Inductors, Coils, Chokes", "product_name": "FIXED IND 22uH 1.45A 182mΩ SMD,6x6mm"}
+{"source": "bom (1).csv", "designator": "D3", "designators": "D3", "value": "RED", "footprint": "LED_0603_1608Metric", "part": "C2286", "category_id": 412, "category_name": "LED Indication - Discrete", "parent_category_name": "Optoelectronics", "product_name": "LED RED WATER CLEAR 0603 SMD"}
+{"source": "bom (1).csv", "designator": "D4", "designators": "D4", "value": "GRN", "footprint": "LED_0603_1608Metric", "part": "C72043", "category_id": 412, "category_name": "LED Indication - Discrete", "parent_category_name": "Optoelectronics", "product_name": "LED EMERALD GREEN WATER CLEAR 0603 SMD"}
+{"source": "bom (1).csv", "designator": "D5", "designators": "D5", "value": "BLU", "footprint": "LED_0603_1608Metric", "part": "C72041", "category_id": 412, "category_name": "LED Indication - Discrete", "parent_category_name": "Optoelectronics", "product_name": "LED BLUE WATER CLEAR 0603 SMD"}
+{"source": "bom (1).csv", "designator": "D1", "designators": "D1, D2", "value": " SMF05CT1G", "footprint": "SOT-363_SC-70-6", "part": "C15879", "category_id": 702, "category_name": "TVS Diodes", "parent_category_name": "Transistors/Thyristors", "product_name": "TVS DIODE 5VWM 12.5VC SC-88"}
+{"source": "bom (1).csv", "designator": "U6", "designators": "U6", "value": "BMI160", "footprint": "Bosch_LGA-14_3x2.5mm_P0.5mm", "part": "C94021", "category_id": 1249, "category_name": "Accelerometers", "parent_category_name": "Sensors", "product_name": "Small, low power inertial measurement unit"}
+{"source": "bom (1).csv", "designator": "U2", "designators": "U2", "value": "DRV8301", "footprint": "HTSSOP-56-1EP_6.1x14mm_P0.5mm_EP3.61x6.35mm", "part": "C98969", "category_id": 1016, "category_name": "Motor Drivers, Controllers", "parent_category_name": "Motor Driver ICs", "product_name": "Three-Phase Gate Driver With Dual Current Shunt Amplifiers and Buck Regulator"}
+{"source": "bom (1).csv", "designator": "U5", "designators": "U5", "value": "STM32F405RGT6", "footprint": "LQFP-64_10x10mm_P0.5mm", "part": "C15742", "category_id": 941, "category_name": "Microcontrollers", "parent_category_name": "Embedded Processors & Controllers", "product_name": "ARM Cortex-M4 32b MCU+FPU, 210DMIPS, up to 1MB Flash/192+4KB RAM, USB OTG HS/FS, Ethernet, 17 TIMs, 3 ADCs, 15 comm. interfaces & camera"}
+{"source": "bom (1).csv", "designator": "U3", "designators": "U3", "value": "TP2604-SR", "footprint": "SOIC-8_3.9x4.9mm_P1.27mm", "part": "C126446", "category_id": 1441, "category_name": "Instrumentation, Op Amps, Buffer Amps", "parent_category_name": "Amplifiers/Comparators", "product_name": "Dual/Quad, Ultra-low Distortion, 36V RRO Op-amps"}
+{"source": "bom (1).csv", "designator": "U4", "designators": "U4", "value": "TJA1051T/3/1J", "footprint": "SOIC-8_3.9x4.9mm_P1.27mm", "part": "C38695", "category_id": 951, "category_name": "Drivers, Receivers, Transceivers", "parent_category_name": "Interface", "product_name": "High-speed CAN transceiver"}
+{"source": "bom (1).csv", "designator": "U1", "designators": "U1", "value": "AMS1117-3.3", "footprint": "SOT-223-3_TabPin2", "part": "C6186", "category_id": 1032, "category_name": "Voltage Regulators - Linear, Low Drop Out (LDO) Regulators", "parent_category_name": "Power Management (PMIC)", "product_name": "1A Low Dropout (LDO) Regulator"}
+{"source": "bom (1).csv", "designator": "X1", "designators": "X1", "value": "8MHz 10ppm (X50328MSB2GI)", "footprint": "Crystal_SMD_5032-2Pin_5.0x3.2mm", "part": "C115962", "category_id": 1155, "category_name": "Crystals", "parent_category_name": "Crystals, Oscillators, Resonators", "product_name": "CRYSTAL 8MHz 20pF SMD"}
+{"source": "bom (1).csv", "designator": "NTC1", "designators": "NTC1", "value": "NTC 10k", "footprint": "R_0603_1608Metric", "part": "C76589", "category_id": 1272, "category_name": "NTC Thermistors", "parent_category_name": "Sensors", "product_name": "THERMISTOR NTC 10kΩ 3380K 0603"}
+{"source": "bom (1).csv", "designator": "USB1", "designators": "USB1", "value": "USB Micro  U-D-M5DD-W-1", "footprint": "USB_Micro-B_Wuerth_614105150721_Vertical", "part": "C145795", "category_id": 842, "category_name": "USB, DVI, HDMI Connector Assemblies", "parent_category_name": "Connectors", "product_name": "CONN RCPT USB 2.0 Micro-B 5POS TH"}
+{"source": "bom (1).csv", "designator": "J1", "designators": "J1", "value": "Conn_01x02", "footprint": "JST_PH_B2B-PH-K_1x02_P2.00mm_Vertical", "part": "C131337", "category_id": 792, "category_name": "Headers, Male Pins", "parent_category_name": "Connectors", "product_name": "CONN HEADER TH 2POS 2mm"}
+{"source": "bom (1).csv", "designator": "J4", "designators": "J4", "value": "On/Off", "footprint": "JST_PH_B2B-PH-K_1x02_P2.00mm_Vertical", "part": "C131337", "category_id": 792, "category_name": "Headers, Male Pins", "parent_category_name": "Connectors", "product_name": "CONN HEADER TH 2POS 2mm"}
+{"source": "bom (1).csv", "designator": "J3", "designators": "J3", "value": "Conn_01x03", "footprint": "JST_PH_B3B-PH-K_1x03_P2.00mm_Vertical", "part": "C131339", "category_id": 792, "category_name": "Headers, Male Pins", "parent_category_name": "Connectors", "product_name": "CONN HEADER TH 3POS 2mm"}
+{"source": "bom (1).csv", "designator": "J2", "designators": "J2", "value": "Conn_01x07", "footprint": "JST_PH_B7B-PH-K_1x07_P2.00mm_Vertical", "part": "C157981", "category_id": 792, "category_name": "Headers, Male Pins", "parent_category_name": "Connectors", "product_name": "CONN HEADER TH 7POS 2mm"}
+{"source": "bom (1).csv", "designator": "J5", "designators": "J5", "value": "UNUSED MCU PINS", "footprint": "PinHeader_2x05_P2.00mm_Vertical", "part": "C225290", "category_id": 792, "category_name": "Headers, Male Pins", "parent_category_name": "Connectors", "product_name": "CONN HEADER 10POS 2mm DUAL ROW TH"}
+{"source": "bom (1).csv", "designator": "P4", "designators": "P4", "value": "HALL/Encoder", "footprint": "PH-6AK", "part": "C146086", "category_id": 792, "category_name": "Headers, Male Pins", "parent_category_name": "Connectors", "product_name": "CONN HEADER TH 6POS 2mm"}
+{"source": "bom (1).csv", "designator": "P5", "designators": "P5", "value": "SWD", "footprint": "PinHeader_1x05_P2.54mm_Vertical", "part": "C225480", "category_id": 792, "category_name": "Headers, Male Pins", "parent_category_name": "Connectors", "product_name": "CONN HEADER 5POS 2.54mm SINGLE ROW TH"}
+{"source": "bom (1).csv", "designator": "Q1", "designators": "Q1, Q2, Q3, Q4, Q5, Q6", "value": "NCEP60T20", "footprint": "TO-220-3_Horizontal_TabUp_no_hole", "part": "C284868", "category_id": 1436, "category_name": "Single FETs, MOSFETs", "parent_category_name": "Transistors/Thyristors", "product_name": "MOSFET N-CH 60V 200A TO-220-3L"}
+{"source": "BOM_imx415_DigitalFPV (1).xlsx", "designator": "C598", "designators": "C598,C599,C601,C602,C623,C624,C625,C626,C627,C628,C642", "value": "100nF", "footprint": "c0402-cap-1", "part": "C1525", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 100nF 16V X7R 0402"}
+{"source": "BOM_imx415_DigitalFPV (1).xlsx", "designator": "C603", "designators": "C603,C604,C605,C606,C607", "value": "1uF", "footprint": "c0402-cap-1", "part": "C52923", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 1uF 25V X5R 0402"}
+{"source": "BOM_imx415_DigitalFPV (1).xlsx", "designator": "C608", "designators": "C608,C610,C612,C613", "value": "4.7uF", "footprint": "c0402-cap-1", "part": "C23733", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 4.7uF 10V X5R 0402"}
+{"source": "BOM_imx415_DigitalFPV (1).xlsx", "designator": "C609", "designators": "C609,C635", "value": "10nF", "footprint": "c0402-cap-1", "part": "C15195", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 10nF 50V X7R 0402"}
+{"source": "BOM_imx415_DigitalFPV (1).xlsx", "designator": "C639", "designators": "C639,C640,C641", "value": "47uF", "footprint": "C0603", "part": "C140782", "category_id": 1142, "category_name": "Ceramic Capacitors", "parent_category_name": "Capacitors", "product_name": "CAP CER 47uF 6.3V X5R 0603"}
+{"source": "BOM_imx415_DigitalFPV (1).xlsx", "designator": "R1", "designators": "R1", "value": "0Ω", "footprint": "R0402", "part": "C17168", "category_id": 1199, "category_name": "Chip Resistor - Surface Mount", "parent_category_name": "Resistors", "product_name": "RES 0Ω ±1% 62.5mW 0402"}
+{"source": "BOM_imx415_DigitalFPV (1).xlsx", "designator": "U3", "designators": "U3", "value": "SGM2033-1.8XUDH4G/TR", "footprint": "UTDFN-4AL_L1.0-W1.0-P0.65-BL", "part": "C5152909", "category_id": 1032, "category_name": "Voltage Regulators - Linear, Low Drop Out (LDO) Regulators", "parent_category_name": "Power Management (PMIC)", "product_name": null}
+{"source": "BOM_imx415_DigitalFPV (1).xlsx", "designator": "U72", "designators": "U72", "value": "SGM2036-2.9YUDH4G/TR", "footprint": "UTDFN-4_L1.0-W1.0-P0.63-BL-EP", "part": "C700397", "category_id": 1032, "category_name": "Voltage Regulators - Linear, Low Drop Out (LDO) Regulators", "parent_category_name": "Power Management (PMIC)", "product_name": "300mA, Low Power, Low Dropout, RF Linear Regulators"}
+{"source": "BOM_imx415_DigitalFPV (1).xlsx", "designator": "U76", "designators": "U76", "value": "SGM2039A-1.1XXEV8G/TR", "footprint": "XDFN-8_L1.6-W1.2-P0.40-BL-EP", "part": "C5152968", "category_id": 1032, "category_name": "Voltage Regulators - Linear, Low Drop Out (LDO) Regulators", "parent_category_name": "Power Management (PMIC)", "product_name": null}

+ 100 - 0
bom_assistant/suppliers/lcsc/product_lookup.py

@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""
+Ground-truth helper: fetch a real LCSC product's actual category by part number.
+
+Scrapes https://www.lcsc.com/product-detail/{part}.html, which embeds a plain
+JSON blob in <script id="__NEXT_DATA__">. The relevant fields live at
+props.pageProps.webData: `catalogId`/`catalogName` (leaf category — matches
+the `id`/`name` used in categories.json) and `parentCatalogId`/`parentCatalogName`.
+
+Usage (library):
+    from bom_assistant.suppliers.lcsc.product_lookup import lookup_product
+    info = lookup_product("C437643")
+    # ProductInfo(part="C437643", category_id=1140, category_name="Aluminum Electrolytic Capacitors",
+    #             parent_category_name="Capacitors", product_name="CAP ALUM 330uF ±20% 63V TH")
+"""
+from __future__ import annotations
+
+import json
+import random
+import re
+import time
+import urllib.request
+from dataclasses import dataclass
+
+_DETAIL_URL = "https://www.lcsc.com/product-detail/{part}.html"
+_UA = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
+_GRACE = 1.5
+_JITTER = 0.5
+_RETRY_WAIT = 5.0
+
+_NEXT_DATA_RE = re.compile(
+    r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.DOTALL
+)
+
+
+@dataclass
+class ProductInfo:
+    part: str
+    category_id: int
+    category_name: str
+    parent_category_name: str
+    product_name: str
+
+
+def _fetch(url: str) -> str | None:
+    req = urllib.request.Request(
+        url, headers={"User-Agent": _UA, "Accept-Language": "en-US,en;q=0.9"}
+    )
+    for attempt in range(2):
+        try:
+            with urllib.request.urlopen(req, timeout=15) as resp:
+                return resp.read().decode("utf-8", errors="replace")
+        except Exception as exc:
+            if attempt == 0:
+                print(f"  warn: {exc} — retrying in {_RETRY_WAIT}s")
+                time.sleep(_RETRY_WAIT)
+            else:
+                return None
+    return None
+
+
+def sleep_between_requests() -> None:
+    time.sleep(max(0.5, _GRACE + random.uniform(-_JITTER, _JITTER)))
+
+
+def lookup_product(part: str) -> ProductInfo | None:
+    """Fetch the real LCSC category for a part number. Returns None on 404/parse failure."""
+    html = _fetch(_DETAIL_URL.format(part=part))
+    if html is None:
+        return None
+
+    m = _NEXT_DATA_RE.search(html)
+    if not m:
+        return None
+
+    try:
+        data = json.loads(m.group(1))
+        web_data = data["props"]["pageProps"].get("webData")
+    except (json.JSONDecodeError, KeyError):
+        return None
+
+    if not web_data or not web_data.get("catalogId"):
+        return None
+
+    return ProductInfo(
+        part=part,
+        category_id=web_data["catalogId"],
+        category_name=web_data.get("catalogName", ""),
+        parent_category_name=web_data.get("parentCatalogName", ""),
+        product_name=web_data.get("productNameEn", ""),
+    )
+
+
+if __name__ == "__main__":
+    import sys
+
+    for p in sys.argv[1:]:
+        info = lookup_product(p)
+        print(f"{p}: {info}")
+        sleep_between_requests()

+ 123 - 0
bom_assistant/suppliers/lcsc/scout_ground_truth.py

@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+"""
+One-off: build a ground-truth dataset of (component -> real LCSC category)
+from the two examples/full BOMs that happen to carry real LCSC part numbers.
+
+Reads the raw BOM columns directly (not through the normal ingestion pipeline —
+production BOMs won't have LCSC part numbers, so there's nothing to wire up
+there). For each row, looks up the part's real category via product_lookup,
+and writes one JSON line per row to ground_truth.ndjson:
+
+    {"designator": "C38", "value": "330u, 63V", "footprint": "...",
+     "part": "C437643", "category_id": 1140, "category_name": "Aluminum Electrolytic Capacitors",
+     "parent_category_name": "Capacitors"}
+
+Usage:
+    python -m bom_assistant.suppliers.lcsc.scout_ground_truth
+"""
+from __future__ import annotations
+
+import csv
+import json
+import re
+from pathlib import Path
+
+import openpyxl
+
+from bom_assistant.suppliers.lcsc.product_lookup import lookup_product, sleep_between_requests
+
+_EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "examples" / "full"
+_OUT_PATH = Path(__file__).parent / "ground_truth.ndjson"
+
+
+def _rows_from_csv(path: Path) -> list[dict]:
+    with path.open(encoding="utf-8-sig", newline="") as f:
+        reader = csv.DictReader(f)
+        return [
+            {
+                "source": path.name,
+                "designators": row.get("Designator", ""),
+                "value": row.get("Comment", ""),
+                "footprint": row.get("Footprint", ""),
+                "part": (row.get("LCSC Part #") or "").strip(),
+            }
+            for row in reader
+            if (row.get("LCSC Part #") or "").strip()
+        ]
+
+
+def _rows_from_xlsx(path: Path) -> list[dict]:
+    wb = openpyxl.load_workbook(path, data_only=True)
+    ws = wb.active
+    header = [str(c.value).strip() if c.value is not None else "" for c in next(ws.iter_rows(min_row=1, max_row=1))]
+    idx = {name: i for i, name in enumerate(header)}
+
+    out: list[dict] = []
+    for row in ws.iter_rows(min_row=2, values_only=True):
+        supplier = str(row[idx["Supplier"]] or "").strip() if "Supplier" in idx else ""
+        part = str(row[idx["Supplier Part"]] or "").strip() if "Supplier Part" in idx else ""
+        if supplier.upper() != "LCSC" or not part:
+            continue
+        out.append({
+            "source": path.name,
+            "designators": str(row[idx["Designator"]] or "") if "Designator" in idx else "",
+            "value": str(row[idx["Value"]] or row[idx["Comment"]] or "") if "Value" in idx or "Comment" in idx else "",
+            "footprint": str(row[idx["Footprint"]] or "") if "Footprint" in idx else "",
+            "part": part,
+        })
+    return out
+
+
+def _first_designator(designators: str) -> str:
+    parts = re.split(r"[,;\s]+", designators.strip())
+    return parts[0] if parts else ""
+
+
+def main() -> None:
+    sources = [
+        (_EXAMPLES_DIR / "bom (1).csv", _rows_from_csv),
+        (_EXAMPLES_DIR / "BOM_imx415_DigitalFPV (1).xlsx", _rows_from_xlsx),
+    ]
+
+    rows: list[dict] = []
+    for path, reader in sources:
+        if not path.exists():
+            print(f"warn: missing {path}, skipping")
+            continue
+        found = reader(path)
+        print(f"{path.name}: {len(found)} rows with an LCSC part #")
+        rows.extend(found)
+
+    print(f"\nLooking up {len(rows)} parts on lcsc.com …")
+    written = 0
+    with _OUT_PATH.open("w", encoding="utf-8") as out:
+        for i, row in enumerate(rows, 1):
+            part = row["part"]
+            info = lookup_product(part)
+            if info is None:
+                print(f"  [{i}/{len(rows)}] {part}: lookup failed — skipping")
+                sleep_between_requests()
+                continue
+
+            entry = {
+                "source": row["source"],
+                "designator": _first_designator(row["designators"]),
+                "designators": row["designators"],
+                "value": row["value"],
+                "footprint": row["footprint"],
+                "part": part,
+                "category_id": info.category_id,
+                "category_name": info.category_name,
+                "parent_category_name": info.parent_category_name,
+                "product_name": info.product_name,
+            }
+            out.write(json.dumps(entry, ensure_ascii=False) + "\n")
+            written += 1
+            print(f"  [{i}/{len(rows)}] {part} -> {info.category_name}")
+            sleep_between_requests()
+
+    print(f"\nWrote {written}/{len(rows)} ground-truth entries to {_OUT_PATH}")
+
+
+if __name__ == "__main__":
+    main()