thread-master/apps/backend/internal/module/search/exa.go

196 lines
4.2 KiB
Go

package search
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const defaultBaseURL = "https://api.exa.ai/search"
type Hit struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
}
type Client interface {
Search(ctx context.Context, apiKey, query string, limit int) ([]Hit, error)
}
// FakeClient for tests / no platform key.
type FakeClient struct {
Fail bool
}
func (f *FakeClient) Search(_ context.Context, apiKey, query string, limit int) ([]Hit, error) {
if f.Fail || apiKey == "" {
return nil, fmt.Errorf("search failed: no key")
}
if limit <= 0 {
limit = 3
}
var hits []Hit
for i := 0; i < limit; i++ {
hits = append(hits, Hit{
Title: fmt.Sprintf("Result %d for %s", i+1, strings.TrimSpace(query)),
URL: fmt.Sprintf("https://example.com/r/%d", i+1),
Snippet: "fake exa snippet",
})
}
return hits, nil
}
// ExaClient calls api.exa.ai (real web search).
type ExaClient struct {
BaseURL string
HTTP *http.Client
// IncludeDomains optional host filter (e.g. threads.net).
IncludeDomains []string
}
// NewExa creates a ready HTTP client.
func NewExa() *ExaClient {
return &ExaClient{
BaseURL: defaultBaseURL,
HTTP: &http.Client{Timeout: 25 * time.Second},
}
}
// NewExaThreads prefers results from Threads domains.
func NewExaThreads() *ExaClient {
c := NewExa()
c.IncludeDomains = []string{"threads.net", "threads.com"}
return c
}
func (c *ExaClient) Search(ctx context.Context, apiKey, query string, limit int) ([]Hit, error) {
apiKey = strings.TrimSpace(apiKey)
query = strings.TrimSpace(query)
if apiKey == "" || strings.HasPrefix(apiKey, "fake") {
return nil, fmt.Errorf("search failed: no key")
}
if query == "" {
return nil, fmt.Errorf("search query required")
}
if limit <= 0 {
limit = 5
}
if limit > 20 {
limit = 20
}
base := c.BaseURL
if base == "" {
base = defaultBaseURL
}
httpClient := c.HTTP
if httpClient == nil {
httpClient = &http.Client{Timeout: 25 * time.Second}
}
body := map[string]any{
"query": query,
"type": "auto",
"numResults": limit,
"userLocation": "TW",
"contents": map[string]any{
"highlights": true,
"text": map[string]any{"maxCharacters": 400},
},
}
if len(c.IncludeDomains) > 0 {
body["includeDomains"] = c.IncludeDomains
}
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", apiKey)
res, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("exa status %d: %s", res.StatusCode, truncateRunes(string(raw), 120))
}
var parsed struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Highlights []string `json:"highlights"`
Text string `json:"text"`
Author string `json:"author"`
} `json:"results"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, err
}
out := make([]Hit, 0, len(parsed.Results))
for _, item := range parsed.Results {
u := strings.TrimSpace(item.URL)
if u == "" {
continue
}
snippet := firstNonEmpty(item.Highlights...)
if snippet == "" {
snippet = strings.TrimSpace(item.Text)
}
if snippet == "" {
snippet = strings.TrimSpace(item.Title)
}
title := strings.TrimSpace(item.Title)
if title == "" {
title = strings.TrimSpace(item.Author)
}
if title == "" {
title = u
}
out = append(out, Hit{
Title: title,
URL: u,
Snippet: truncateRunes(snippet, 200),
})
if len(out) >= limit {
break
}
}
return out, nil
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if t := strings.TrimSpace(s); t != "" {
return t
}
}
return ""
}
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}