- 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
88 lines
2.3 KiB
Go
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
|
|
}
|