diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c693592..803aa3b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -139,7 +139,11 @@ Plans: 3. SearXNG queries are sanitized before dispatch — no raw AI output reaches the search engine **Note:** AI-04 is the sole unmapped requirement from Phase 2 that belongs here — it requires the full orchestrator, SearXNG client, and NetBox inventory all in place. The other Phase 2 requirements cover Tier 1 intake; this requirement covers the Tier 2 research loop. -**Plans**: TBD +**Plans**: 2 plans + +Plans: +- [ ] 07-01-PLAN.md — SearXNG client, ResearchAgent worker, POST /api/research/trigger +- [ ] 07-02-PLAN.md — NL search endpoint GET /api/search, dashboard search bar ## Progress @@ -154,4 +158,4 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 | 4. USB Manager & Label Printing | 5/5 | Complete | 2026-04-10 | | 5. Cable Test Integration | 3/3 | Complete | 2026-04-10 | | 6. Lab Advisor | 3/3 | Complete | 2026-04-10 | -| 7. Research Agent & Search | 0/TBD | Not started | - | +| 7. Research Agent & Search | 0/2 | Not started | - | diff --git a/.planning/phases/07-research-agent-search/07-01-PLAN.md b/.planning/phases/07-research-agent-search/07-01-PLAN.md new file mode 100644 index 0000000..3379a81 --- /dev/null +++ b/.planning/phases/07-research-agent-search/07-01-PLAN.md @@ -0,0 +1,319 @@ +--- +phase: 07-research-agent-search +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - internal/config/config.go + - internal/netbox/client.go + - internal/research/searxng.go + - internal/research/agent.go + - internal/ai/research.go + - cmd/hwlab/main.go +autonomous: true +requirements: + - AI-04 + +must_haves: + truths: + - "A SearXNG HTTP GET to http://10.5.0.129:8080/search?q=...&format=json returns parsed results" + - "Items with catalog_status=needs_research are polled from NetBox every 10 minutes" + - "Each needs_research item is enriched by SearXNG + Tier 2 LLM and updated to catalog_status=researched in NetBox" + - "POST /api/research/trigger fires an immediate research cycle (does not wait for the 10-min ticker)" + artifacts: + - path: "internal/research/searxng.go" + provides: "SearXNGClient implementing ai.ResearchClient" + exports: ["SearXNGClient", "NewSearXNGClient"] + - path: "internal/research/agent.go" + provides: "ResearchAgent background goroutine" + exports: ["Agent", "NewAgent", "RunOnce", "Start"] + key_links: + - from: "internal/research/searxng.go" + to: "http://10.5.0.129:8080/search" + via: "net/http GET with q and format=json query params" + pattern: "http\\.Get.*search.*format=json" + - from: "internal/research/agent.go" + to: "internal/netbox/client.go" + via: "ListDevicesWithStatus(ctx, \"needs_research\")" + pattern: "ListDevicesWithStatus" + - from: "internal/research/agent.go" + to: "internal/ai/client.go" + via: "tier2.AnalyzePhotos (text-only prompt, no photos)" + pattern: "AnalyzePhotos" +--- + + +Build the real SearXNG research client and the ResearchAgent background worker that +closes the AI-04 research loop: items at needs_research are enriched automatically. + +Purpose: Replace the Phase 2 NoOpResearchClient stub and deliver the automated +enrichment cycle that advances items from needs_research to researched in NetBox. + +Output: +- internal/research/searxng.go — real HTTP client implementing ai.ResearchClient +- internal/research/agent.go — background worker with ticker + on-demand trigger +- Config additions for SearXNG URL +- main.go goroutine start + POST /api/research/trigger handler + + + +@/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 + +@internal/ai/research.go +@internal/ai/client.go +@internal/ai/orchestrator.go +@internal/netbox/client.go +@internal/netbox/custom_fields.go +@internal/netbox/types.go +@internal/inventory/catalog_updater.go +@internal/config/config.go +@cmd/hwlab/main.go +@internal/api/router.go + + + + +From internal/ai/research.go: +```go +type SearchResult struct { + Title string + URL string + Snippet string +} + +type ResearchClient interface { + Search(ctx context.Context, query string) ([]SearchResult, error) +} + +type NoOpResearchClient struct{} +// Replace this with SearXNGClient in this plan. +``` + +From internal/ai/client.go: +```go +type AIClient interface { + AnalyzePhotos(ctx context.Context, req IntakeRequest) (*IntakeResult, error) +} +// IntakeRequest.PhotosBase64 may be empty — the Tier 2 model accepts text-only +// if the prompt is placed in a separate system message; use a text-only prompt +// for research enrichment (no photos). +``` + +From internal/netbox/client.go (method to ADD): +```go +// ListDevicesWithStatus returns devices whose catalog_status custom field equals status. +// Use status="needs_research" to find items needing enrichment. +func (c *Client) ListDevicesWithStatus(ctx context.Context, status string) ([]Device, error) +``` + +From internal/inventory/catalog_updater.go: +```go +func (u *CatalogUpdater) UpdateCatalogStatus(ctx context.Context, deviceID int64, current, next CatalogStatus) (CatalogStatus, error) +``` + +From internal/inventory (quality_gate.go constants): +```go +const StatusNeedsResearch CatalogStatus = "needs_research" +const StatusResearched CatalogStatus = "researched" +``` + +From internal/config/config.go (field to ADD): +```go +SearXNGURL string `mapstructure:"searxng_url"` +// default: "http://10.5.0.129:8080" +// env: HWLAB_SEARXNG_URL +``` + + + + + + + Task 1: SearXNG client + netbox.ListDevicesWithStatus + + internal/research/searxng.go, + internal/research/searxng_test.go, + internal/netbox/client.go, + internal/config/config.go + + + - SearXNGClient.Search(ctx, "Intel NIC i350") sends GET http://10.5.0.129:8080/search?q=Intel+NIC+i350&format=json + - HTTP 200 with JSON body {"results":[{"title":"...","url":"...","content":"..."},...]} parses into []ai.SearchResult (map content->Snippet) + - HTTP non-200 returns error with status code + - Empty results array returns empty slice, no error + - Query is URL-encoded (url.QueryEscape or url.Values) + - ListDevicesWithStatus filters via custom_fields cf_catalog_status in go-netbox list call; falls back to client-side filter if API param unavailable + - ListDevicesWithStatus("needs_research") returns only devices with that catalog_status + + + Create package internal/research. + + internal/research/searxng.go: + - Struct SearXNGClient with baseURL string and httpClient *http.Client (timeout 15s) + - NewSearXNGClient(baseURL string) *SearXNGClient — if baseURL empty, use "http://10.5.0.129:8080" + - Implements ai.ResearchClient interface + - Search method: build GET {baseURL}/search?q={url-encoded query}&format=json, execute, decode JSON + - SearXNG JSON response shape: {"results":[{"title":"","url":"","content":""},...]} + Map content field to SearchResult.Snippet (SearXNG uses "content" not "snippet") + - Return ([]ai.SearchResult, error). Never panic on empty results. + + internal/research/searxng_test.go: + - Use httptest.NewServer to mock SearXNG responses + - Test: valid response parses correctly (2 results) + - Test: HTTP 500 returns error + - Test: empty results returns empty slice + + internal/netbox/client.go — add ListDevicesWithStatus: + - List all devices (up to 200), filter client-side where CustomFields.CatalogStatus == status + - (go-netbox v4 custom field filtering via query param is schema-dependent; client-side is safer) + + internal/config/config.go — add SearXNGURL: + - Field: SearXNGURL string `mapstructure:"searxng_url"` + - Default: v.SetDefault("searxng_url", "http://10.5.0.129:8080") + - Env binding: v.BindEnv("searxng_url", "HWLAB_SEARXNG_URL") + + + cd /home/mikkel/homelabby && go test ./internal/research/... ./internal/config/... -v -count=1 -run TestSearXNG 2>&1 | tail -20 + + + SearXNGClient implements ai.ResearchClient. Tests pass with httptest mock server. + ListDevicesWithStatus added to netbox.Client. Config loads SearXNGURL with default. + + + + + Task 2: ResearchAgent worker + main.go wiring + trigger endpoint + + internal/research/agent.go, + internal/research/agent_test.go, + internal/api/handlers/research.go, + internal/api/router.go, + cmd/hwlab/main.go + + + - Agent.RunOnce(ctx) polls NetBox for needs_research items, for each: builds a text-only search query from item Name, calls SearXNGClient.Search, sends results to Tier 2 LLM with a research prompt, patches NetBox custom fields (ai_notes, product_url from first result URL), transitions status to researched via CatalogUpdater + - Agent.Start(ctx, interval) runs RunOnce on ticker; logs "research agent: cycle complete, enriched N items" + - If SearXNG returns 0 results for an item, log warning and skip (do not change status) + - Tier 2 LLM research prompt: "You are enriching a hardware inventory record. Item: {name}. Search results: {formatted snippets}. Return JSON: {\"ai_notes\": \"...\", \"product_url\": \"...\"}" + - POST /api/research/trigger responds 202 Accepted and fires RunOnce in a goroutine (non-blocking) + - Query sanitization: strip characters outside [a-zA-Z0-9 .-_] before passing to SearXNG + + + internal/research/agent.go: + - Struct Agent with fields: nbClient *netbox.Client, researchClient ai.ResearchClient, + tier2 ai.AIClient, updater *inventory.CatalogUpdater + - NewAgent(nb *netbox.Client, rc ai.ResearchClient, tier2 ai.AIClient, updater *inventory.CatalogUpdater) *Agent + - sanitizeQuery(s string) string — regexp [^a-zA-Z0-9 .\-_]+ replaced with space, strings.TrimSpace + - RunOnce(ctx context.Context) (enriched int, err error): + 1. ListDevicesWithStatus(ctx, "needs_research") + 2. For each device: + a. query = sanitizeQuery(device.Name) + b. results = researchClient.Search(ctx, query) — skip if 0 results + c. Build text prompt with top 3 results (title + snippet) + d. tier2.AnalyzePhotos(ctx, IntakeRequest{PhotosBase64: nil, SystemPrompt: researchPrompt}) + NOTE: IntakeRequest may not have SystemPrompt; build the research prompt as the + text part of the multimodal request by putting it in a single text-only message. + Check IntakeRequest fields; if no SystemPrompt, use a wrapper: set PhotosBase64 to + nil and pass the assembled prompt text in a way the TierClient accepts. + ALTERNATIVE if IntakeRequest does not support text-only: use go-openai directly + via a new ResearchTierClient method — add TextComplete(ctx, prompt) (*IntakeResult, error) + that posts a simple text ChatCompletion (no images). Prefer this approach for clarity. + e. Parse response for ai_notes and product_url + f. Patch NetBox: PatchCustomFields with ai_notes + product_url (if non-empty) + g. UpdateCatalogStatus(ctx, id, StatusNeedsResearch, StatusResearched) + h. enriched++ + 3. Return enriched count + - Start(ctx context.Context, interval time.Duration): + log.Printf("research agent: starting, interval=%v", interval) + RunOnce immediately, then ticker loop until ctx.Done() + + For the text-only LLM call: add TextComplete to TierClient in internal/ai/client.go: + ```go + func (c *TierClient) TextComplete(ctx context.Context, prompt string) (string, error) + ``` + This does a simple non-vision ChatCompletion with a single user message. Agent uses this. + + internal/research/agent_test.go: + - Mock ResearchClient returning 2 fake SearchResults + - Mock AIClient (use existing MockAIClient pattern if available, else minimal struct) + - Mock NetBox (or use a stub struct) — test RunOnce returns enriched=1 for a fake device + - Test sanitizeQuery strips special chars + + internal/api/handlers/research.go: + - ResearchHandler struct with agent *research.Agent + - NewResearchHandler(agent *research.Agent) *ResearchHandler + - TriggerResearch(w http.ResponseWriter, r *http.Request): + go func() { agent.RunOnce(context.Background()) }() + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]string{"status": "accepted"}) + + internal/api/router.go: + - Add researchHandler *handlers.ResearchHandler parameter to NewRouter signature + - Add r.Post("/research/trigger", researchHandler.TriggerResearch) inside r.Route("/api", ...) + - If researchHandler is nil, register an unavailable handler (same pattern as advisorHandler) + + cmd/hwlab/main.go: + - Import internal/research + - After config load: searxngClient := research.NewSearXNGClient(cfg.SearXNGURL) + - researchAgent := research.NewAgent(nbClient, searxngClient, tier2, catalogUpdater) + - go researchAgent.Start(ctx, 10*time.Minute) + - researchHandler := handlers.NewResearchHandler(researchAgent) + - Pass researchHandler to api.NewRouter(...) + + + cd /home/mikkel/homelabby && go build ./... && go test ./internal/research/... -v -count=1 2>&1 | tail -30 + + + go build passes. Agent tests pass. POST /api/research/trigger wired in router. + Research agent goroutine starts on server launch with 10-minute interval. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| agent → SearXNG | AI-generated query text leaves the process and reaches the search engine | +| SearXNG → agent | External search results (HTML snippets) enter the process and are forwarded to LLM | +| trigger endpoint → agent | HTTP request from frontend triggers a research cycle | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-07-01 | Tampering | sanitizeQuery | mitigate | Strip [^a-zA-Z0-9 .\-_]+ before dispatch; test with adversarial input in unit test | +| T-07-02 | Information Disclosure | SearXNG response snippets | accept | SearXNG is self-hosted LAN service; snippets never stored, only passed to LLM | +| T-07-03 | Denial of Service | POST /api/research/trigger | mitigate | Trigger fires goroutine but RunOnce is bounded per item; no queuing needed for MVP rate | +| T-07-04 | Spoofing | SearXNG base URL in config | accept | LAN-only service at fixed IP; no auth required by design | + + + +1. `go build ./...` passes with no errors +2. `go test ./internal/research/...` all pass +3. SearXNG integration (manual): `curl "http://10.5.0.129:8080/search?q=Intel+i350&format=json"` returns JSON +4. Trigger endpoint: `curl -X POST http://localhost:8080/api/research/trigger` returns 202 +5. Log line "research agent: starting, interval=10m0s" appears on server start + + + +- SearXNGClient.Search returns parsed []ai.SearchResult from live SearXNG instance +- ResearchAgent.RunOnce enriches needs_research items end-to-end: search → LLM → NetBox patch → status transition +- Research cycle runs every 10 minutes automatically and on demand via POST /api/research/trigger +- All queries sanitized before SearXNG dispatch +- go build clean, all new tests pass + + + +After completion, create `.planning/phases/07-research-agent-search/07-01-SUMMARY.md` + diff --git a/.planning/phases/07-research-agent-search/07-02-PLAN.md b/.planning/phases/07-research-agent-search/07-02-PLAN.md new file mode 100644 index 0000000..def4997 --- /dev/null +++ b/.planning/phases/07-research-agent-search/07-02-PLAN.md @@ -0,0 +1,302 @@ +--- +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` +