# Architecture ## Modules ### 1. Session Shared data schemas used across all other modules. - `ComponentCategory` enum: capacitor, resistor, inductor, diode, led, ic, connector, crystal, mosfet, sensor, other - `RowState` enum: pending → scouting → filtering → confirmed | flagged - `NormalizedParams`: package, value (SI float), value_str, unit, voltage_rating, current_rating, power_rating, tolerance, part_number, manufacturer - `BomRow`: row_id, designators, quantity, raw_value, footprint, category, normalized_params, state, applied_filters, facet_groups, confirmed_pick - `BomSession`: session_id, created_at, filename, preferred_suppliers, rows ### 2. Ingestion Converts raw file bytes into a list of `BomRow` objects. **Parser** — detects format by extension, handles: - CSV: utf-8-sig decode, delimiter detection (`,` vs `;`), footer stripping, blank row skipping - XLSX: preamble skip via header-row detection (scans rows 0–14 for synonym matches), column-offset detection, phantom column stripping, footer/totals detection - XLS: same as XLSX via xlrd **Column Mapper** — maps raw column names to canonical fields: - Heuristic lookup against `column_synonyms.json` (exact normalized match) - Comment disambiguation: if BOM has both `Comment` and a richer value column, `Comment` → `part_number` (unless values look like passive component values) - AI fallback (Claude Haiku) when required `value` field is still unmapped - New AI-discovered synonyms persisted to `column_synonyms.json` to avoid future API calls - Unknown columns passed through with `_raw_` prefix **Normalizer** — per-row processing: - Skip empty / KiCad `~` rows - Parse and expand designators (`C1-5` → `[C1, C2, C3, C4, C5]`) - Parse quantity; qty=0 → flagged - DNP detection (value string, boolean columns, Mounting column) - Non-electronic detection (PCB, screw, nut, heatsink, etc.) → flagged - Calls classifier + param_extractor → emits `BomRow` ### 3. Classification Determines component category and extracts normalized parameters. **Classifier** — `(designators, raw_value)` → `ComponentCategory`: - Designator prefix map: `C`→capacitor, `R/RV`→resistor, `L`→inductor, `D`→diode, `U/IC`→ic, `J/P/CON/USB`→connector, `Q/T`→mosfet, `X/Y`→crystal, `NTC/RT`→sensor - Special: D + LED keyword in value → led - Fallback (no designators): capacitance/resistance regex, alphanumeric part-number pattern → ic, else → other **Param Extractor** — `(raw_value, footprint, category)` → `NormalizedParams`: - Value parsing: capacitance (bare prefix, European notation), resistance (European 2k2/4k7, standard), inductance, frequency, IC/MOSFET (entire string → part_number) - Description fallback: scans full string for embedded values (`"CAP CER 0.1UF 100V X7R 0603"`) - Cross-category extractions: voltage_rating, current_rating, power_rating, tolerance - Package extraction cascade: 1. Exact match in `known_footprints` dict 2. Regex cascade from `package_patterns.json` 3. AI fallback (Claude Haiku) → result persisted to `known_footprints` ### 4. Suppliers Adapter layer for electronic component marketplaces. All adapters implement the abstract `SupplierAdapter` base class, which supports both scraper mode (default, no credentials) and token mode (official API). ```python class SupplierAdapter(ABC): def __init__(self, api_token: str | None = None): ... def search(self, category_id: int, params: NormalizedParams, filters: dict[str, Any] | None = None, page: int = 1) -> SearchResult: ... ``` #### 4a. LCSC Category Resolution Before searching, each `BomRow` must be mapped to a specific LCSC subcategory (name + integer ID). Generic keyword search is unreliable — LCSC expects category-scoped queries. **One-time setup** (`lcsc/fetch_categories.py`): - Scrapes ~10 LCSC category pages (Capacitors, Resistors, ICs, etc.) with 1.5s grace period between requests - Extracts subcategory name + ID from each page - Writes `lcsc/categories.json` — committed to repo, refreshed quarterly **Category JSON format:** ```json [ { "group": "Passives", "category": "Capacitors", "category_id": 11, "subcategories": [ {"name": "Aluminum Electrolytic Capacitors", "id": 1140}, {"name": "Multilayer Ceramic Capacitors (MLCC)", "id": 1142}, {"name": "Film Capacitors", "id": 1144}, {"name": "Tantalum Capacitors", "id": 1150} ] } ] ``` **LLM Category Resolver** (`lcsc/category_resolver.py`): - Cache key: `f"{internal_category}|{package}|{value_str}"` → check `lcsc/category_cache.json` - On miss: pre-filter category list to relevant subtree → build Claude Haiku prompt → parse JSON response - Prompt passes component info + subcategory name list; LLM returns one exact name - Persist result to `category_cache.json` (same file-backed learning pattern as `package_patterns.json`) Example: `category=capacitor, package=6.3x5.8, voltage=25V` → LLM picks `"Aluminum Electrolytic Capacitors"` → looked up → id `1140` **LCSC Search** (`lcsc/lcsc.py`): - Live API mode (default, no credentials needed): LCSC's real internal JSON API, reverse-engineered from live browser traffic (the site is a Nuxt SPA — the previously assumed `lcsc.com/api/products/search` scraper endpoint does not exist and was never actually verified working). No CSRF/session dance required, plain POST with `User-Agent`/`Origin`/`Referer` is sufficient: - `POST https://wmsc.lcsc.com/ftps/wm/product/query/list` — paginated product search. Request: `{catalogIdList:[id], brandIdList, encapValueList, paramNameValueMap, isStock, ..., currentPage, pageSize}`. Response items carry LCSC part number (`productCode`), manufacturer (`brandNameEn`), package (`encapStandard`), tiered pricing (`productPriceList`), stock, per-part spec list (`paramVOList`), datasheet URL. - `POST https://wmsc.lcsc.com/ftps/wm/product/query/param/group` — facet/filter query, same request shape (minus pagination). Response: `{Package: [...], Manufacturer: [...], Packaging: [...], paramNameValueMap: {: {paramDetailList, unitList, unitConversionMap}}}`, scoped to the category and any filters already applied — this is the source for building filter chips each scouting round, no separate scraping or client-side aggregation needed. - Verified live: `categories.json`/`category_resolver.py`'s subcategory ids (e.g. `1142` = "Ceramic Capacitors") are directly usable as `catalogIdList` values against both endpoints — no id remapping layer required. - Token mode: if `api_token` provided, use official LCSC API (`https://ips.lcsc.com/rest/wmsc2agent/`) with HMAC-signed requests — not yet implemented (`NotImplementedError`). ### 5. Scouting Loop Drives each `BomRow` from `pending` to `confirmed` through an iterative user-guided process (`bom_assistant/scouting/orchestrator.py`). ``` pending → scouting (LLM infers LCSC subcategory, user confirms or picks different) — built → filtering (facet query + search within confirmed subcategory, user narrows/browses) — built (Package + Manufacturer filters only) → confirmed (user picks a specific part) — built → flagged (DNP / non-electronic / user skips) — built (ingestion-time only) ``` Not yet built: per-component-param filters (Capacitance/Tolerance/Voltage Rating/... — `paramNameValueMap` request shape unverified live), pagination past page 1, removing/toggling off an applied filter, and the retry path (user rejects all results → back to scouting with modified parameters). ### 6. API Layer FastAPI HTTP interface (`bom_assistant/api/routes.py`). - `POST /upload` — accept BOM file, run ingestion, return `BomSession` (built) - `GET /session/{id}/row/{row_id}/scout` — trigger LLM category inference, sets `row.scout_candidates` (built) - `POST /session/{id}/row/{row_id}/confirm-category` — user accepts/overrides category → state: filtering, populates facets + initial search results (built) - `POST /session/{id}/row/{row_id}/apply-filter` — apply a Package/Manufacturer facet value, re-run facets + search (built) - `POST /session/{id}/row/{row_id}/confirm` — lock part pick, advance state to confirmed (built) - `GET /session/{id}` — current session state (not yet built) - `GET /session/{id}/export` — collate confirmed picks → CSV/XLSX download (not yet built) ### 7. Frontend *(not yet built)* React SPA. - Upload page → triggers `POST /upload` - Row-by-row scouting view: category confirmation dropdown + results grid + confirm/reject buttons - Progress bar across all rows - Export button --- ## User Flow ``` User uploads BOM file └─ Ingestion pipeline runs ├─ Parser reads file → raw rows ├─ Column Mapper normalizes column names └─ Normalizer classifies + extracts params → BomSession For each pending BomRow: └─ Scout ├─ LLM infers LCSC subcategory from component info │ (cache hit → instant; miss → Claude Haiku call → cached) ├─ UI shows inferred subcategory + dropdown of all alternatives ├─ User confirms or picks a different subcategory ├─ Search within confirmed subcategory + param filters ├─ User browses results, adjusts filters if needed └─ User confirms pick ──→ row state: confirmed └─ rejects ──→ retry with different subcategory/filters All rows confirmed └─ Export → order-ready BOM (CSV/XLSX) ``` --- ## System Flow ``` POST /upload parse_bom(bytes, filename) → map_columns(raw_rows) → normalize(mapped_rows) → BomSession stored in memory GET /session/{id}/row/{row_id}/scout lcsc_category_resolver.resolve(row) ← cache hit or Haiku call → [LcscCategory("Alum. Electrolytic...", 1140), ...] (best-first, up to 3) row.scout_candidates = [{name, id}, ...] row.state → scouting POST /session/{id}/row/{row_id}/confirm-category {category_id: 1140, category_name: "..."} row.resolved_category_id = 1140 row.resolved_category_name = "..." row.applied_filters = {} ← reset; kept strictly as an LCSC filter payload fragment row.state → filtering _refresh_filtering(row): row.facet_groups = lcsc.query_facets(1140, row.applied_filters) result = lcsc.search(1140, row.normalized_params, row.applied_filters, page=1) row.search_results = result.items row.search_meta = {count, page, page_size, error} POST /session/{id}/row/{row_id}/apply-filter {group: "Package"|"Manufacturer", value: ...} row.applied_filters[].append(value) _refresh_filtering(row) ← same as above, narrows facets + results POST /session/{id}/row/{row_id}/confirm {product_code: "C602037"} row.confirmed_pick = row.state → confirmed GET /session/{id}/export [confirmed rows] → assemble BOM → return file ``` --- ## Physical File Map ``` instant-bom/ ├── bom_assistant/ │ ├── session/ │ │ └── models.py — Pydantic schemas │ │ │ ├── ingestion/ │ │ ├── parser.py — raw bytes → list[dict[str, str]] │ │ ├── column_mapper.py — raw keys → canonical keys │ │ ├── column_synonyms.json — file-backed synonym table (auto-updated by AI) │ │ └── normalizer.py — mapped rows → list[BomRow] │ │ │ ├── classification/ │ │ ├── classifier.py — designator prefix + value heuristics → ComponentCategory │ │ ├── param_extractor.py — value string + footprint → NormalizedParams │ │ └── package_patterns.json — file-backed regex + known_footprints (auto-updated by AI) │ │ │ ├── suppliers/ │ │ ├── base.py — abstract SupplierAdapter │ │ └── lcsc/ │ │ ├── fetch_categories.py — one-time CLI: scrape LCSC → categories.json │ │ ├── categories.json — committed static file (refresh quarterly) │ │ ├── categories.py — load + filter category tree │ │ ├── category_resolver.py — LLM category inference + file-backed cache │ │ ├── category_cache.json — auto-updated by resolver │ │ └── lcsc.py — search adapter (live wmsc.lcsc.com API + token mode stub) │ │ │ ├── scouting/ │ │ ├── ingest.py — bytes → BomSession (parser → column_mapper → normalizer) │ │ ├── orchestrator.py — scout_row, confirm_category, apply_filter, confirm_pick │ │ └── errors.py — SessionNotFoundError, RowNotFoundError, ScoutingValidationError │ │ │ └── api/ │ ├── main.py — FastAPI app + exception handlers │ ├── routes.py — HTTP routes (HTMX-rendered partials) │ ├── store.py — in-memory SessionStore │ └── templates/ — Jinja2 partials for the HTMX test page │ ├── frontend/ (not yet built — React SPA; HTMX test page above covers interim needs) │ └── src/ │ ├── examples/ │ ├── full/ — real BOM test files (CSV, XLSX, XLS) │ └── reduced/ — same files, trimmed │ └── requirements.txt ```