homelabby/internal/research/searxng.go
Mikkel Georgsen 30cd279f49 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
2026-04-10 07:48:22 +00:00

88 lines
2.3 KiB
Go

// 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
}