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

feat basic filtering + search api

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

+ 6 - 1
bom_assistant/api/main.py

@@ -5,7 +5,7 @@ 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
+from bom_assistant.scouting.errors import RowNotFoundError, ScoutingValidationError, SessionNotFoundError
 
 app = FastAPI(title="Instant BOM Assistant")
 app.include_router(router)
@@ -19,3 +19,8 @@ def _session_not_found(request: Request, exc: SessionNotFoundError) -> JSONRespo
 @app.exception_handler(RowNotFoundError)
 def _row_not_found(request: Request, exc: RowNotFoundError) -> JSONResponse:
     return JSONResponse(status_code=404, content={"detail": str(exc)})
+
+
+@app.exception_handler(ScoutingValidationError)
+def _validation_error(request: Request, exc: ScoutingValidationError) -> JSONResponse:
+    return JSONResponse(status_code=400, content={"detail": str(exc)})

+ 47 - 3
bom_assistant/api/routes.py

@@ -2,13 +2,13 @@ from __future__ import annotations
 
 from pathlib import Path
 
-from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
+from fastapi import APIRouter, Depends, File, Form, 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
+from bom_assistant.scouting.orchestrator import apply_filter, confirm_category, confirm_pick, scout_row
 
 router = APIRouter()
 templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
