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