yao/agent/search/handlers/web/serper.go
Max e4d1701dab Add Search API configuration and enhance documentation
- Introduced new environment variables for TAVILY_API_KEY, SERPAPI_API_KEY, and SERPER_API_KEY in both `pr-test.yml` and `unit-test.yml` workflows to support additional search providers.
- Updated `DESIGN.md` to reflect the inclusion of SerpAPI as a search provider, detailing its usage and configuration options, including support for multiple search engines.
- Enhanced the `builtinSearch` function in `handler.go` to accommodate the new SerpAPI provider, ensuring proper handling of search requests.
- Revised the `WebConfig` struct in `config.go` to include an `Engine` field for specifying the search engine when using SerpAPI, improving flexibility in search configurations.
- Updated documentation to clarify the roles of new search providers and their integration within the search module, ensuring comprehensive guidance for developers.
2025-12-13 12:37:07 +08:00

280 lines
7.2 KiB
Go

package web
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/yaoapp/yao/agent/search/types"
)
const (
serperAPIURL = "https://google.serper.dev/search"
serperAPITimeout = 30 * time.Second
)
// SerperProvider implements web search using Serper API (serper.dev)
type SerperProvider struct {
apiKey string
maxResults int
}
// NewSerperProvider creates a new Serper provider
func NewSerperProvider(cfg *types.WebConfig) *SerperProvider {
apiKey := ""
if cfg != nil && cfg.APIKeyEnv != "" {
// Support both "$ENV.VAR_NAME" and "VAR_NAME" formats
envName := cfg.APIKeyEnv
if len(envName) > 5 && envName[:5] == "$ENV." {
envName = envName[5:]
}
apiKey = os.Getenv(envName)
}
maxResults := 10
if cfg != nil && cfg.MaxResults > 0 {
maxResults = cfg.MaxResults
}
return &SerperProvider{
apiKey: apiKey,
maxResults: maxResults,
}
}
// serperRequest represents the request body for Serper API
type serperRequest struct {
Q string `json:"q"` // Search query
Num int `json:"num,omitempty"` // Number of results (default: 10, max: 100)
GL string `json:"gl,omitempty"` // Country code (e.g., "us", "cn")
HL string `json:"hl,omitempty"` // Language code (e.g., "en", "zh-cn")
TBS string `json:"tbs,omitempty"` // Time-based search (qdr:h, qdr:d, qdr:w, qdr:m, qdr:y)
Page int `json:"page,omitempty"` // Page number (default: 1)
AutoCor bool `json:"autocorrect"` // Auto-correct spelling
}
// serperResponse represents the response from Serper API
type serperResponse struct {
SearchParameters serperSearchParams `json:"searchParameters"`
Organic []serperResult `json:"organic"`
AnswerBox *serperAnswerBox `json:"answerBox,omitempty"`
KnowledgeGraph *serperKnowledge `json:"knowledgeGraph,omitempty"`
RelatedSearches []serperRelated `json:"relatedSearches,omitempty"`
}
// serperSearchParams contains search parameters from response
type serperSearchParams struct {
Q string `json:"q"`
Type string `json:"type"`
GL string `json:"gl"`
HL string `json:"hl"`
Num int `json:"num"`
}
// serperResult represents a single organic search result
type serperResult struct {
Title string `json:"title"`
Link string `json:"link"`
Snippet string `json:"snippet"`
Position int `json:"position"`
Date string `json:"date,omitempty"`
}
// serperAnswerBox represents the answer box (featured snippet)
type serperAnswerBox struct {
Title string `json:"title,omitempty"`
Snippet string `json:"snippet,omitempty"`
Link string `json:"link,omitempty"`
}
// serperKnowledge represents knowledge graph data
type serperKnowledge struct {
Title string `json:"title,omitempty"`
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
}
// serperRelated represents related searches
type serperRelated struct {
Query string `json:"query"`
}
// Search executes a web search using Serper API
func (p *SerperProvider) Search(req *types.Request) (*types.Result, error) {
startTime := time.Now()
// Validate API key
if p.apiKey == "" {
return &types.Result{
Type: types.SearchTypeWeb,
Query: req.Query,
Source: req.Source,
Items: []*types.ResultItem{},
Total: 0,
Error: "Serper API key not configured",
}, nil
}
// Determine max results
maxResults := p.maxResults
if req.Limit > 0 {
maxResults = req.Limit
}
// Build search query with site restrictions if specified
query := req.Query
if len(req.Sites) > 0 {
// Serper uses "site:domain" syntax in query
if len(req.Sites) == 1 {
query = "site:" + req.Sites[0] + " " + req.Query
} else {
// Multiple sites: (site:domain1 OR site:domain2) query
siteQuery := ""
for i, site := range req.Sites {
if i > 0 {
siteQuery += " OR "
}
siteQuery += "site:" + site
}
query = "(" + siteQuery + ") " + req.Query
}
}
// Build request body
serperReq := serperRequest{
Q: query,
Num: maxResults,
AutoCor: true,
}
// Add time range if specified
if req.TimeRange != "" {
serperReq.TBS = convertSerperTimeRange(req.TimeRange)
}
// Execute API call
serperResp, err := p.callAPI(&serperReq)
if err != nil {
return &types.Result{
Type: types.SearchTypeWeb,
Query: req.Query,
Source: req.Source,
Items: []*types.ResultItem{},
Total: 0,
Duration: time.Since(startTime).Milliseconds(),
Error: fmt.Sprintf("Serper API error: %v", err),
}, nil
}
// Convert results
items := make([]*types.ResultItem, 0, len(serperResp.Organic))
// Add answer box as first result if available
if serperResp.AnswerBox != nil && serperResp.AnswerBox.Snippet != "" {
items = append(items, &types.ResultItem{
Type: types.SearchTypeWeb,
Title: serperResp.AnswerBox.Title,
Content: serperResp.AnswerBox.Snippet,
URL: serperResp.AnswerBox.Link,
Score: 1.0, // Featured snippet gets highest score
Source: req.Source,
Metadata: map[string]interface{}{
"type": "answer_box",
},
})
}
// Add organic results
for _, r := range serperResp.Organic {
// Calculate score based on position (1st = 0.95, 2nd = 0.90, etc.)
score := 1.0 - float64(r.Position)*0.05
if score < 0.1 {
score = 0.1
}
items = append(items, &types.ResultItem{
Type: types.SearchTypeWeb,
Title: r.Title,
Content: r.Snippet,
URL: r.Link,
Score: score,
Source: req.Source,
})
}
return &types.Result{
Type: types.SearchTypeWeb,
Query: req.Query,
Source: req.Source,
Items: items,
Total: len(items),
Duration: time.Since(startTime).Milliseconds(),
}, nil
}
// callAPI makes the HTTP POST request to Serper API
func (p *SerperProvider) callAPI(req *serperRequest) (*serperResponse, error) {
// Serialize request body
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Create HTTP request
httpReq, err := http.NewRequest(http.MethodPost, serperAPIURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-API-KEY", p.apiKey)
// Execute request
client := &http.Client{Timeout: serperAPITimeout}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Check status code
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody))
}
// Parse response
var serperResp serperResponse
if err := json.Unmarshal(respBody, &serperResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &serperResp, nil
}
// convertSerperTimeRange converts time range to Serper tbs format
func convertSerperTimeRange(timeRange string) string {
switch timeRange {
case "hour":
return "qdr:h"
case "day":
return "qdr:d"
case "week":
return "qdr:w"
case "month":
return "qdr:m"
case "year":
return "qdr:y"
default:
return ""
}
}