@@ -44,4 +44,48 @@ def scout(
     session = store.get(session_id)
     row = scout_row(session, row_id)
     store.save(session)
-    return templates.TemplateResponse(request, "_scout_result.html", {"row": row})
+    return templates.TemplateResponse(request, "_scout_result.html", {"session_id": session_id, "row": row})
+
+
+@router.post("/session/{session_id}/row/{row_id}/confirm-category", response_class=HTMLResponse)
+def confirm_category_route(
+    request: Request,
+    session_id: str,
+    row_id: str,
+    category_id: int = Form(...),
+    category_name: str = Form(...),
+    store: SessionStore = Depends(get_store),
+) -> HTMLResponse:
+    session = store.get(session_id)
+    row = confirm_category(session, row_id, category_id, category_name)
+    store.save(session)
+    return templates.TemplateResponse(request, "_filtering.html", {"session_id": session_id, "row": row})
+
+
+@router.post("/session/{session_id}/row/{row_id}/apply-filter", response_class=HTMLResponse)
+def apply_filter_route(
+    request: Request,
+    session_id: str,
+    row_id: str,
+    group: str = Form(...),
+    value: str = Form(...),
+    store: SessionStore = Depends(get_store),
+) -> HTMLResponse:
+    session = store.get(session_id)
+    row = apply_filter(session, row_id, group, value)
+    store.save(session)
+    return templates.TemplateResponse(request, "_filtering.html", {"session_id": session_id, "row": row})
+
+
+@router.post("/session/{session_id}/row/{row_id}/confirm", response_class=HTMLResponse)
+def confirm_route(
+    request: Request,
+    session_id: str,
+    row_id: str,
+    product_code: str = Form(...),
+    store: SessionStore = Depends(get_store),
+) -> HTMLResponse:
+    session = store.get(session_id)
+    row = confirm_pick(session, row_id, product_code)
+    store.save(session)
+    return templates.TemplateResponse(request, "_confirmed.html", {"row": row})

+ 3 - 0
bom_assistant/api/templates/_confirmed.html

@@ -0,0 +1,3 @@
+<div class="confirmed">
+  Confirmed: {{ row.confirmed_pick.productCode }} — {{ row.confirmed_pick.brandNameEn }}
+</div>

+ 68 - 0
bom_assistant/api/templates/_filtering.html

@@ -0,0 +1,68 @@
+<div class="filtering">
+  <div><strong>{{ row.resolved_category_name }}</strong> <small>(id {{ row.resolved_category_id }})</small></div>
+
+  {% if row.applied_filters %}
+  <div class="applied-filters">
+    Applied:
+    {% for key, values in row.applied_filters.items() %}
+      <code>{{ key }}: {{ values }}</code>
+    {% endfor %}
+  </div>
+  {% endif %}
+
+  {% if row.facet_groups.error %}
+  <div class="error">facets error: {{ row.facet_groups.error }}</div>
+  {% else %}
+  <div class="facets">
+    {% if row.facet_groups.Package %}
+    <div class="facet-group">
+      <small>Package</small>
+      {% for opt in row.facet_groups.Package %}
+      <button hx-post="/session/{{ session_id }}/row/{{ row.row_id }}/apply-filter"
+              hx-vals='{"group": "Package", "value": {{ opt.name | tojson }}}'
+              hx-target="#row-{{ row.row_id }}-filtering"
+              hx-swap="innerHTML">
+        {{ opt.name }}
+      </button>
+      {% endfor %}
+    </div>
+    {% endif %}
+    {% if row.facet_groups.Manufacturer %}
+    <div class="facet-group">
+      <small>Manufacturer</small>
+      {% for opt in row.facet_groups.Manufacturer %}
+      <button hx-post="/session/{{ session_id }}/row/{{ row.row_id }}/apply-filter"
+              hx-vals='{"group": "Manufacturer", "value": {{ opt.id | tojson }}}'
+              hx-target="#row-{{ row.row_id }}-filtering"
+              hx-swap="innerHTML">
+        {{ opt.name }}
+      </button>
+      {% endfor %}
+    </div>
+    {% endif %}
+  </div>
+  {% endif %}
+
+  {% if row.search_meta.error %}
+  <div class="error">search error: {{ row.search_meta.error }}</div>
+  {% else %}
+  <div class="results">
+    <small>{{ row.search_meta.count }} result(s)</small>
+    <ul>
+      {% for item in row.search_results %}
+      <li>
+        {{ item.productCode }} — {{ item.brandNameEn }} — {{ item.encapStandard }}
+        {% if item.productPriceList %}— ${{ item.productPriceList[0].usdPrice }}{% endif %}
+        — stock {{ item.stockNumber }}
+        <button hx-post="/session/{{ session_id }}/row/{{ row.row_id }}/confirm"
+                hx-vals='{"product_code": {{ item.productCode | tojson }}}'
+                hx-target="#row-{{ row.row_id }}-filtering"
+                hx-swap="innerHTML">
+          Pick
+        </button>
+      </li>
+      {% endfor %}
+    </ul>
+  </div>
+  {% endif %}
+</div>

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

@@ -9,6 +9,7 @@
       <th>State</th>
       <th></th>
       <th>Scout Result</th>
+      <th>Filtering / Pick</th>
     </tr>
   </thead>
   <tbody>
@@ -28,6 +29,7 @@
         </button>
       </td>
       <td id="row-{{ row.row_id }}-result"></td>
+      <td id="row-{{ row.row_id }}-filtering"></td>
     </tr>
     {% endfor %}
   </tbody>

+ 8 - 1
bom_assistant/api/templates/_scout_result.html

@@ -1,7 +1,14 @@
 {% if row.scout_candidates %}
 <ul class="candidates">
   {% for c in row.scout_candidates %}
-  <li>{{ c.name }} <small>(id {{ c.id }})</small></li>
+  <li>
+    <button hx-post="/session/{{ session_id }}/row/{{ row.row_id }}/confirm-category"
+            hx-vals='{"category_id": {{ c.id }}, "category_name": {{ c.name | tojson }}}'
+            hx-target="#row-{{ row.row_id }}-filtering"
+            hx-swap="innerHTML">
+      {{ c.name }} <small>(id {{ c.id }})</small>
+    </button>
+  </li>
   {% endfor %}
 </ul>
 {% else %}

+ 4 - 0
bom_assistant/scouting/errors.py

@@ -16,3 +16,7 @@ class RowNotFoundError(ScoutingError):
         super().__init__(f"row not found: {row_id} (session {session_id})")
         self.session_id = session_id
         self.row_id = row_id
+
+
+class ScoutingValidationError(ScoutingError):
+    pass

+ 52 - 1
bom_assistant/scouting/orchestrator.py

@@ -1,8 +1,11 @@
 from __future__ import annotations
 
-from bom_assistant.scouting.errors import RowNotFoundError
+from bom_assistant.scouting.errors import RowNotFoundError, ScoutingValidationError
 from bom_assistant.session.models import BomRow, BomSession, RowState
 from bom_assistant.suppliers.lcsc.category_resolver import resolve
+from bom_assistant.suppliers.lcsc.lcsc import LcscAdapter
+
+_GROUP_FILTER_KEYS = {"Package": "encapValueList", "Manufacturer": "brandIdList"}
 
 
 def get_row(session: BomSession, row_id: str) -> BomRow:
@@ -18,3 +21,51 @@ def scout_row(session: BomSession, row_id: str) -> BomRow:
     row.scout_candidates = [{"name": c.name, "id": c.id} for c in candidates]
     row.state = RowState.scouting
     return row
+
+
+def _refresh_filtering(row: BomRow) -> None:
+    adapter = LcscAdapter()
+    row.facet_groups = adapter.query_facets(row.resolved_category_id, row.applied_filters)
+    result = adapter.search(row.resolved_category_id, row.normalized_params, row.applied_filters, page=1)
+    row.search_results = result.items
+    row.search_meta = {
+        "count": result.count,
+        "page": result.page,
+        "page_size": result.page_size,
+        "error": result.error,
+    }
+
+
+def confirm_category(session: BomSession, row_id: str, category_id: int, category_name: str) -> BomRow:
+    row = get_row(session, row_id)
+    row.resolved_category_id = category_id
+    row.resolved_category_name = category_name
+    row.applied_filters = {}
+    row.confirmed_pick = None
+    row.state = RowState.filtering
+    _refresh_filtering(row)
+    return row
+
+
+def apply_filter(session: BomSession, row_id: str, group: str, value: str) -> BomRow:
+    row = get_row(session, row_id)
+    if row.resolved_category_id is None:
+        raise ScoutingValidationError("row has no confirmed category yet")
+    key = _GROUP_FILTER_KEYS.get(group)
+    if key is None:
+        raise ScoutingValidationError(f"unsupported filter group: {group}")
+    row.applied_filters.setdefault(key, [])
+    if value not in row.applied_filters[key]:
+        row.applied_filters[key].append(value)
+    _refresh_filtering(row)
+    return row
+
+
+def confirm_pick(session: BomSession, row_id: str, product_code: str) -> BomRow:
+    row = get_row(session, row_id)
+    pick = next((item for item in row.search_results if item.get("productCode") == product_code), None)
+    if pick is None:
+        raise ScoutingValidationError(f"product not found in current results: {product_code}")
+    row.confirmed_pick = pick
+    row.state = RowState.confirmed
+    return row

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


+ 4 - 0
bom_assistant/session/models.py

@@ -54,6 +54,10 @@ class BomRow(BaseModel):
     facet_groups: dict[str, Any] = Field(default_factory=dict)
     confirmed_pick: dict[str, Any] | None = None
     scout_candidates: list[dict[str, Any]] | None = None
+    resolved_category_id: int | None = None
+    resolved_category_name: str | None = None
+    search_results: list[dict[str, Any]] = Field(default_factory=list)
+    search_meta: dict[str, Any] = Field(default_factory=dict)
 
 
 class BomSession(BaseModel):

+ 48 - 31
docs/architecture.md

@@ -100,29 +100,29 @@ Example: `category=capacitor, package=6.3x5.8, voltage=25V` → LLM picks `"Alum
   - 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 *(not yet built)*
-Drives each `BomRow` from `pending` to `confirmed` through an iterative user-guided process.
+### 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)
-  → filtering  (search within confirmed subcategory + param filters, user browses results)
-  → confirmed  (user picks a specific part)
-  → flagged    (DNP / non-electronic / user skips)
+  → 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)
 ```
 
-Retry path: user rejects all results → back to scouting with modified parameters.
+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 *(not yet built)*
-FastAPI HTTP interface.
+### 6. API Layer
+FastAPI HTTP interface (`bom_assistant/api/routes.py`).
 
-- `POST /upload` — accept BOM file, run ingestion, return `BomSession`
-- `GET /session/{id}` — current session state
-- `GET /session/{id}/row/{row_id}/scout` — trigger LLM category inference, return `{category, alternatives[]}`
-- `POST /session/{id}/row/{row_id}/confirm-category` — user accepts/overrides category → state: filtering
-- `POST /session/{id}/row/{row_id}/search` — search within confirmed category + params, return page 1
-- `POST /session/{id}/row/{row_id}/confirm` — lock part pick, advance state to confirmed
-- `GET /session/{id}/export` — collate confirmed picks → CSV/XLSX download
+- `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.
@@ -171,19 +171,27 @@ POST /upload
 
 GET /session/{id}/row/{row_id}/scout
   lcsc_category_resolver.resolve(row)          ← cache hit or Haiku call
-    → {subcategory: "Alum. Electrolytic...", id: 1140, alternatives: [...]}
+    → [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  {subcategory_id: 1140}
-  row.applied_filters["lcsc_category_id"] = 1140
+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
-
-POST /session/{id}/row/{row_id}/search  {filters: {voltage_min: 25, ...}, page: 1}
-  lcsc.search(category_id=1140, params=row.normalized_params, filters=..., page=1)
-    → {count: N, results: [...]}
-
-POST /session/{id}/row/{row_id}/confirm  {pick: {...}}
-  row.confirmed_pick = pick
+  _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[<encapValueList|brandIdList>].append(value)
+  _refresh_filtering(row)           ← same as above, narrows facets + results
+
+POST /session/{id}/row/{row_id}/confirm  {product_code: "C602037"}
+  row.confirmed_pick = <item from row.search_results matching product_code>
   row.state → confirmed
 
 GET /session/{id}/export
@@ -219,16 +227,25 @@ instant-bom/
 │   │       ├── 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 (scraper + token mode)
+│   │       └── 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/                               (not yet built)
-│       └── main.py                        — FastAPI app + routes
+│   └── 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)
+├── frontend/                              (not yet built — React SPA; HTMX test page above covers interim needs)
 │   └── src/
 ├── examples/
-│   └── bom/                               — 12 real BOM test files (CSV, XLSX, XLS)
+│   ├── full/                              — real BOM test files (CSV, XLSX, XLS)
+│   └── reduced/                           — same files, trimmed
 └── requirements.txt
 ```

+ 13 - 12
docs/backlog.md

@@ -30,15 +30,19 @@
 - [x] 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)
