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>
302 lines
13 KiB
Markdown
302 lines
13 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/mikkel/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
<!-- Key contracts the executor needs. Extracted from codebase. -->
|
|
|
|
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<InventoryItem[]>
|
|
// 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.
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: SearchHandler — NL query → NetBox filter → device list</name>
|
|
<files>
|
|
internal/api/handlers/search.go,
|
|
internal/api/handlers/search_test.go,
|
|
internal/api/router.go
|
|
</files>
|
|
<behavior>
|
|
- 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
|
|
</behavior>
|
|
<action>
|
|
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)
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/mikkel/homelabby && go build ./... && go test ./internal/api/handlers/... -v -count=1 -run TestSearch 2>&1 | tail -20</automated>
|
|
</verify>
|
|
<done>
|
|
GET /api/search?q=... handler compiles and handler tests pass.
|
|
Router wired. go build clean.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Frontend NL search bar + main.go wiring</name>
|
|
<files>
|
|
web/src/lib/api.ts,
|
|
web/src/pages/DashboardPage.tsx,
|
|
web/src/components/inventory/FilterBar.tsx,
|
|
cmd/hwlab/main.go
|
|
</files>
|
|
<action>
|
|
web/src/lib/api.ts — add fetchSearch:
|
|
```typescript
|
|
export const fetchSearch = (q: string): Promise<InventoryItem[]> =>
|
|
fetchJSON<InventoryItem[]>(`${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)
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/mikkel/homelabby && go build ./... && cd web && npm run build 2>&1 | tail -20</automated>
|
|
</verify>
|
|
<done>
|
|
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.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/07-research-agent-search/07-02-SUMMARY.md`
|
|
</output>
|