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:
parent
cbe411996f
commit
9db7707a64
4 changed files with 337 additions and 1 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
187
internal/api/handlers/search.go
Normal file
187
internal/api/handlers/search.go
Normal 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
|
||||
}
|
||||
138
internal/api/handlers/search_test.go
Normal file
138
internal/api/handlers/search_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue