homelabby/.planning/phases/07-research-agent-search/07-02-PLAN.md
Mikkel Georgsen 34e0803661 docs(07): create phase 7 plans — research agent and NL search
2 plans, 2 waves: SearXNG client + ResearchAgent (wave 1),
NL search endpoint + dashboard search bar (wave 2). Covers AI-04 + UI-03.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:45:56 +00:00

13 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
07-research-agent-search 02 execute 2
07-01-PLAN.md
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
true
AI-04
UI-03
truths artifacts key_links
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
path provides exports
internal/api/handlers/search.go SearchHandler: GET /api/search?q=...
SearchHandler
NewSearchHandler
path provides exports
web/src/lib/api.ts fetchSearch(q) function
fetchSearch
SearchResponse
from to via pattern
web/src/pages/DashboardPage.tsx /api/search TanStack Query useQuery on nlQuery state useQuery.*search
from to via pattern
internal/api/handlers/search.go internal/netbox/client.go ListDevices or ListDevicesWithStatus filtered by extracted params 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

<execution_context> @/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md @/home/mikkel/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

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:

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:

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:

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<InventoryItem[]>
// Add fetchSearch here — returns InventoryItem[] with same shape

From web/src/pages/DashboardPage.tsx:

// 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 <Loader2 className="w-4 h-4 animate-spin text-volt" />
  - 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.

<threat_model>

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
</threat_model>
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

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/07-research-agent-search/07-02-SUMMARY.md`