--- phase: 07-research-agent-search plan: 02 type: execute wave: 2 depends_on: - "07-01-PLAN.md" files_modified: - internal/api/handlers/search.go - internal/api/router.go - web/src/lib/api.ts - web/src/pages/DashboardPage.tsx - web/src/components/inventory/FilterBar.tsx autonomous: true requirements: - AI-04 - UI-03 must_haves: truths: - "GET /api/search?q=show+me+free+10GbE+NICs returns matching inventory items" - "Tier 1 (Gemma 4) translates the natural language query to NetBox filter params before the inventory lookup" - "Dashboard has a natural language search input; submitting it calls GET /api/search and displays results using existing ItemCard/ItemRow" - "Sanitized query — no raw NL text reaches NetBox filter params; only structured extracted values" artifacts: - path: "internal/api/handlers/search.go" provides: "SearchHandler: GET /api/search?q=..." exports: ["SearchHandler", "NewSearchHandler"] - path: "web/src/lib/api.ts" provides: "fetchSearch(q) function" exports: ["fetchSearch", "SearchResponse"] key_links: - from: "web/src/pages/DashboardPage.tsx" to: "/api/search" via: "TanStack Query useQuery on nlQuery state" pattern: "useQuery.*search" - from: "internal/api/handlers/search.go" to: "internal/netbox/client.go" via: "ListDevices or ListDevicesWithStatus filtered by extracted params" pattern: "nbClient\\.List" --- Natural language inventory search: a Tier 1 LLM translates the user's query to structured NetBox filter params, fetches matching devices, and returns them. The dashboard gains an NL search input wired to GET /api/search. Purpose: Delivers UI-03 (natural language search) and closes the remaining AI-04 surface (research loop query path). Output: - internal/api/handlers/search.go — SearchHandler with NL→filter translation - Router wired with GET /api/search - web/src/lib/api.ts — fetchSearch function - DashboardPage NL search bar replaces/augments the existing local text filter @/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md @/home/mikkel/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/07-research-agent-search/07-01-SUMMARY.md @internal/api/router.go @internal/api/handlers/search.go @internal/netbox/client.go @internal/netbox/types.go @internal/ai/client.go @internal/config/config.go @web/src/lib/api.ts @web/src/pages/DashboardPage.tsx @web/src/components/inventory/FilterBar.tsx From internal/netbox/client.go: ```go func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error) func (c *Client) ListDevicesWithStatus(ctx context.Context, status string) ([]Device, error) // Added in Plan 01 ``` From internal/netbox/types.go: ```go type Device struct { ID int Name string AssetTag string CustomFields CustomFields Created time.Time LastUpdated time.Time } type CustomFields struct { HWID string CatalogStatus string ProductURL string FirmwareVersion string TestDate string TestData string AINotes string PhotoURLs []string } ``` From internal/ai/client.go: ```go type TierClient struct { ... } // TextComplete added in Plan 01: func (c *TierClient) TextComplete(ctx context.Context, prompt string) (string, error) ``` From web/src/lib/api.ts: ```typescript export interface InventoryItem { id: number; name: string; asset_tag: string | null; hw_id: string | null; catalog_status: string | null; product_url: string | null; firmware_version: string | null; test_date: string | null; test_data: string | null; ai_notes: string | null; photo_urls: string[]; } export const fetchInventory = (): Promise // Add fetchSearch here — returns InventoryItem[] with same shape ``` From web/src/pages/DashboardPage.tsx: ```typescript // Existing local text filter: const [search, setSearch] = useState('') // Filtered locally via useMemo. NL search should add a separate nlQuery state. // When nlQuery is non-empty: show NL results (from GET /api/search) instead of local filter. // When nlQuery is empty: use existing local search behavior unchanged. ``` Task 1: SearchHandler — NL query → NetBox filter → device list internal/api/handlers/search.go, internal/api/handlers/search_test.go, internal/api/router.go - GET /api/search?q=show+me+free+10GbE+NICs → 200 JSON array of InventoryItem - GET /api/search with empty q → 400 {"error":"q parameter required"} - Tier 1 LLM receives: "Extract NetBox filter parameters from this inventory search query. Return JSON only: {\"catalog_status\": \"...\", \"name_contains\": \"...\", \"tag\": \"...\"}. All fields optional. Query: {user query}" - LLM response parsed; client-side filter applied to ListDevices(ctx, 200) results - name_contains: case-insensitive substring match on device.Name - catalog_status: exact match on CustomFields.CatalogStatus (available → "available", etc.) - tag: ignored for MVP (NetBox tag filtering requires separate API; log "tag filter not implemented") - If LLM parse fails, fall back to simple substring match on device Name against raw query - Result serialized as []map[string]interface{} matching InventoryItem TypeScript shape internal/api/handlers/search.go: - SearchHandler struct: nbClient *netbox.Client, tier1 *ai.TierClient - NewSearchHandler(nb *netbox.Client, tier1 *ai.TierClient) *SearchHandler - SearchDevices(w http.ResponseWriter, r *http.Request): 1. q := r.URL.Query().Get("q"); if empty → 400 2. Sanitize q: strip non-printable chars, trim to 200 chars max 3. Call tier1.TextComplete(ctx, nlFilterPrompt(q)) — 5s timeout 4. Parse JSON response into struct { CatalogStatus string `json:"catalog_status"`, NameContains string `json:"name_contains"`, Tag string `json:"tag"` } 5. If parse fails: log warning, set NameContains = q (fallback) 6. devices, _ = nbClient.ListDevices(ctx, 200) 7. Apply filters: CatalogStatus match + NameContains match (both case-insensitive) 8. Convert filtered devices to response slice using deviceToResponseMap helper 9. json.NewEncoder(w).Encode(result) deviceToResponseMap converts netbox.Device to map[string]interface{} matching InventoryItem shape: { "id", "name", "asset_tag" (from AssetTag or nil), "hw_id", "catalog_status", "product_url", "firmware_version", "test_date", "test_data", "ai_notes", "photo_urls" } nlFilterPrompt(q string) string — returns the extraction prompt. internal/api/handlers/search_test.go: - Use a mock netbox client (simple struct implementing a ListDevices method via interface, or just test the filter logic separately) - Test: empty q returns 400 - Test: nlFilter parse failure falls back to name substring match - Test: catalog_status filter correctly narrows results internal/api/router.go: - Add searchHandler *handlers.SearchHandler param to NewRouter signature - Add r.Get("/search", searchHandler.SearchDevices) inside r.Route("/api", ...) - nil guard: if searchHandler is nil, return 503 (same pattern as advisorHandler) cd /home/mikkel/homelabby && go build ./... && go test ./internal/api/handlers/... -v -count=1 -run TestSearch 2>&1 | tail -20 GET /api/search?q=... handler compiles and handler tests pass. Router wired. go build clean. Task 2: Frontend NL search bar + main.go wiring web/src/lib/api.ts, web/src/pages/DashboardPage.tsx, web/src/components/inventory/FilterBar.tsx, cmd/hwlab/main.go web/src/lib/api.ts — add fetchSearch: ```typescript export const fetchSearch = (q: string): Promise => fetchJSON(`${BASE}/search?q=${encodeURIComponent(q)}`) ``` web/src/pages/DashboardPage.tsx changes: 1. Add state: const [nlQuery, setNlQuery] = useState('') 2. Add TanStack Query hook: ```typescript const { data: searchResults, isLoading: searchLoading } = useQuery({ queryKey: ['search', nlQuery], queryFn: () => fetchSearch(nlQuery), enabled: nlQuery.trim().length > 2, staleTime: 30_000, }) ``` 3. Display logic: when nlQuery.length > 2, show searchResults (or empty state) instead of the existing `filtered` local results. 4. When nlQuery is empty, existing local filter behavior is unchanged. 5. Pass nlQuery + setNlQuery + searchLoading to FilterBar as new props. web/src/components/inventory/FilterBar.tsx changes: - Add nlQuery string prop + onNlQueryChange (string) => void + nlSearchLoading boolean - Add a second input below (or inline with) the existing search input: - Placeholder: "Ask anything: show me free 10GbE NICs…" - Tailwind: full-width, border-volt/40, bg-[#0a0a0a], text-white, focus:border-volt, rounded-card, px-3 py-2 text-sm - Right side: if nlSearchLoading show - onBlur / onChange with 400ms debounce: call onNlQueryChange - Use a local useState for the input value; debounce via useEffect + setTimeout clearing pattern - Keep existing search + status filter inputs intact — NL search is additive cmd/hwlab/main.go: - Import internal/api/handlers (already imported) - After building tier1 TierClient: searchHandler := handlers.NewSearchHandler(nbClient, tier1) - Pass searchHandler to api.NewRouter(...) — add as new final param - NOTE: tier1 is already constructed as `ai.NewTierClient(cfg.AI.Tier1)` — pass it directly but SearchHandler needs *ai.TierClient not ai.AIClient; adjust if needed (TierClient is a concrete type) cd /home/mikkel/homelabby && go build ./... && cd web && npm run build 2>&1 | tail -20 go build and npm run build both pass with no errors. Dashboard FilterBar renders NL search input. Typing a query with > 2 chars triggers GET /api/search and displays results using existing ItemCard/ItemRow components. Existing local search filter still works when nlQuery is empty. ## Trust Boundaries | Boundary | Description | |----------|-------------| | browser → GET /api/search | User-supplied NL query crosses HTTP boundary | | search handler → Tier 1 LLM | Sanitized query forwarded to local oMLX | | LLM output → NetBox filter | Structured JSON from LLM used to filter devices | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-07-05 | Tampering | NL query → LLM prompt | mitigate | Strip non-printable chars, truncate to 200 chars before building prompt | | T-07-06 | Tampering | LLM output → filter params | mitigate | Parse only known fields (catalog_status, name_contains, tag); ignore unknown keys; fallback on parse failure | | T-07-07 | Denial of Service | GET /api/search fanout | accept | ListDevices(200) is bounded; Tier 1 local inference is fast; no per-user rate limiting needed for single-operator tool | | T-07-08 | Information Disclosure | search results | accept | All results are local NetBox inventory; no cross-tenant risk in single-operator homelab | 1. `go build ./...` passes 2. `cd web && npm run build` passes 3. Manual: GET http://localhost:8080/api/search?q=show+me+available+NICs returns JSON array 4. Manual: GET http://localhost:8080/api/search (no q) returns 400 5. Dashboard: NL search input visible below existing filter bar; typing triggers spinner then results - GET /api/search?q=... returns filtered InventoryItem array using Tier 1 NL→filter translation - Query sanitized (non-printable stripped, 200 char max) before LLM - LLM parse failure falls back to name substring match (never 500) - Dashboard NL search bar triggers live search; existing local filter unchanged when NL query empty - go build and npm run build both clean After completion, create `.planning/phases/07-research-agent-search/07-02-SUMMARY.md`