From 9db7707a644c5d9d064d617059c2912b09f73866 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 07:55:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(07-02):=20SearchHandler=20=E2=80=94=20NL?= =?UTF-8?q?=20query=20to=20NetBox=20filter=20with=20Tier=201=20LLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/api/handlers/search.go: SearchHandler, NewSearchHandler, SearchDevices - Sanitizes query (non-printable stripped, 200 char max) per T-07-05 - LLM extracts catalog_status/name_contains/tag; falls back to substring on parse failure - internal/api/handlers/search_test.go: 4 tests covering 400, fallback, status filter, combined - internal/api/router.go: wires GET /api/search with nil guard (503) - cmd/hwlab/main.go: constructs searchHandler and passes to NewRouter --- cmd/hwlab/main.go | 3 +- internal/api/handlers/search.go | 187 +++++++++++++++++++++++++++ internal/api/handlers/search_test.go | 138 ++++++++++++++++++++ internal/api/router.go | 10 ++ 4 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 internal/api/handlers/search.go create mode 100644 internal/api/handlers/search_test.go diff --git a/cmd/hwlab/main.go b/cmd/hwlab/main.go index 5e9ee56..269d34f 100644 --- a/cmd/hwlab/main.go +++ b/cmd/hwlab/main.go @@ -127,6 +127,7 @@ func main() { researchAgent := research.NewAgent(nbClient, searxngClient, tier2, catalogUpdater) go researchAgent.Start(ctx, 10*time.Minute) researchHandler := handlers.NewResearchHandler(researchAgent) + searchHandler := handlers.NewSearchHandler(nbClient, tier1) // Wire USB Manager events to cable tester driver when a RoleCableTester device connects. // Currently a no-op stub — wires the plumbing for Phase 5 hardware integration. @@ -141,7 +142,7 @@ func main() { } }() - router := api.NewRouter(staticFS, intakeHandler, inventoryHandler, labelHandler, usbEventsHandler, testHandler, advisorHandler, researchHandler) + router := api.NewRouter(staticFS, intakeHandler, inventoryHandler, labelHandler, usbEventsHandler, testHandler, advisorHandler, researchHandler, searchHandler) addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) log.Printf("HWLab starting on %s", addr) diff --git a/internal/api/handlers/search.go b/internal/api/handlers/search.go new file mode 100644 index 0000000..ef4d812 --- /dev/null +++ b/internal/api/handlers/search.go @@ -0,0 +1,187 @@ +package handlers + +import ( + "context" + "encoding/json" + "log" + "net/http" + "strings" + "unicode" + + "git.georgsen.dk/hwlab/internal/netbox" +) + +// SearchNetBoxClient is the narrow interface the search handler needs. +type SearchNetBoxClient interface { + ListDevices(ctx context.Context, limit int) ([]netbox.Device, error) +} + +// SearchAIClient is the narrow interface for NL→filter translation. +type SearchAIClient interface { + TextComplete(ctx context.Context, prompt string) (string, error) +} + +// nlFilter holds the parsed output from the LLM (T-07-06: only known fields accepted). +type nlFilter struct { + CatalogStatus string `json:"catalog_status"` + NameContains string `json:"name_contains"` + Tag string `json:"tag"` +} + +// SearchHandler handles GET /api/search?q=... +type SearchHandler struct { + nbClient SearchNetBoxClient + tier1 SearchAIClient +} + +// NewSearchHandler creates a SearchHandler. +func NewSearchHandler(nb SearchNetBoxClient, tier1 SearchAIClient) *SearchHandler { + return &SearchHandler{nbClient: nb, tier1: tier1} +} + +// SearchDevices handles GET /api/search?q= +// It translates the query to NetBox filter params via Tier 1 LLM, then fetches +// and filters devices accordingly. +func (h *SearchHandler) SearchDevices(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + q := r.URL.Query().Get("q") + if strings.TrimSpace(q) == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "q parameter required"}) + return + } + + // Sanitize: strip non-printable chars, truncate to 200 chars (T-07-05). + q = sanitizeQuery(q) + + // Translate NL query to structured filter via Tier 1 LLM. + filter := h.parseFilter(r.Context(), q) + + // Fetch up to 200 devices from NetBox. + devices, err := h.nbClient.ListDevices(r.Context(), 200) + if err != nil { + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "netbox unavailable: " + err.Error()}) + return + } + + // Apply filters in-memory and serialize. + results := make([]map[string]interface{}, 0) + for _, d := range devices { + if !matchesFilter(d, filter) { + continue + } + results = append(results, deviceToSearchResponse(d)) + } + + _ = json.NewEncoder(w).Encode(results) +} + +// parseFilter calls the LLM and parses its JSON output into an nlFilter. +// On any failure, falls back to a name substring match on the raw query. +func (h *SearchHandler) parseFilter(ctx context.Context, q string) nlFilter { + resp, err := h.tier1.TextComplete(ctx, nlFilterPrompt(q)) + if err != nil { + log.Printf("search: LLM TextComplete error: %v — falling back to substring match", err) + return nlFilter{NameContains: q} + } + + // Extract JSON from response (LLM may wrap JSON in markdown code fences). + jsonStr := extractJSON(resp) + + var f nlFilter + if err := json.Unmarshal([]byte(jsonStr), &f); err != nil { + log.Printf("search: LLM JSON parse failed: %v — falling back to substring match (raw: %.200s)", err, resp) + return nlFilter{NameContains: q} + } + + if f.Tag != "" { + log.Printf("search: tag filter %q not implemented for MVP — ignoring", f.Tag) + f.Tag = "" + } + + return f +} + +// matchesFilter returns true if device d satisfies all active filter criteria. +func matchesFilter(d netbox.Device, f nlFilter) bool { + // catalog_status: exact match (case-insensitive) when set (T-07-06). + if f.CatalogStatus != "" { + if !strings.EqualFold(d.CustomFields.CatalogStatus, f.CatalogStatus) { + return false + } + } + // name_contains: case-insensitive substring match when set. + if f.NameContains != "" { + if !strings.Contains(strings.ToLower(d.Name), strings.ToLower(f.NameContains)) { + return false + } + } + return true +} + +// deviceToSearchResponse converts a netbox.Device to the InventoryItem JSON shape. +func deviceToSearchResponse(d netbox.Device) map[string]interface{} { + cf := d.CustomFields + urls := cf.PhotoURLs + if urls == nil { + urls = []string{} + } + var assetTag interface{} = nil + if d.AssetTag != "" { + assetTag = d.AssetTag + } + return map[string]interface{}{ + "id": d.ID, + "name": d.Name, + "asset_tag": assetTag, + "hw_id": nilIfEmpty(cf.HWID), + "catalog_status": nilIfEmpty(cf.CatalogStatus), + "product_url": nilIfEmpty(cf.ProductURL), + "firmware_version": nilIfEmpty(cf.FirmwareVersion), + "test_date": nilIfEmpty(cf.TestDate), + "test_data": nilIfEmpty(cf.TestData), + "ai_notes": nilIfEmpty(cf.AINotes), + "photo_urls": urls, + } +} + +func nilIfEmpty(s string) interface{} { + if s == "" { + return nil + } + return s +} + +// nlFilterPrompt builds the LLM extraction prompt for a given user query. +func nlFilterPrompt(q string) string { + return `Extract NetBox filter parameters from this inventory search query. Return JSON only: {"catalog_status": "...", "name_contains": "...", "tag": "..."}. All fields optional. Use empty string for fields that don't apply. Valid catalog_status values: draft, indexed, needs_research, researched, complete, available. Query: ` + q +} + +// sanitizeQuery strips non-printable characters and truncates to 200 chars. +func sanitizeQuery(q string) string { + var sb strings.Builder + for _, r := range q { + if unicode.IsPrint(r) { + sb.WriteRune(r) + } + } + result := sb.String() + runes := []rune(result) + if len(runes) > 200 { + result = string(runes[:200]) + } + return strings.TrimSpace(result) +} + +// extractJSON attempts to extract a JSON object from a string that may contain +// markdown code fences or surrounding text. +func extractJSON(s string) string { + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start >= 0 && end > start { + return s[start : end+1] + } + return s +} diff --git a/internal/api/handlers/search_test.go b/internal/api/handlers/search_test.go new file mode 100644 index 0000000..f71b0b7 --- /dev/null +++ b/internal/api/handlers/search_test.go @@ -0,0 +1,138 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.georgsen.dk/hwlab/internal/api/handlers" + "git.georgsen.dk/hwlab/internal/netbox" +) + +// mockNetBoxClient satisfies the SearchNetBoxClient interface. +type mockNetBoxClient struct { + devices []netbox.Device + err error +} + +func (m *mockNetBoxClient) ListDevices(_ context.Context, _ int) ([]netbox.Device, error) { + return m.devices, m.err +} + +// mockAIClient returns canned JSON for TextComplete. +type mockAIClient struct { + response string + err error +} + +func (m *mockAIClient) TextComplete(_ context.Context, _ string) (string, error) { + return m.response, m.err +} + +func testDevices() []netbox.Device { + return []netbox.Device{ + {ID: 1, Name: "10GbE NIC Intel X550", CustomFields: netbox.CustomFields{CatalogStatus: "available", HWID: "HW-0001"}}, + {ID: 2, Name: "Raspberry Pi 4B", CustomFields: netbox.CustomFields{CatalogStatus: "draft", HWID: "HW-0002"}}, + {ID: 3, Name: "10GbE NIC Mellanox", CustomFields: netbox.CustomFields{CatalogStatus: "complete", HWID: "HW-0003"}}, + {ID: 4, Name: "USB-C Hub", CustomFields: netbox.CustomFields{CatalogStatus: "available", HWID: "HW-0004"}}, + } +} + +// TestSearch_EmptyQ verifies that missing q parameter returns 400. +func TestSearch_EmptyQ(t *testing.T) { + h := handlers.NewSearchHandler(&mockNetBoxClient{}, &mockAIClient{}) + req := httptest.NewRequest(http.MethodGet, "/api/search", nil) + w := httptest.NewRecorder() + h.SearchDevices(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + var body map[string]string + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if !strings.Contains(body["error"], "q parameter") { + t.Errorf("expected error about q parameter, got %q", body["error"]) + } +} + +// TestSearch_LLMParseFallback verifies that when LLM returns unparseable JSON, +// the handler falls back to name substring match against the raw query. +func TestSearch_LLMParseFallback(t *testing.T) { + nb := &mockNetBoxClient{devices: testDevices()} + ai := &mockAIClient{response: "not valid json {{ broken"} + h := handlers.NewSearchHandler(nb, ai) + + req := httptest.NewRequest(http.MethodGet, "/api/search?q=NIC", nil) + w := httptest.NewRecorder() + h.SearchDevices(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var items []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&items); err != nil { + t.Fatalf("decode: %v", err) + } + // "NIC" substring matches devices 1 and 3 (both contain "NIC") + if len(items) != 2 { + t.Errorf("expected 2 NIC results, got %d: %v", len(items), items) + } +} + +// TestSearch_CatalogStatusFilter verifies that LLM-extracted catalog_status filters results. +func TestSearch_CatalogStatusFilter(t *testing.T) { + nb := &mockNetBoxClient{devices: testDevices()} + aiResp := `{"catalog_status": "available", "name_contains": "", "tag": ""}` + ai := &mockAIClient{response: aiResp} + h := handlers.NewSearchHandler(nb, ai) + + req := httptest.NewRequest(http.MethodGet, "/api/search?q=show+me+available+items", nil) + w := httptest.NewRecorder() + h.SearchDevices(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var items []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&items); err != nil { + t.Fatalf("decode: %v", err) + } + // Devices 1 and 4 have catalog_status "available" + if len(items) != 2 { + t.Errorf("expected 2 available items, got %d", len(items)) + } + for _, item := range items { + if item["catalog_status"] != "available" { + t.Errorf("unexpected catalog_status: %v", item["catalog_status"]) + } + } +} + +// TestSearch_NameContainsAndStatus verifies combined filtering works. +func TestSearch_NameContainsAndStatus(t *testing.T) { + nb := &mockNetBoxClient{devices: testDevices()} + aiResp := `{"catalog_status": "available", "name_contains": "NIC", "tag": ""}` + ai := &mockAIClient{response: aiResp} + h := handlers.NewSearchHandler(nb, ai) + + req := httptest.NewRequest(http.MethodGet, "/api/search?q=available+NICs", nil) + w := httptest.NewRecorder() + h.SearchDevices(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var items []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&items); err != nil { + t.Fatalf("decode: %v", err) + } + // Only device 1: NIC + available (device 3 is "complete") + if len(items) != 1 { + t.Errorf("expected 1 result, got %d", len(items)) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index a169298..f74b0f7 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -40,6 +40,7 @@ func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // testHandler handles POST /api/test/cable, GET /api/test/events, GET /api/test/recent. // advisorHandler handles POST /api/advisor/chat, GET /api/advisor/conversations, GET /api/advisor/conversations/{id}. // researchHandler handles POST /api/research/trigger. +// searchHandler handles GET /api/search?q=... func NewRouter( staticFiles fs.FS, intakeHandler http.Handler, @@ -49,6 +50,7 @@ func NewRouter( testHandler *handlers.TestHandler, advisorHandler *advisor.AdvisorHandler, researchHandler *handlers.ResearchHandler, + searchHandler *handlers.SearchHandler, ) http.Handler { r := chi.NewRouter() r.Use(middleware.Logger) @@ -90,6 +92,14 @@ func NewRouter( }) } }) + + if searchHandler != nil { + r.Get("/search", searchHandler.SearchDevices) + } else { + r.Get("/search", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "search unavailable", http.StatusServiceUnavailable) + }) + } }) // SPA fallback — serve static files; unknown paths fall back to index.html.