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 }