Browse Source

feat backend + simple page for category inference

n2749 1 week ago
parent
commit
cce0aeb21e

+ 0 - 0
bom_assistant/api/__init__.py


+ 21 - 0
bom_assistant/api/main.py

@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from fastapi import FastAPI
+from fastapi.responses import JSONResponse
+from starlette.requests import Request
+
+from bom_assistant.api.routes import router
+from bom_assistant.scouting.errors import RowNotFoundError, SessionNotFoundError
+
+app = FastAPI(title="Instant BOM Assistant")
+app.include_router(router)
+
+
+@app.exception_handler(SessionNotFoundError)
+def _session_not_found(request: Request, exc: SessionNotFoundError) -> JSONResponse:
+    return JSONResponse(status_code=404, content={"detail": str(exc)})
+
+
+@app.exception_handler(RowNotFoundError)
+def _row_not_found(request: Request, exc: RowNotFoundError) -> JSONResponse:
+    return JSONResponse(status_code=404, content={"detail": str(exc)})

+ 47 - 0
bom_assistant/api/routes.py

@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+from bom_assistant.api.store import SessionStore, get_store
+from bom_assistant.scouting.ingest import build_session
+from bom_assistant.scouting.orchestrator import scout_row
+
+router = APIRouter()
+templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
+
+
+@router.get("/", response_class=HTMLResponse)
+def index(request: Request) -> HTMLResponse:
+    return templates.TemplateResponse(request, "index.html", {})
+
+
+@router.post("/upload", response_class=HTMLResponse)
+def upload(
+    request: Request,
+    file: UploadFile = File(...),
+    store: SessionStore = Depends(get_store),
+) -> HTMLResponse:
+    content = file.file.read()
+    try:
+        session = build_session(content, file.filename or "upload")
+    except Exception as exc:
+        raise HTTPException(status_code=400, detail=f"failed to parse BOM: {exc}") from exc
+    store.create(session)
+    return templates.TemplateResponse(request, "_rows.html", {"session": session})
+
+
+@router.get("/session/{session_id}/row/{row_id}/scout", response_class=HTMLResponse)
+def scout(
+    request: Request,
+    session_id: str,
+    row_id: str,
+    store: SessionStore = Depends(get_store),
+) -> HTMLResponse:
+    session = store.get(session_id)
+    row = scout_row(session, row_id)
+    store.save(session)
+    return templates.TemplateResponse(request, "_scout_result.html", {"row": row})

+ 28 - 0
bom_assistant/api/store.py

@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from bom_assistant.scouting.errors import SessionNotFoundError
+from bom_assistant.session.models import BomSession
+
+
+class SessionStore:
+    def __init__(self) -> None:
+        self._sessions: dict[str, BomSession] = {}
+
+    def create(self, session: BomSession) -> None:
+        self._sessions[session.session_id] = session
+
+    def get(self, session_id: str) -> BomSession:
+        session = self._sessions.get(session_id)
+        if session is None:
+            raise SessionNotFoundError(session_id)
+        return session
+
+    def save(self, session: BomSession) -> None:
+        self._sessions[session.session_id] = session
+
+
+_store = SessionStore()
+
+
+def get_store() -> SessionStore:
+    return _store

+ 34 - 0
bom_assistant/api/templates/_rows.html

@@ -0,0 +1,34 @@
+<table>
+  <thead>
+    <tr>
+      <th>Designators</th>
+      <th>Qty</th>
+      <th>Raw Value</th>
+      <th>Footprint</th>
+      <th>Category</th>
+      <th>State</th>
+      <th></th>
+      <th>Scout Result</th>
+    </tr>
+  </thead>
+  <tbody>
+    {% for row in session.rows %}
+    <tr>
+      <td>{{ row.designators | join(", ") }}</td>
+      <td>{{ row.quantity }}</td>
+      <td>{{ row.raw_value }}</td>
+      <td>{{ row.footprint or "" }}</td>
+      <td>{{ row.category.value }}</td>
+      <td class="state">{{ row.state.value }}</td>
+      <td>
+        <button hx-get="/session/{{ session.session_id }}/row/{{ row.row_id }}/scout"
+                hx-target="#row-{{ row.row_id }}-result"
+                hx-swap="innerHTML">
+          Scout
+        </button>
+      </td>
+      <td id="row-{{ row.row_id }}-result"></td>
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>

+ 9 - 0
bom_assistant/api/templates/_scout_result.html

@@ -0,0 +1,9 @@
+{% if row.scout_candidates %}
+<ul class="candidates">
+  {% for c in row.scout_candidates %}
+  <li>{{ c.name }} <small>(id {{ c.id }})</small></li>
+  {% endfor %}
+</ul>
+{% else %}
+<span>No candidates found</span>
+{% endif %}

+ 25 - 0
bom_assistant/api/templates/index.html

@@ -0,0 +1,25 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>Instant BOM Assistant — Category Inference Test</title>
+<script src="https://unpkg.com/htmx.org@1.9.12"></script>
+<style>
+  body { font-family: system-ui, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
+  table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
+  th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #ddd; vertical-align: top; }
+  button { cursor: pointer; }
+  .candidates { list-style: none; padding: 0; margin: 0; }
+  .candidates li { font-size: 0.85rem; }
+  .state { font-size: 0.75rem; color: #666; text-transform: uppercase; }
+</style>
+</head>
+<body>
+<h1>Category Inference Test</h1>
+<form hx-post="/upload" hx-target="#rows" hx-encoding="multipart/form-data">
+  <input type="file" name="file" accept=".csv,.xlsx,.xls" required>
+  <button type="submit">Upload BOM</button>
+</form>
+<div id="rows"></div>
+</body>
+</html>

+ 0 - 0
bom_assistant/scouting/__init__.py


+ 18 - 0
bom_assistant/scouting/errors.py

@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+
+class ScoutingError(Exception):
+    pass
+
+
+class SessionNotFoundError(ScoutingError):
+    def __init__(self, session_id: str) -> None:
+        super().__init__(f"session not found: {session_id}")
+        self.session_id = session_id
+
+
+class RowNotFoundError(ScoutingError):
+    def __init__(self, session_id: str, row_id: str) -> None:
+        super().__init__(f"row not found: {row_id} (session {session_id})")
+        self.session_id = session_id
+        self.row_id = row_id

+ 21 - 0
bom_assistant/scouting/ingest.py

@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+import uuid
+from datetime import datetime, timezone
+
+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 BomSession
+
+
+def build_session(content: bytes, filename: str) -> BomSession:
+    raw = parse_bom(content, filename)
+    mapped = map_columns(raw)
+    rows = normalize(mapped)
+    return BomSession(
+        session_id=str(uuid.uuid4()),
+        created_at=datetime.now(timezone.utc).isoformat(),
+        filename=filename,
+        rows=rows,
+    )

+ 20 - 0
bom_assistant/scouting/orchestrator.py

@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from bom_assistant.scouting.errors import RowNotFoundError
+from bom_assistant.session.models import BomRow, BomSession, RowState
+from bom_assistant.suppliers.lcsc.category_resolver import resolve
+
+
+def get_row(session: BomSession, row_id: str) -> BomRow:
+    for row in session.rows:
+        if row.row_id == row_id:
+            return row
+    raise RowNotFoundError(session.session_id, row_id)
+
+
+def scout_row(session: BomSession, row_id: str) -> BomRow:
+    row = get_row(session, row_id)
+    candidates = resolve(row)
+    row.scout_candidates = [{"name": c.name, "id": c.id} for c in candidates]
+    row.state = RowState.scouting
+    return row

BIN
bom_assistant/session/__pycache__/models.cpython-314.pyc


+ 1 - 0
bom_assistant/session/models.py

@@ -53,6 +53,7 @@ class BomRow(BaseModel):
     applied_filters: dict[str, Any] = Field(default_factory=dict)
     facet_groups: dict[str, Any] = Field(default_factory=dict)
     confirmed_pick: dict[str, Any] | None = None
+    scout_candidates: list[dict[str, Any]] | None = None
 
 
 class BomSession(BaseModel):

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

@@ -956,5 +956,103 @@
       "name": "Power Management - Specialized",
       "id": 1020
     }
+  ],
+  "capacitor||1uF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Tantalum Capacitors",
+      "id": 1150
+    },
+    {
+      "name": "Aluminum Electrolytic Capacitors",
+      "id": 1140
+    }
+  ],
+  "capacitor||10nF": [
+    {
+      "name": "Ceramic Capacitors",
+      "id": 1142
+    },
+    {
+      "name": "Silicon Capacitors",
+      "id": 1148
+    },
+    {
+      "name": "Capacitor Networks, Arrays",
+      "id": 1141
+    }
+  ],
+  "capacitor||47uF": [
+    {
+      "name": "Aluminum Electrolytic Capacitors",
+      "id": 1140
+    },
+    {
+      "name": "Aluminum - Polymer Capacitors",
+      "id": 1139
+    },
+    {
+      "name": "Tantalum Capacitors",
+      "id": 1150
+    }
+  ],
+  "resistor||0R": [
+    {
+      "name": "Chip Resistor - Surface Mount",
+      "id": 1199
+    },
+    {
+      "name": "Through Hole Resistors",
+      "id": 1203
+    },
+    {
+      "name": "Current Sense Resistors",
+      "id": 1336
+    }
+  ],
+  "ic||SGM2033-1.8XUDH4G/TR": [
+    {
+      "name": "Voltage Regulators - Linear, Low Drop Out (LDO) Regulators",
+      "id": 1032
+    },
+    {
+      "name": "Power Management (PMIC)",
+      "id": 263
+    },
+    {
+      "name": "Linear",
+      "id": 260
+    }
+  ],
+  "ic||SGM2036-2.9YUDH4G/TR": [
+    {
+      "name": "Voltage Regulators - Linear, Low Drop Out (LDO) Regulators",
+      "id": 1032
+    },
+    {
+      "name": "Power Management (PMIC)",
+      "id": 263
+    },
+    {
+      "name": "Linear",
+      "id": 260
+    }
+  ],
+  "ic||SGM2039A-1.1XXEV8G/TR": [
+    {
+      "name": "Voltage Regulators - Linear, Low Drop Out (LDO) Regulators",
+      "id": 1032
+    },
+    {
+      "name": "Power Management (PMIC)",
+      "id": 263
+    },
+    {
+      "name": "Special Purpose Regulators",
+      "id": 1024
+    }
   ]
 }