-- [x] `api/main.py` — FastAPI app entry point
+- [x] `api/main.py` — FastAPI app entry point + exception handlers (404 for not-found, 400 for `ScoutingValidationError`)
 - [x] `POST /upload` — ingest file, return BomSession (HTMX-rendered row table)
-- [x] `GET /session/{id}/row/{row_id}/scout` — LLM category inference, HTMX-rendered candidate list
-- [x] `bom_assistant/scouting/` — framework-agnostic orchestrator (`build_session`, `scout_row`) backing the above
-- [x] HTMX-based test page (`api/templates/`, server-rendered by FastAPI, no separate frontend process) for interactively testing category inference
+- [x] `GET /session/{id}/row/{row_id}/scout` — LLM category inference, HTMX-rendered candidate list (now with confirm buttons)
+- [x] `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
+- [x] `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
+- [x] `POST /session/{id}/row/{row_id}/confirm` — locks `confirmed_pick` from `search_results` by `productCode`, state → confirmed
+- [x] `bom_assistant/scouting/` — framework-agnostic orchestrator (`build_session`, `scout_row`, `confirm_category`, `apply_filter`, `confirm_pick`) backing the above
+- [x] 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
 - [x] `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
 - [x] `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
+- [x] 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
 
 ### Bug Fixes
 - [x] Double-space header normalization (`_normalize_key` collapses whitespace) — fixed `Ref Des  (Multi-4)` mismatch
