From 30cd279f49d229cda8db3d56211ceb04a0acf32c Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 07:48:22 +0000 Subject: [PATCH] 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 --- internal/config/config.go | 4 ++ internal/netbox/client.go | 18 +++++ internal/research/searxng.go | 88 +++++++++++++++++++++++ internal/research/searxng_test.go | 111 ++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 internal/research/searxng.go create mode 100644 internal/research/searxng_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 45a848a..90559ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/netbox/client.go b/internal/netbox/client.go index 7d8c575..c125347 100644 --- a/internal/netbox/client.go +++ b/internal/netbox/client.go @@ -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 { diff --git a/internal/research/searxng.go b/internal/research/searxng.go new file mode 100644 index 0000000..fa5d6c7 --- /dev/null +++ b/internal/research/searxng.go @@ -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 +} diff --git a/internal/research/searxng_test.go b/internal/research/searxng_test.go new file mode 100644 index 0000000..aba4a71 --- /dev/null +++ b/internal/research/searxng_test.go @@ -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") + } +}