+ 23 - 14
docs/backlog.md

@@ -1,5 +1,9 @@
 # Backlog
 
+## Notes
+- 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.
+
 ## Done
 
 ### Ingestion Pipeline
@@ -12,6 +16,17 @@
 - [x] `classification/param_extractor.py` — value parsing, description fallback, package extraction, AI fallback
 - [x] `classification/package_patterns.json` — file-backed regex patterns + known_footprints dict
 
+### LCSC Category Resolution
+- [x] `suppliers/base.py` — abstract `SupplierAdapter(ABC)`, `search(category_id, params, filters=None, page=1) -> SearchResult`
+- [x] `suppliers/category_resolver.py` — generic AI category resolver, caches by `(category, package, value)`
+- [x] `suppliers/lcsc/category_resolver.py` — LCSC-specific wrapper over generic resolver
+- [x] `suppliers/lcsc/fetch_categories.py` + `suppliers/lcsc/categories.json` — scraped LCSC category tree, 600 subcategories committed
+- [x] `suppliers/lcsc/categories.py` — `load_categories()`, internal `ComponentCategory` → curated LCSC subcategory lists
+- [x] `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
+- [x] `suppliers/lcsc/category_cache.json` — file-backed resolution cache (69 entries)
+- [x] `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)**
+
 ### Bug Fixes
 - [x] Double-space header normalization (`_normalize_key` collapses whitespace) — fixed `Ref Des  (Multi-4)` mismatch
 - [x] `Comment` → `part_number` disambiguation — passive value sampling prevents `pn='100nF'`
