homelabby/internal/api/handlers/search.go
Mikkel Georgsen 9db7707a64 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
2026-04-10 07:55:07 +00:00

187 lines
5.6 KiB
Go

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
}