feat(07-02): SearchHandler — NL query to NetBox filter with Tier 1 LLM

- 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
This commit is contained in:
Mikkel Georgsen 2026-04-10 07:55:07 +00:00
parent cbe411996f
commit 9db7707a64
4 changed files with 337 additions and 1 deletions

View file

@ -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)

View file

@ -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=<natural language query>
// 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
}

View file

@ -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))
}
}

View file

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