@@ -59,16 +63,14 @@
 - [ ] `suppliers/lcsc/lcsc.py` — official API mode (`_search_official`) still a stub, raises `NotImplementedError`; needs HMAC signing + LCSC API key
 
 ### Scouting Loop
-- [ ] BomRow state machine transitions for `filtering`/`confirmed` wired to `query_facets()` + `search()` (data layer for both is ready and live-verified, see Done)
+- [ ] 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.
+- [ ] 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}/row/{row_id}/facets` — call `query_facets()`, return filter chip groups — **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}/confirm-category` — lock LCSC subcategory, advance state to filtering — **deferred**, same reason
-- [ ] `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
+- [ ] `GET /session/{id}/export` — collate confirmed picks → downloadable BOM
 
 ### Future: Other Suppliers
 - [ ] DigiKey adapter
@@ -76,7 +78,6 @@
 - [ ] Nexar adapter
 
 ### Frontend
-- [ ] React scaffold + build setup — deferred until the scouting loop is validated; HTMX test page (see Done) covers interim needs
-- [ ] Row-by-row scouting UI (category dropdown, results grid, confirm/reject)
+- [ ] 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
 - [ ] Session progress view (rows confirmed vs pending)
 - [ ] Export button

+ 26 - 14
docs/diagrams/scouting_loop_sequence.puml

@@ -20,22 +20,34 @@ Orch -> Orch : row.scout_candidates = [...]\nrow.state = scouting
 Orch --> API : row
 API --> User : candidate list (HTMX swap)
 
-== Designed, not yet built ==
-
-User --> API : POST .../confirm-category {category_id}
-API --> Orch : confirm_category(session, row_id, category_id)
-Orch --> Orch : row.resolved_category_id = ...\nrow.state = filtering
-
-User --> API : POST .../search {filters, page}
-API --> Orch : search_row(session, row_id, filters, page)
-Orch --> Adapter : search(category_id, params, filters, page)
+User -> API : POST .../confirm-category {category_id, category_name}
+API -> Orch : confirm_category(session, row_id, category_id, category_name)
+Orch -> Orch : row.resolved_category_id = ...\nrow.applied_filters = {}\nrow.state = filtering
+Orch -> Adapter : query_facets(category_id, applied_filters)
+Adapter --> Orch : facet groups (Package/Manufacturer/...)
+Orch -> Adapter : search(category_id, params, applied_filters, page=1)
 Adapter --> Orch : SearchResult(items, count)
-Orch --> API : SearchResult
-API --> User : results grid
+Orch --> API : row (facet_groups, search_results set)
+API --> User : facets + results (HTMX swap)
+
+User -> API : POST .../apply-filter {group, value}
+API -> Orch : apply_filter(session, row_id, group, value)
+Orch -> Orch : applied_filters[key].append(value)
+Orch -> Adapter : query_facets(...) + search(...)\n(same as confirm-category)
+Adapter --> Orch : narrowed facets + SearchResult
+Orch --> API : row (updated)
+API --> User : narrowed facets + results (HTMX swap)
+
+User -> API : POST .../confirm {product_code}
+API -> Orch : confirm_pick(session, row_id, product_code)
+Orch -> Orch : row.confirmed_pick = <matching item>\nrow.state = confirmed
+Orch --> API : row
+API --> User : confirmed summary (HTMX swap)
+
+== Designed, not yet built ==
 
-User --> API : POST .../confirm {pick}
-API --> Orch : confirm_pick(session, row_id, pick)
-Orch --> Orch : row.confirmed_pick = pick\nrow.state = confirmed
+User --> API : POST .../apply-filter {group: <param name>, value}
+note right : per-component param filters\n(Capacitance/Tolerance/...) -\nrequest shape unverified live
 
 User --> API : GET .../export
 API --> User : collated BOM (CSV/XLSX)

+ 3 - 2
docs/diagrams/scouting_loop_state.puml

@@ -6,8 +6,9 @@ pending --> flagged : DNP / non-electronic\n(normalizer.py, built)
 pending --> scouting : GET .../scout\n(resolve() -> candidates, built)
 scouting --> scouting : re-scout\n(GET .../scout again, built)
 
-scouting -[dotted]-> filtering : POST .../confirm-category\n**not yet built**
-filtering -[dotted]-> confirmed : POST .../confirm\n**not yet built**
+scouting --> filtering : POST .../confirm-category\n(built)
+filtering --> filtering : POST .../apply-filter\n(Package/Manufacturer, built)
+filtering --> confirmed : POST .../confirm\n(built)
 filtering -[dotted]-> scouting : user rejects all results,\nre-scout with new params\n**not yet built**
 
 confirmed --> [*]

+ 26 - 22
docs/scouting_loop.md

@@ -6,11 +6,13 @@ overrides it → parametric search within that subcategory → user picks a part
 Design source of truth: `docs/architecture.md` (§5 "Scouting Loop", "User
 Flow", "System Flow"). Build status source of truth: `docs/backlog.md`.
 
-**Current reality:** only the first hop (`pending → scouting`, i.e. category
-inference) is implemented, via `GET /session/{id}/row/{row_id}/scout`. The
-rest of the loop (`confirm-category`, `search`, `confirm`, `export`) is
-designed but intentionally deferred until inference itself is trusted through
-interactive testing (see `docs/backlog.md` → API section, "deferred" notes).
+**Current reality:** the full `pending → scouting → filtering → confirmed`
+loop is implemented: `GET .../scout` (category inference), `POST
+.../confirm-category` (locks category, populates facets + initial results),
+`POST .../apply-filter` (Package/Manufacturer only — narrows facets +
+results), `POST .../confirm` (locks a pick). Not yet built: per-component
+param filters (Capacitance/Tolerance/...), pagination, removing an applied
+filter, retry/re-scout, and `export` (see `docs/backlog.md`).
 
 ## State diagram
 
@@ -44,19 +46,23 @@ Source: [`diagrams/scouting_loop_sequence.puml`](diagrams/scouting_loop_sequence
    `LcscCategoryResolver.resolve(row)` (file-cached, falls back to a Claude
    Haiku call) and returns up to 3 ranked LCSC subcategory candidates. Row
    moves to `scouting`. Can be re-triggered freely.
-3. **Confirm category** *(deferred)* — user accepts the top candidate or
-   picks an alternative; row locks in `resolved_category_id` and moves to
-   `filtering`.
-4. **Facet query + Search** *(data layer built, not yet wired to an
-   endpoint)* — `LcscAdapter.query_facets(category_id, filters)` calls LCSC's
-   real facet API (`wmsc.lcsc.com/ftps/wm/product/query/param/group`) to get
-   manufacturer/package/param filter groups scoped to the category and
-   whatever's already selected; `LcscAdapter.search(category_id, params,
-   filters, page)` calls the matching product-list endpoint. Both were
-   reverse-engineered from live traffic and verified end-to-end against real
-   category-resolver output — see `docs/backlog.md`. No `GET .../facets` or
-   `POST .../search` route exists yet.
-5. **Confirm pick** *(deferred)* — user selects a specific part; row locks
+3. **Confirm category** *(built)* — `POST .../confirm-category` — user
+   accepts the top candidate or picks an alternative; row locks in
+   `resolved_category_id`/`resolved_category_name`, resets `applied_filters`,
+   and moves to `filtering`.
+4. **Facet query + Search** *(built — Package/Manufacturer only)* —
+   confirming a category (and every `apply-filter` call after) eagerly calls
+   `LcscAdapter.query_facets(category_id, applied_filters)` and
+   `LcscAdapter.search(category_id, params, applied_filters, page=1)`,
+   storing results on `row.facet_groups`/`row.search_results`. `POST
+   .../apply-filter` lets the user narrow by a Package or Manufacturer facet
+   value; per-component param filters (Capacitance/Tolerance/...) are not
+   wired — the request shape LCSC expects to select a param value was never
+   captured live, so it's deferred rather than guessed. `applied_filters` is
+   kept strictly as the raw LCSC filter payload fragment (no app-internal
+   keys mixed in) so it passes straight through with no translation layer.
+5. **Confirm pick** *(built)* — `POST .../confirm` — user selects a specific
+   part by `productCode` from `row.search_results`; row locks
    `confirmed_pick` and moves to `confirmed`.
 6. **Retry** *(deferred)* — if the user rejects all search results, the row
    can go back to `scouting` with adjusted parameters rather than being a
@@ -64,10 +70,8 @@ Source: [`diagrams/scouting_loop_sequence.puml`](diagrams/scouting_loop_sequence
 7. **Export** *(deferred)* — once rows are `confirmed` (or explicitly
    skipped), `GET .../export` collates them into an order-ready BOM.
 
-Steps 3–7 are why the API currently exposes only `/upload` and `/scout`: the
-loop is built incrementally, and the next slice only gets built once category
-inference (step 2) has been exercised enough through the HTMX test page to
-trust building on top of it.
+Steps 6–7 and per-param filtering within step 4 are the remaining gaps — see
+`docs/backlog.md` → Todo → Scouting Loop / API.
 
 ## See also
 - `docs/architecture.md` — full intended module design