@@ -27,16 +42,9 @@
 - [ ] 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 Category Resolution
-- [ ] `suppliers/lcsc/fetch_categories.py` — one-time CLI to scrape LCSC category pages → `categories.json` (1.5s grace period between requests)
-- [ ] `suppliers/lcsc/categories.json` — committed static category tree (refresh quarterly)
-- [ ] `suppliers/lcsc/categories.py` — `load_categories()`, `get_subcategories_for(internal_cat)` 
-- [ ] `suppliers/lcsc/category_resolver.py` — LLM (Claude Haiku) category inference + `category_cache.json` persistence
-- [ ] `suppliers/lcsc/category_cache.json` — file-backed cache (same pattern as `package_patterns.json`)
-
 ### LCSC Search Adapter
-- [ ] `suppliers/base.py` — abstract `SupplierAdapter(api_token=None)` with `search(category_id, params, page)` interface
-- [ ] `suppliers/lcsc/lcsc.py` — scraper mode: CSRF session + POST to `lcsc.com/api/products/search`; token mode stub for official API
+- [x] `suppliers/lcsc/lcsc.py` — scraper mode: CSRF session + POST to `lcsc.com/api/products/search`
+- [ ] `suppliers/lcsc/lcsc.py` — official API mode (`_search_official`) still a stub, raises `NotImplementedError`; needs HMAC signing + LCSC API key
 
 ### Scouting Loop
 - [ ] Scouting orchestrator — drive pending rows through scouting → filtering → confirmed
@@ -48,10 +56,10 @@
 - [ ] `POST /upload` — ingest file, return BomSession
 - [ ] `GET /session/{id}` — return session state
 - [ ] `GET /session/{id}/row/{row_id}/scout` — LLM category inference, return `{subcategory, alternatives[]}`
-- [ ] `POST /session/{id}/row/{row_id}/confirm-category` — lock LCSC subcategory, advance state to filtering
-- [ ] `POST /session/{id}/row/{row_id}/search` — search within confirmed category + param filters
-- [ ] `POST /session/{id}/row/{row_id}/confirm` — lock part pick, advance state to confirmed
-- [ ] `GET /session/{id}/export` — collate confirmed picks → downloadable BOM
+- [ ] `POST /session/{id}/row/{row_id}/confirm-category` — lock LCSC subcategory, advance state to filtering — **deferred** until category inference is validated interactively (only inference itself has been tested so far, not the full loop)
+- [ ] `POST /session/{id}/row/{row_id}/search` — search within confirmed category + param filters — **deferred**, same reason
+- [ ] `POST /session/{id}/row/{row_id}/confirm` — lock part pick, advance state to confirmed — **deferred**, same reason
+- [ ] `GET /session/{id}/export` — collate confirmed picks → downloadable BOM — **deferred**, same reason
 
 ### Future: Other Suppliers
 - [ ] DigiKey adapter
@@ -59,7 +67,8 @@
 - [ ] Nexar adapter
 
 ### Frontend
-- [ ] React scaffold + build setup
+- [ ] HTMX-based test page (server-rendered by FastAPI, no separate frontend process) for interactively testing category inference — backbone, in progress
+- [ ] React scaffold + build setup — deferred until the scouting loop is validated; HTMX test page covers interim needs
 - [ ] Upload page
 - [ ] Row-by-row scouting UI (category dropdown, results grid, confirm/reject)
 - [ ] Session progress view (rows confirmed vs pending)

+ 1 - 0
requirements.txt

@@ -1,5 +1,6 @@
 fastapi
 uvicorn
+jinja2
 pydantic>=2
 python-multipart
 openpyxl