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:
parent
34e0803661
commit
30cd279f49
4 changed files with 221 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
88
internal/research/searxng.go
Normal file
88
internal/research/searxng.go
Normal 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
|
||||
}
|
||||
111
internal/research/searxng_test.go
Normal file
111
internal/research/searxng_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue