feat(07-01): SearXNG client, ListDevicesWithStatus, SearXNGURL config

- internal/research/searxng.go: SearXNGClient implementing ai.ResearchClient
- internal/research/searxng_test.go: httptest mock server tests (4 pass)
- internal/netbox/client.go: ListDevicesWithStatus client-side filter
- internal/config/config.go: SearXNGURL field with default + env binding
This commit is contained in:
Mikkel Georgsen 2026-04-10 07:48:22 +00:00
parent 34e0803661
commit 30cd279f49
4 changed files with 221 additions and 0 deletions

View file

@ -29,6 +29,8 @@ type Config struct {
NetBoxDefaultSiteID int32 `mapstructure:"netbox_default_site_id"`
AI ai.AIConfig `mapstructure:"ai"`
SearXNGURL string `mapstructure:"searxng_url"`
}
func Load() (*Config, error) {
@ -65,6 +67,7 @@ func Load() (*Config, error) {
v.SetDefault("ai.confidence_threshold", 0.75)
v.SetDefault("ai.quick_add_enabled", false)
v.SetDefault("ai.quick_add_threshold", 0.90)
v.SetDefault("searxng_url", "http://10.5.0.129:8080")
// Config file
v.SetConfigName("config")
@ -103,6 +106,7 @@ func Load() (*Config, error) {
_ = v.BindEnv("ai.tier3.model", "HWLAB_AI_TIER3_MODEL")
_ = v.BindEnv("ai.confidence_threshold", "HWLAB_AI_CONFIDENCE_THRESHOLD")
_ = v.BindEnv("ai.quick_add_enabled", "HWLAB_AI_QUICK_ADD_ENABLED")
_ = v.BindEnv("searxng_url", "HWLAB_SEARXNG_URL")
// Read primary config file (non-fatal if missing)
if err := v.ReadInConfig(); err != nil {

View file

@ -122,6 +122,24 @@ func (c *Client) CreateCable(ctx context.Context, label, assetTag, testDataJSON
return int64(result.GetId()), nil
}
// ListDevicesWithStatus returns devices whose catalog_status custom field equals status.
// Uses client-side filtering (up to 200 devices) since go-netbox v4 custom field
// query param support is schema-dependent.
func (c *Client) ListDevicesWithStatus(ctx context.Context, status string) ([]Device, error) {
res, _, err := c.api.DcimAPI.DcimDevicesList(ctx).Limit(200).Execute()
if err != nil {
return nil, fmt.Errorf("list devices for status %q: %w", status, err)
}
devices := make([]Device, 0)
for _, d := range res.Results {
dev := deviceFromNetBox(d)
if dev.CustomFields.CatalogStatus == status {
devices = append(devices, dev)
}
}
return devices, nil
}
// DeleteDevice removes a device from NetBox by its internal ID.
// Used primarily for test cleanup after CreateDevice integration tests.
func (c *Client) DeleteDevice(ctx context.Context, id int64) error {

View file

@ -0,0 +1,88 @@
// Package research provides the SearXNG HTTP search client and the ResearchAgent
// background worker that enriches needs_research hardware records.
package research
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"encoding/json"
"git.georgsen.dk/hwlab/internal/ai"
)
const defaultSearXNGURL = "http://10.5.0.129:8080"
// searxngResponse is the parsed JSON body returned by SearXNG.
// SearXNG uses "content" for the text snippet, not "snippet".
type searxngResponse struct {
Results []searxngResult `json:"results"`
}
type searxngResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
}
// SearXNGClient implements ai.ResearchClient using a self-hosted SearXNG instance.
type SearXNGClient struct {
baseURL string
httpClient *http.Client
}
// NewSearXNGClient creates a SearXNGClient. If baseURL is empty the default LAN
// address (http://10.5.0.129:8080) is used.
func NewSearXNGClient(baseURL string) *SearXNGClient {
if baseURL == "" {
baseURL = defaultSearXNGURL
}
return &SearXNGClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
// Search executes a GET {baseURL}/search?q={query}&format=json and returns parsed results.
// An HTTP non-2xx response is returned as an error. An empty results array is not an error.
func (c *SearXNGClient) Search(ctx context.Context, query string) ([]ai.SearchResult, error) {
params := url.Values{}
params.Set("q", query)
params.Set("format", "json")
reqURL := c.baseURL + "/search?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("searxng: build request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("searxng: http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("searxng: unexpected status %d", resp.StatusCode)
}
var body searxngResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("searxng: decode response: %w", err)
}
results := make([]ai.SearchResult, 0, len(body.Results))
for _, r := range body.Results {
results = append(results, ai.SearchResult{
Title: r.Title,
URL: r.URL,
Snippet: r.Content,
})
}
return results, nil
}

View file

@ -0,0 +1,111 @@
package research_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.georgsen.dk/hwlab/internal/research"
)
func TestSearXNGSearch_ValidResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("format") != "json" {
t.Errorf("expected format=json, got %s", r.URL.Query().Get("format"))
}
if r.URL.Query().Get("q") == "" {
t.Error("expected non-empty q param")
}
resp := map[string]interface{}{
"results": []map[string]interface{}{
{"title": "Intel i350 NIC", "url": "https://ark.intel.com/i350", "content": "Quad-port Gigabit Ethernet adapter"},
{"title": "Intel i350 Datasheet", "url": "https://intel.com/datasheet", "content": "Technical specs for i350 series"},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
client := research.NewSearXNGClient(srv.URL)
results, err := client.Search(context.Background(), "Intel NIC i350")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].Title != "Intel i350 NIC" {
t.Errorf("unexpected title: %s", results[0].Title)
}
if results[0].URL != "https://ark.intel.com/i350" {
t.Errorf("unexpected URL: %s", results[0].URL)
}
if results[0].Snippet != "Quad-port Gigabit Ethernet adapter" {
t.Errorf("unexpected snippet (content): %s", results[0].Snippet)
}
}
func TestSearXNGSearch_HTTP500(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
client := research.NewSearXNGClient(srv.URL)
_, err := client.Search(context.Background(), "test query")
if err == nil {
t.Fatal("expected error for HTTP 500, got nil")
}
}
func TestSearXNGSearch_EmptyResults(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{"results": []interface{}{}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
client := research.NewSearXNGClient(srv.URL)
results, err := client.Search(context.Background(), "something obscure")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
}
func TestSearXNGSearch_QueryEncoding(t *testing.T) {
var capturedQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.Query().Get("q")
resp := map[string]interface{}{"results": []interface{}{}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
client := research.NewSearXNGClient(srv.URL)
_, err := client.Search(context.Background(), "Intel NIC i350 2.5G")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedQuery != "Intel NIC i350 2.5G" {
t.Errorf("unexpected decoded query: %q", capturedQuery)
}
}
func TestNewSearXNGClient_DefaultURL(t *testing.T) {
// Empty baseURL should use the default LAN address
client := research.NewSearXNGClient("")
if client == nil {
t.Fatal("expected non-nil client")
}
}