diff --git a/.planning/phases/07-research-agent-search/07-02-SUMMARY.md b/.planning/phases/07-research-agent-search/07-02-SUMMARY.md new file mode 100644 index 0000000..f9b4d50 --- /dev/null +++ b/.planning/phases/07-research-agent-search/07-02-SUMMARY.md @@ -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=`. + +`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