--- phase: 07-research-agent-search plan: "02" subsystem: search tags: [search, nlp, ai, frontend, go, react] dependency_graph: requires: [07-01] provides: [search-endpoint, nl-search-ui] affects: [DashboardPage, FilterBar, api-router] tech_stack: added: [] patterns: - NL query sanitize → LLM extraction → in-memory filter (no raw text to NetBox) - TDD: failing tests written first, handler implemented to green - Debounced NL input (400ms) with TanStack Query (enabled guard on length > 2) key_files: created: - internal/api/handlers/search.go - internal/api/handlers/search_test.go modified: - internal/api/router.go - cmd/hwlab/main.go - web/src/lib/api.ts - web/src/components/inventory/FilterBar.tsx - web/src/pages/DashboardPage.tsx decisions: - Used SearchNetBoxClient and SearchAIClient narrow interfaces in search.go for testability without importing ai package in tests - extractJSON helper strips markdown code fences from LLM response before JSON parse - NL search row placed below existing filter row (additive, not replacing) to keep local filter intact - displayLoading/displayItems derived state avoids duplicating isLoading/searchLoading logic metrics: duration: "~12 minutes" completed: "2026-04-10" tasks_completed: 2 files_changed: 7 --- # Phase 07 Plan 02: Natural Language Search Summary Natural language inventory search via Tier 1 LLM (Gemma 4): user query sanitized, translated to structured filter params (catalog_status + name_contains), applied in-memory against ListDevices(200), returned as InventoryItem JSON; dashboard gains a debounced NL search input with volt accent styling and fallback to substring match on LLM parse failure. ## Tasks Completed | Task | Name | Commit | Files | |------|------|--------|-------| | 1 | SearchHandler — NL query to NetBox filter | 9db7707 | search.go, search_test.go, router.go, main.go | | 2 | Frontend NL search bar + api.ts wiring | 7db093c | api.ts, FilterBar.tsx, DashboardPage.tsx | ## What Was Built ### Backend (Task 1) `internal/api/handlers/search.go` provides `SearchHandler` with `SearchDevices(w, r)`: 1. Validates `q` param — returns 400 if empty 2. Sanitizes query: strips non-printable chars, truncates to 200 runes (T-07-05) 3. Calls `tier1.TextComplete` with an extraction prompt requesting `{"catalog_status","name_contains","tag"}` JSON 4. Calls `extractJSON` to strip markdown fences before parse (LLM sometimes wraps in code blocks) 5. On parse failure: logs warning, falls back to `NameContains = rawQuery` (never 500) 6. `tag` field logged as not implemented and ignored (MVP) 7. Fetches `ListDevices(ctx, 200)`, applies in-memory filter, encodes result Router wired: `GET /api/search` with nil-guard 503 fallback. `main.go` constructs `handlers.NewSearchHandler(nbClient, tier1)` and passes to `NewRouter`. TDD: 4 tests written before implementation — `TestSearch_EmptyQ`, `TestSearch_LLMParseFallback`, `TestSearch_CatalogStatusFilter`, `TestSearch_NameContainsAndStatus` — all pass. ### Frontend (Task 2) `web/src/lib/api.ts`: `fetchSearch(q)` added — calls `GET /api/search?q=`. `FilterBar.tsx` restructured to two rows: - Row 1: existing local text search + status select + item count + view toggle (unchanged) - Row 2: NL input with `Sparkles` icon (volt/60 tint), `border-volt/20` → `focus:border-volt`, `Loader2` spinner when `nlSearchLoading` - 400ms debounce via `useEffect + setTimeout` pattern; local `nlInputValue` state, propagates via `onNlQueryChange` `DashboardPage.tsx`: - `nlQuery` state + `useQuery({ queryKey: ['search', nlQuery], enabled: nlQuery.trim().length > 2 })` - `displayItems = nlQuery > 2 ? searchResults : filtered` — local filter fully preserved when NL empty - Loading/empty state messages adapt to NL vs local mode ## Threat Mitigations Applied | Threat | Mitigation | |--------|-----------| | T-07-05 | `sanitizeQuery`: non-printable chars stripped, truncated to 200 runes | | T-07-06 | Only `catalog_status`, `name_contains`, `tag` extracted; unknown keys ignored; fallback on parse failure | ## Deviations from Plan ### Auto-added: extractJSON helper **Found during:** Task 1 implementation **Issue:** LLMs commonly wrap JSON responses in markdown code fences (` ```json ... ``` `), which breaks `json.Unmarshal` directly **Fix:** Added `extractJSON(s string) string` that finds first `{` and last `}` to extract the JSON object before parsing **Files modified:** internal/api/handlers/search.go **Rule:** Rule 2 — missing critical functionality (robustness of LLM output parsing) No other deviations — plan executed as specified. ## Known Stubs None — all data paths are wired end-to-end. ## Threat Flags None — no new network endpoints or trust boundaries beyond what the plan specified. ## Self-Check: PASSED - internal/api/handlers/search.go: exists - internal/api/handlers/search_test.go: exists, 4 tests pass - internal/api/router.go: GET /api/search wired - web/src/lib/api.ts: fetchSearch exported - web/src/pages/DashboardPage.tsx: nlQuery state + useQuery present - web/src/components/inventory/FilterBar.tsx: NL input with debounce present - go build ./...: clean - npm run build: clean (built in 3.14s) - Commits 9db7707 and 7db093c: verified in git log