- 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
187 lines
5.6 KiB
Go
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
|
|
}
|