docs(07-02): complete natural language search plan summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-04-10 07:56:51 +00:00
parent 7db093c696
commit 712a0a39b8

View file

@ -0,0 +1,118 @@
---
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=<encoded>`.
`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