First-time setup: fastapi/uvicorn/starlette are listed in requirements.txt but are not installed by default in a fresh .venv — run pip install -r requirements.txt before running the API.
ComponentCategory.other maps to an empty subcategory list in suppliers/lcsc/categories.py:89 (_SUBCATEGORY_NAMES[other] = []) — rows classified as other can never resolve to an LCSC subcategory. Needs a taxonomy decision (which LCSC categories represent the catch-all), not a code fix.
suppliers/lcsc/lcsc.py's original scraper (lcsc.com/api/products/search via CSRF session) was previously marked Done but was never actually verified live — it returns the Nuxt SSR shell, not JSON. Replaced with LCSC's real internal API (wmsc.lcsc.com/ftps/wm/product/query/*), found via live browser traffic capture and verified end-to-end.
suppliers/lcsc/enrich_categories.py + make_enrich_input.py / apply_enrich_output.py — scrape example products + LLM one-line descriptions per subcategory, applied to all 600/600
suppliers/lcsc/product_lookup.py, scout_ground_truth.py, eval_ground_truth.py — ground-truth harness (60 rows from real LCSC part numbers) + accuracy eval
Current accuracy: top-1 68% (41/60), top-3 88% (53/60)
Verified live: categories.json subcategory ids are directly usable as catalogIdList values against LCSC's real search/facet API — no remapping layer needed
API + Frontend (backbone)
api/main.py — FastAPI app entry point + exception handlers (404 for not-found, 400 for ScoutingValidationError)
POST /upload — ingest file, return BomSession (HTMX-rendered row table)
GET /session/{id}/row/{row_id}/scout — LLM category inference, HTMX-rendered candidate list (now with confirm buttons)
POST /session/{id}/row/{row_id}/confirm-category — locks resolved_category_id/resolved_category_name, resets applied_filters, state → filtering, eagerly populates facet_groups + search_results via live LCSC calls; pre-seeds encapValueList from normalized_params.package when it exactly matches a live facet Package name (no match → left unfiltered, never guessed)
POST /session/{id}/row/{row_id}/apply-filter — applies a Package/Manufacturer facet value to applied_filters, re-runs facets + search — verified live: narrows search_results items to the selected package/manufacturer
POST /session/{id}/row/{row_id}/confirm — locks confirmed_pick from search_results by productCode, state → confirmed
HTMX-based test page (api/templates/, server-rendered by FastAPI, no separate frontend process) — exercises the full pending → scouting → filtering → confirmed loop for Package/Manufacturer filters
LCSC Search Adapter
suppliers/lcsc/lcsc.py — search() against LCSC's real internal API (wmsc.lcsc.com/ftps/wm/product/query/list), reverse-engineered from live browser traffic; previous lcsc.com/api/products/search scraper endpoint was stale/never worked (returned the Nuxt SSR shell, not JSON) — no CSRF/session dance needed for the real endpoint
suppliers/lcsc/lcsc.py — query_facets() against wmsc.lcsc.com/ftps/wm/product/query/param/group: returns Package/Manufacturer/Packaging + per-param value lists, scoped to category + already-applied filters — this is the source for building filter chips each scouting round, no scraping or manual aggregation needed
Wired live end-to-end via the API: applied_filters is kept strictly as the raw LCSC filter payload fragment (encapValueList/brandIdList/...) — never polluted with app-internal bookkeeping — so it round-trips straight into query_facets()/search() with no translation layer
HTSOP overcapture fix — suffix pattern (?:-[A-Z0-9]+)* stops at _digit
Todo
Ingestion
Strip Elec_ prefix from package — store "6.3x5.8" not "Elec_6.3x5.8" (change capture group in package_patterns.json)
Persian-header BOM support — AI column mapping for non-Latin headers (currently returns 0 rows)
LCSC Search Adapter
suppliers/lcsc/lcsc.py — official API mode (_search_official) still a stub, raises NotImplementedError; needs HMAC signing + LCSC API key
Scouting Loop
Per-component param filters (Capacitance, Tolerance, Voltage Rating, ...) — facet response (paramNameValueMap) describes available values, but the exact request shape LCSC expects to select one was never captured live; wiring it blind risks a silently-broken filter. Needs another live traffic capture.
Packaging facet group (bulk/reel/tape) — same problem as param filters: query_facets() returns it, but no request field for selecting a value was ever captured (_FILTER_DEFAULTS in lcsc.py has no packaging-type key). User decision (2026-07-27): skip rather than guess a field name; revisit after a live traffic capture.
Pagination past page 1 of search results
Remove/toggle off an already-applied filter (currently additive-only within a session)
Retry / re-scout flow when user rejects results or picks a different category
API
GET /session/{id} — return session state
GET /session/{id}/export — collate confirmed picks → downloadable BOM
Future: Other Suppliers
DigiKey adapter
Mouser adapter
Nexar adapter
Frontend
React scaffold + build setup — deferred until the scouting loop is validated further; HTMX test page (see Done) covers interim needs and now exercises the full pending → confirmed loop, unstyled