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.
This commit is contained in:
parent
f8ba875cd3
commit
e4d1701dab
11 changed files with 1859 additions and 15 deletions
5
.github/workflows/pr-test.yml
vendored
5
.github/workflows/pr-test.yml
vendored
|
|
@ -49,6 +49,11 @@ env:
|
||||||
DEEPSEEK_MODELS_V3: ${{ secrets.DEEPSEEK_MODELS_V3 }}
|
DEEPSEEK_MODELS_V3: ${{ secrets.DEEPSEEK_MODELS_V3 }}
|
||||||
DEEPSEEK_MODELS_V3_1: ${{ secrets.DEEPSEEK_MODELS_V3_1 }}
|
DEEPSEEK_MODELS_V3_1: ${{ secrets.DEEPSEEK_MODELS_V3_1 }}
|
||||||
|
|
||||||
|
# Search API Configuration
|
||||||
|
TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
|
||||||
|
SERPAPI_API_KEY: ${{ secrets.SERPAPI_API_KEY }}
|
||||||
|
SERPER_API_KEY: ${{ secrets.SERPER_API_KEY }}
|
||||||
|
|
||||||
# Claude API Configuration
|
# Claude API Configuration
|
||||||
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }}
|
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }}
|
||||||
CLAUDE_PROXY: ${{ secrets.CLAUDE_PROXY }}
|
CLAUDE_PROXY: ${{ secrets.CLAUDE_PROXY }}
|
||||||
|
|
|
||||||
5
.github/workflows/unit-test.yml
vendored
5
.github/workflows/unit-test.yml
vendored
|
|
@ -53,6 +53,11 @@ env:
|
||||||
DEEPSEEK_MODELS_V3: ${{ secrets.DEEPSEEK_MODELS_V3 }}
|
DEEPSEEK_MODELS_V3: ${{ secrets.DEEPSEEK_MODELS_V3 }}
|
||||||
DEEPSEEK_MODELS_V3_1: ${{ secrets.DEEPSEEK_MODELS_V3_1 }}
|
DEEPSEEK_MODELS_V3_1: ${{ secrets.DEEPSEEK_MODELS_V3_1 }}
|
||||||
|
|
||||||
|
# Search API Configuration
|
||||||
|
TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
|
||||||
|
SERPAPI_API_KEY: ${{ secrets.SERPAPI_API_KEY }}
|
||||||
|
SERPER_API_KEY: ${{ secrets.SERPER_API_KEY }}
|
||||||
|
|
||||||
# Claude API Configuration
|
# Claude API Configuration
|
||||||
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }}
|
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }}
|
||||||
CLAUDE_PROXY: ${{ secrets.CLAUDE_PROXY }}
|
CLAUDE_PROXY: ${{ secrets.CLAUDE_PROXY }}
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,8 @@ agent/search/
|
||||||
│ ├── web/ # Web search
|
│ ├── web/ # Web search
|
||||||
│ │ ├── handler.go # Web search handler (mode dispatch)
|
│ │ ├── handler.go # Web search handler (mode dispatch)
|
||||||
│ │ ├── tavily.go # Tavily provider (builtin)
|
│ │ ├── tavily.go # Tavily provider (builtin)
|
||||||
│ │ ├── serper.go # Serper provider (builtin)
|
│ │ ├── serper.go # Serper provider (serper.dev, builtin)
|
||||||
|
│ │ ├── serpapi.go # SerpAPI provider (serpapi.com, multi-engine, builtin)
|
||||||
│ │ ├── agent.go # Agent mode (AI Search)
|
│ │ ├── agent.go # Agent mode (AI Search)
|
||||||
│ │ └── mcp.go # MCP mode (external service)
|
│ │ └── mcp.go # MCP mode (external service)
|
||||||
│ │
|
│ │
|
||||||
|
|
@ -663,9 +664,10 @@ type Config struct {
|
||||||
// Note: uses.web determines the mode (builtin/agent/mcp)
|
// Note: uses.web determines the mode (builtin/agent/mcp)
|
||||||
// Provider is only used when uses.web = "builtin"
|
// Provider is only used when uses.web = "builtin"
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Provider string `json:"provider,omitempty"` // "tavily" or "serper" (for builtin mode)
|
Provider string `json:"provider,omitempty"` // "tavily", "serper", or "serpapi" (for builtin mode)
|
||||||
APIKeyEnv string `json:"api_key_env,omitempty"` // Environment variable for API key
|
APIKeyEnv string `json:"api_key_env,omitempty"` // Environment variable for API key
|
||||||
MaxResults int `json:"max_results,omitempty"` // Max results (default: 10)
|
MaxResults int `json:"max_results,omitempty"` // Max results (default: 10)
|
||||||
|
Engine string `json:"engine,omitempty"` // Search engine for SerpAPI: "google", "bing", "baidu", etc. (default: "google")
|
||||||
}
|
}
|
||||||
|
|
||||||
// KBConfig for knowledge base search settings
|
// KBConfig for knowledge base search settings
|
||||||
|
|
@ -1299,9 +1301,10 @@ func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
|
||||||
|
|
||||||
# Web search settings
|
# Web search settings
|
||||||
web:
|
web:
|
||||||
provider: "tavily" # "tavily", "serper" (builtin providers only)
|
provider: "tavily" # "tavily", "serper", or "serpapi" (builtin providers only)
|
||||||
api_key_env: "TAVILY_API_KEY"
|
api_key_env: "TAVILY_API_KEY"
|
||||||
max_results: 10
|
max_results: 10
|
||||||
|
# engine: "google" # For SerpAPI only: "google", "bing", "baidu", "yandex", etc.
|
||||||
|
|
||||||
# Knowledge base search settings
|
# Knowledge base search settings
|
||||||
kb:
|
kb:
|
||||||
|
|
@ -1743,16 +1746,21 @@ func (h *Handler) Search(ctx *context.Context, req *types.Request) (*types.Resul
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// builtinSearch uses Tavily/Serper directly
|
// builtinSearch uses Tavily/Serper/SerpAPI directly
|
||||||
func (h *Handler) builtinSearch(ctx *context.Context, req *types.Request) (*types.Result, error) {
|
func (h *Handler) builtinSearch(ctx *context.Context, req *types.Request) (*types.Result, error) {
|
||||||
var provider Provider
|
|
||||||
switch h.config.Provider {
|
switch h.config.Provider {
|
||||||
case "tavily":
|
case "tavily":
|
||||||
provider = NewTavilyProvider(h.config)
|
return NewTavilyProvider(h.config).Search(req)
|
||||||
case "serper":
|
case "serper":
|
||||||
provider = NewSerperProvider(h.config)
|
// Serper (serper.dev) - POST request with X-API-KEY header
|
||||||
|
return NewSerperProvider(h.config).Search(req)
|
||||||
|
case "serpapi":
|
||||||
|
// SerpAPI (serpapi.com) - GET request with api_key parameter
|
||||||
|
// Supports multiple engines: google, bing, baidu, yandex, etc.
|
||||||
|
return NewSerpAPIProvider(h.config).Search(req)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown provider: %s", h.config.Provider)
|
||||||
}
|
}
|
||||||
return provider.Search(ctx, req)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// agentSearch delegates to an assistant for AI-powered search
|
// agentSearch delegates to an assistant for AI-powered search
|
||||||
|
|
@ -1780,10 +1788,40 @@ func (h *Handler) mcpSearch(ctx *context.Context, req *types.Request) (*types.Re
|
||||||
|
|
||||||
**Built-in Providers (when `uses.web = "builtin"`):**
|
**Built-in Providers (when `uses.web = "builtin"`):**
|
||||||
|
|
||||||
| Provider | File | Notes |
|
| Provider | File | Notes |
|
||||||
| -------- | ----------- | ------------------------------- |
|
| -------- | ------------ | ----------------------------------------------- |
|
||||||
| Tavily | `tavily.go` | Recommended for AI applications |
|
| Tavily | `tavily.go` | Recommended for AI applications |
|
||||||
| Serper | `serper.go` | Google search API |
|
| Serper | `serper.go` | Google search via serper.dev (POST + X-API-KEY) |
|
||||||
|
| SerpAPI | `serpapi.go` | Multi-engine search via serpapi.com (GET + URL) |
|
||||||
|
|
||||||
|
**SerpAPI Engine Support:**
|
||||||
|
|
||||||
|
SerpAPI supports multiple search engines via the `engine` config:
|
||||||
|
|
||||||
|
| Engine | Description |
|
||||||
|
| ------------ | ---------------------------- |
|
||||||
|
| `google` | Google Search (default) |
|
||||||
|
| `bing` | Bing Search |
|
||||||
|
| `baidu` | Baidu (百度) |
|
||||||
|
| `yandex` | Yandex Search |
|
||||||
|
| `yahoo` | Yahoo Search |
|
||||||
|
| `duckduckgo` | DuckDuckGo Search |
|
||||||
|
| `naver` | Naver Search (Korean) |
|
||||||
|
| `ecosia` | Ecosia Search (eco-friendly) |
|
||||||
|
| `seznam` | Seznam Search (Czech) |
|
||||||
|
|
||||||
|
See [SerpAPI Documentation](https://serpapi.com/search-api) for the full list of supported engines.
|
||||||
|
|
||||||
|
Configuration example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# agent/search.yml
|
||||||
|
web:
|
||||||
|
provider: "serpapi"
|
||||||
|
api_key_env: "SERPAPI_API_KEY"
|
||||||
|
engine: "bing" # Use Bing instead of Google
|
||||||
|
max_results: 10
|
||||||
|
```
|
||||||
|
|
||||||
**Agent Mode (AI Search):**
|
**Agent Mode (AI Search):**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/agent/search/types"
|
"github.com/yaoapp/yao/agent/search/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -21,14 +24,88 @@ func (h *Handler) Type() types.SearchType {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search executes web search based on uses.web mode
|
// Search executes web search based on uses.web mode
|
||||||
// TODO: Implement actual search logic
|
|
||||||
func (h *Handler) Search(req *types.Request) (*types.Result, error) {
|
func (h *Handler) Search(req *types.Request) (*types.Result, error) {
|
||||||
// Skeleton implementation - returns empty result
|
switch {
|
||||||
|
case h.usesWeb == "builtin" || h.usesWeb == "":
|
||||||
|
return h.builtinSearch(req)
|
||||||
|
case strings.HasPrefix(h.usesWeb, "mcp:"):
|
||||||
|
return h.mcpSearch(req)
|
||||||
|
default:
|
||||||
|
// Agent mode: delegate to assistant for AI-powered search
|
||||||
|
return h.agentSearch(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtinSearch uses Tavily/Serper/SerpAPI directly
|
||||||
|
func (h *Handler) builtinSearch(req *types.Request) (*types.Result, error) {
|
||||||
|
// Determine provider from config
|
||||||
|
providerName := "tavily" // default
|
||||||
|
if h.config != nil && h.config.Provider != "" {
|
||||||
|
providerName = h.config.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
switch providerName {
|
||||||
|
case "tavily":
|
||||||
|
return NewTavilyProvider(h.config).Search(req)
|
||||||
|
case "serper":
|
||||||
|
// Serper (serper.dev) - POST request with X-API-KEY header
|
||||||
|
return NewSerperProvider(h.config).Search(req)
|
||||||
|
case "serpapi":
|
||||||
|
// SerpAPI (serpapi.com) - GET request with api_key parameter
|
||||||
|
return NewSerpAPIProvider(h.config).Search(req)
|
||||||
|
default:
|
||||||
|
return &types.Result{
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Query: req.Query,
|
||||||
|
Source: req.Source,
|
||||||
|
Items: []*types.ResultItem{},
|
||||||
|
Total: 0,
|
||||||
|
Error: fmt.Sprintf("Unknown provider: %s (supported: tavily, serper, serpapi)", providerName),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentSearch delegates to an assistant for AI-powered search
|
||||||
|
func (h *Handler) agentSearch(req *types.Request) (*types.Result, error) {
|
||||||
|
// TODO: Implement agent mode
|
||||||
|
// 1. Call assistant with search request
|
||||||
|
// 2. Assistant understands intent, generates optimized queries
|
||||||
|
// 3. Assistant executes searches (may call builtin internally)
|
||||||
|
// 4. Assistant analyzes and returns structured results
|
||||||
return &types.Result{
|
return &types.Result{
|
||||||
Type: types.SearchTypeWeb,
|
Type: types.SearchTypeWeb,
|
||||||
Query: req.Query,
|
Query: req.Query,
|
||||||
Source: req.Source,
|
Source: req.Source,
|
||||||
Items: []*types.ResultItem{},
|
Items: []*types.ResultItem{},
|
||||||
Total: 0,
|
Total: 0,
|
||||||
|
Error: "Agent mode not yet implemented",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpSearch calls external MCP tool
|
||||||
|
func (h *Handler) mcpSearch(req *types.Request) (*types.Result, error) {
|
||||||
|
// TODO: Implement MCP mode
|
||||||
|
// Parse "mcp:server.tool"
|
||||||
|
mcpRef := strings.TrimPrefix(h.usesWeb, "mcp:")
|
||||||
|
parts := strings.SplitN(mcpRef, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return &types.Result{
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Query: req.Query,
|
||||||
|
Source: req.Source,
|
||||||
|
Items: []*types.ResultItem{},
|
||||||
|
Total: 0,
|
||||||
|
Error: fmt.Sprintf("Invalid MCP format, expected 'mcp:server.tool', got '%s'", h.usesWeb),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
// serverID, toolName := parts[0], parts[1]
|
||||||
|
// Call MCP tool
|
||||||
|
return &types.Result{
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Query: req.Query,
|
||||||
|
Source: req.Source,
|
||||||
|
Items: []*types.ResultItem{},
|
||||||
|
Total: 0,
|
||||||
|
Error: "MCP mode not yet implemented",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
302
agent/search/handlers/web/serpapi.go
Normal file
302
agent/search/handlers/web/serpapi.go
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/agent/search/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
serpAPIURL = "https://serpapi.com/search.json"
|
||||||
|
serpAPITimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// SerpAPIProvider implements web search using SerpAPI (supports multiple search engines)
|
||||||
|
type SerpAPIProvider struct {
|
||||||
|
apiKey string
|
||||||
|
maxResults int
|
||||||
|
engine string // Search engine: "google", "bing", "baidu", "yandex", "duckduckgo", etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSerpAPIProvider creates a new SerpAPI provider
|
||||||
|
func NewSerpAPIProvider(cfg *types.WebConfig) *SerpAPIProvider {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := "google" // Default to Google
|
||||||
|
if cfg != nil && cfg.Engine != "" {
|
||||||
|
engine = cfg.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SerpAPIProvider{
|
||||||
|
apiKey: apiKey,
|
||||||
|
maxResults: maxResults,
|
||||||
|
engine: engine,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serpAPIResponse represents the response from SerpAPI
|
||||||
|
type serpAPIResponse struct {
|
||||||
|
SearchMetadata serpAPIMetadata `json:"search_metadata"`
|
||||||
|
SearchParameters serpAPIParams `json:"search_parameters"`
|
||||||
|
SearchInformation serpAPIInfo `json:"search_information"`
|
||||||
|
OrganicResults []serpAPIResult `json:"organic_results"`
|
||||||
|
AnswerBox *serpAPIAnswerBox `json:"answer_box,omitempty"`
|
||||||
|
KnowledgeGraph *serpAPIKnowledge `json:"knowledge_graph,omitempty"`
|
||||||
|
RelatedSearches []serpAPIRelated `json:"related_searches,omitempty"`
|
||||||
|
RelatedQuestions []serpAPIQuestion `json:"related_questions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serpAPIMetadata contains metadata from response
|
||||||
|
type serpAPIMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
ProcessedAt string `json:"processed_at"`
|
||||||
|
TotalTimeTaken float64 `json:"total_time_taken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serpAPIParams contains search parameters from response
|
||||||
|
type serpAPIParams struct {
|
||||||
|
Engine string `json:"engine"`
|
||||||
|
Q string `json:"q"`
|
||||||
|
Location string `json:"location_used"`
|
||||||
|
GoogleDomain string `json:"google_domain"`
|
||||||
|
HL string `json:"hl"`
|
||||||
|
GL string `json:"gl"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serpAPIInfo contains search information
|
||||||
|
type serpAPIInfo struct {
|
||||||
|
QueryDisplayed string `json:"query_displayed"`
|
||||||
|
TotalResults int64 `json:"total_results"`
|
||||||
|
TimeTakenDisplayed float64 `json:"time_taken_displayed"`
|
||||||
|
OrganicResultsState string `json:"organic_results_state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serpAPIResult represents a single organic search result
|
||||||
|
type serpAPIResult struct {
|
||||||
|
Position int `json:"position"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
RedirectLink string `json:"redirect_link,omitempty"`
|
||||||
|
DisplayedLink string `json:"displayed_link"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
CachedPageLink string `json:"cached_page_link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serpAPIAnswerBox represents the answer box (featured snippet)
|
||||||
|
type serpAPIAnswerBox struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Snippet string `json:"snippet,omitempty"`
|
||||||
|
Link string `json:"link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serpAPIKnowledge represents knowledge graph data
|
||||||
|
type serpAPIKnowledge struct {
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serpAPIRelated represents related searches
|
||||||
|
type serpAPIRelated struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serpAPIQuestion represents related questions (People Also Ask)
|
||||||
|
type serpAPIQuestion struct {
|
||||||
|
Question string `json:"question"`
|
||||||
|
Snippet string `json:"snippet,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Link string `json:"link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search executes a web search using SerpAPI
|
||||||
|
func (p *SerpAPIProvider) 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: "SerpAPI API key not configured",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine max results
|
||||||
|
maxResults := p.maxResults
|
||||||
|
if req.Limit > 0 {
|
||||||
|
maxResults = req.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("engine", p.engine)
|
||||||
|
params.Set("api_key", p.apiKey)
|
||||||
|
params.Set("num", fmt.Sprintf("%d", maxResults))
|
||||||
|
|
||||||
|
// Build search query with site restrictions if specified
|
||||||
|
query := req.Query
|
||||||
|
if len(req.Sites) > 0 {
|
||||||
|
siteQuery := ""
|
||||||
|
for i, site := range req.Sites {
|
||||||
|
if i > 0 {
|
||||||
|
siteQuery += " OR "
|
||||||
|
}
|
||||||
|
siteQuery += "site:" + site
|
||||||
|
}
|
||||||
|
query = "(" + siteQuery + ") " + req.Query
|
||||||
|
}
|
||||||
|
params.Set("q", query)
|
||||||
|
|
||||||
|
// Add time range if specified (tbs parameter)
|
||||||
|
if req.TimeRange != "" {
|
||||||
|
tbs := convertSerpAPITimeRange(req.TimeRange)
|
||||||
|
if tbs != "" {
|
||||||
|
params.Set("tbs", tbs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute API call
|
||||||
|
serpResp, err := p.callAPI(params)
|
||||||
|
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("SerpAPI error: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert results
|
||||||
|
items := make([]*types.ResultItem, 0, len(serpResp.OrganicResults))
|
||||||
|
|
||||||
|
// Add answer box as first result if available
|
||||||
|
if serpResp.AnswerBox != nil && serpResp.AnswerBox.Snippet != "" {
|
||||||
|
items = append(items, &types.ResultItem{
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Title: serpResp.AnswerBox.Title,
|
||||||
|
Content: serpResp.AnswerBox.Snippet,
|
||||||
|
URL: serpResp.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 serpResp.OrganicResults {
|
||||||
|
// 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 GET request to SerpAPI
|
||||||
|
func (p *SerpAPIProvider) callAPI(params url.Values) (*serpAPIResponse, error) {
|
||||||
|
// Build URL with query parameters
|
||||||
|
reqURL := serpAPIURL + "?" + params.Encode()
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
httpReq, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
client := &http.Client{Timeout: serpAPITimeout}
|
||||||
|
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 serpResp serpAPIResponse
|
||||||
|
if err := json.Unmarshal(respBody, &serpResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &serpResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertSerpAPITimeRange converts time range to SerpAPI tbs format
|
||||||
|
func convertSerpAPITimeRange(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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
394
agent/search/handlers/web/serpapi_test.go
Normal file
394
agent/search/handlers/web/serpapi_test.go
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/agent/search/handlers/web"
|
||||||
|
"github.com/yaoapp/yao/agent/search/types"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSerpAPIProviderWithAssistantConfig tests SerpAPIProvider using web-serpapi assistant config
|
||||||
|
func TestSerpAPIProviderWithAssistantConfig(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serpapi test assistant to get its config
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serpapi")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Verify assistant config
|
||||||
|
assert.Equal(t, "tests.web-serpapi", ast.ID)
|
||||||
|
assert.Equal(t, "serpapi", ast.Search.Web.Provider)
|
||||||
|
assert.Equal(t, "$ENV.SERPAPI_API_KEY", ast.Search.Web.APIKeyEnv)
|
||||||
|
assert.Equal(t, 10, ast.Search.Web.MaxResults)
|
||||||
|
|
||||||
|
// Create SerpAPIProvider with assistant's web config
|
||||||
|
provider := web.NewSerpAPIProvider(ast.Search.Web)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
// Execute search
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "Yao App Engine",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
Limit: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// Verify result structure
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||||
|
assert.Equal(t, "Yao App Engine", result.Query)
|
||||||
|
assert.Equal(t, types.SourceAuto, result.Source)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
|
||||||
|
// Verify we got results
|
||||||
|
assert.Greater(t, result.Total, 0)
|
||||||
|
assert.NotEmpty(t, result.Items)
|
||||||
|
assert.Greater(t, result.Duration, int64(0))
|
||||||
|
|
||||||
|
// Verify result item structure
|
||||||
|
for _, item := range result.Items {
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, item.Type)
|
||||||
|
assert.Equal(t, types.SourceAuto, item.Source)
|
||||||
|
assert.NotEmpty(t, item.Title)
|
||||||
|
assert.NotEmpty(t, item.URL)
|
||||||
|
assert.Greater(t, item.Score, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Search returned %d results in %dms", result.Total, result.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerpAPIProviderWithSiteRestriction tests SerpAPIProvider with domain restriction
|
||||||
|
func TestSerpAPIProviderWithSiteRestriction(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serpapi test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serpapi")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create SerpAPIProvider
|
||||||
|
provider := web.NewSerpAPIProvider(ast.Search.Web)
|
||||||
|
|
||||||
|
// Execute search with site restriction
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "documentation",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceHook,
|
||||||
|
Sites: []string{"github.com"},
|
||||||
|
Limit: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||||
|
assert.Equal(t, types.SourceHook, result.Source)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
require.NotEmpty(t, result.Items, "Search should return results")
|
||||||
|
|
||||||
|
// All results should be from github.com
|
||||||
|
for _, item := range result.Items {
|
||||||
|
assert.Contains(t, item.URL, "github.com", "Result URL should be from github.com")
|
||||||
|
}
|
||||||
|
t.Logf("Site-restricted search returned %d results from github.com", result.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerpAPIProviderWithMultipleSites tests SerpAPIProvider with multiple domain restrictions
|
||||||
|
func TestSerpAPIProviderWithMultipleSites(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serpapi test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serpapi")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create SerpAPIProvider
|
||||||
|
provider := web.NewSerpAPIProvider(ast.Search.Web)
|
||||||
|
|
||||||
|
// Execute search with multiple site restrictions
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "golang tutorial",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
Sites: []string{"github.com", "golang.org"},
|
||||||
|
Limit: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
require.NotEmpty(t, result.Items, "Search should return results")
|
||||||
|
|
||||||
|
// Results should be from either github.com or golang.org
|
||||||
|
for _, item := range result.Items {
|
||||||
|
isValidSite := false
|
||||||
|
for _, site := range req.Sites {
|
||||||
|
if containsSite(item.URL, site) {
|
||||||
|
isValidSite = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, isValidSite, "Result URL should be from github.com or golang.org: %s", item.URL)
|
||||||
|
}
|
||||||
|
t.Logf("Multi-site search returned %d results", result.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerpAPIProviderWithTimeRange tests SerpAPIProvider with time range filter
|
||||||
|
func TestSerpAPIProviderWithTimeRange(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serpapi test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serpapi")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create SerpAPIProvider
|
||||||
|
provider := web.NewSerpAPIProvider(ast.Search.Web)
|
||||||
|
|
||||||
|
// Execute search with time range
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "artificial intelligence news",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
TimeRange: "week", // Last week
|
||||||
|
Limit: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
t.Logf("Time-ranged search (last week) returned %d results in %dms", result.Total, result.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerpAPIProviderWithoutAPIKey tests graceful degradation when API key is missing
|
||||||
|
func TestSerpAPIProviderWithoutAPIKey(t *testing.T) {
|
||||||
|
// Create provider with nil config (no API key)
|
||||||
|
provider := web.NewSerpAPIProvider(nil)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "test query",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
|
||||||
|
// Should not return error, but result should have error message
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||||
|
assert.Equal(t, "test query", result.Query)
|
||||||
|
assert.NotEmpty(t, result.Error)
|
||||||
|
assert.Contains(t, result.Error, "API key")
|
||||||
|
assert.Empty(t, result.Items)
|
||||||
|
assert.Equal(t, 0, result.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerpAPIProviderWithEmptyConfig tests provider with empty config
|
||||||
|
func TestSerpAPIProviderWithEmptyConfig(t *testing.T) {
|
||||||
|
// Create provider with empty config
|
||||||
|
cfg := &types.WebConfig{}
|
||||||
|
provider := web.NewSerpAPIProvider(cfg)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "test query",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.NotEmpty(t, result.Error)
|
||||||
|
assert.Contains(t, result.Error, "API key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerpAPIProviderMaxResults tests that max_results from config is respected
|
||||||
|
func TestSerpAPIProviderMaxResults(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serpapi test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serpapi")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create SerpAPIProvider
|
||||||
|
provider := web.NewSerpAPIProvider(ast.Search.Web)
|
||||||
|
|
||||||
|
// Execute search without limit (should use config's max_results)
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "machine learning",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
// No Limit set, should use config's max_results (10)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
|
||||||
|
// Should respect max_results from config (+1 for possible answer box)
|
||||||
|
assert.LessOrEqual(t, result.Total, ast.Search.Web.MaxResults+1)
|
||||||
|
t.Logf("Search without limit returned %d results (max: %d)", result.Total, ast.Search.Web.MaxResults)
|
||||||
|
|
||||||
|
// Execute search with explicit limit
|
||||||
|
req2 := &types.Request{
|
||||||
|
Query: "machine learning",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
Limit: 3, // Override config's max_results
|
||||||
|
}
|
||||||
|
|
||||||
|
result2, err := provider.Search(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result2)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result2.Error, "Search should succeed with valid API key, got error: %s", result2.Error)
|
||||||
|
|
||||||
|
// Should respect request's limit (+1 for possible answer box)
|
||||||
|
assert.LessOrEqual(t, result2.Total, 4)
|
||||||
|
t.Logf("Search with limit=3 returned %d results", result2.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerpAPIProviderWithBingEngine tests SerpAPIProvider with Bing search engine
|
||||||
|
func TestSerpAPIProviderWithBingEngine(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serpapi test assistant to get base config
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serpapi")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create config with Bing engine
|
||||||
|
bingConfig := &types.WebConfig{
|
||||||
|
Provider: "serpapi",
|
||||||
|
APIKeyEnv: ast.Search.Web.APIKeyEnv,
|
||||||
|
MaxResults: 5,
|
||||||
|
Engine: "bing",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SerpAPIProvider with Bing engine
|
||||||
|
provider := web.NewSerpAPIProvider(bingConfig)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
// Execute search
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "Golang programming",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
Limit: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// Verify result structure
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||||
|
assert.Equal(t, "Golang programming", result.Query)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Bing search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
|
||||||
|
// Verify we got results
|
||||||
|
assert.Greater(t, result.Total, 0)
|
||||||
|
assert.NotEmpty(t, result.Items)
|
||||||
|
|
||||||
|
t.Logf("Bing search returned %d results in %dms", result.Total, result.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerpAPIProviderEngineDefault tests that default engine is Google
|
||||||
|
func TestSerpAPIProviderEngineDefault(t *testing.T) {
|
||||||
|
// Create provider with config that has no engine specified
|
||||||
|
cfg := &types.WebConfig{
|
||||||
|
Provider: "serpapi",
|
||||||
|
APIKeyEnv: "SERPAPI_API_KEY",
|
||||||
|
MaxResults: 10,
|
||||||
|
// Engine not set - should default to "google"
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := web.NewSerpAPIProvider(cfg)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
// We can't directly check the engine field since it's private,
|
||||||
|
// but we verify the provider is created successfully
|
||||||
|
// The actual engine usage is tested in integration tests
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsSite checks if url contains the site domain
|
||||||
|
func containsSite(url, site string) bool {
|
||||||
|
return len(url) >= len(site) && containsHelper(url, site)
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsHelper is a helper function for string containment check
|
||||||
|
func containsHelper(s, substr string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
280
agent/search/handlers/web/serper.go
Normal file
280
agent/search/handlers/web/serper.go
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
327
agent/search/handlers/web/serper_test.go
Normal file
327
agent/search/handlers/web/serper_test.go
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/agent/search/handlers/web"
|
||||||
|
"github.com/yaoapp/yao/agent/search/types"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// skipIfNoSerperKey skips the test if SERPER_API_KEY is not set
|
||||||
|
// Note: Serper (serper.dev) requires registration at https://serper.dev
|
||||||
|
func skipIfNoSerperKey(t *testing.T) {
|
||||||
|
if os.Getenv("SERPER_API_KEY") == "" {
|
||||||
|
t.Skip("Skipping Serper test: SERPER_API_KEY not set. Register at https://serper.dev for free 2500 queries.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerperProviderWithAssistantConfig tests SerperProvider using web-serper assistant config
|
||||||
|
func TestSerperProviderWithAssistantConfig(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
skipIfNoSerperKey(t)
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serper test assistant to get its config
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serper")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Verify assistant config
|
||||||
|
assert.Equal(t, "tests.web-serper", ast.ID)
|
||||||
|
assert.Equal(t, "serper", ast.Search.Web.Provider)
|
||||||
|
assert.Equal(t, "$ENV.SERPER_API_KEY", ast.Search.Web.APIKeyEnv)
|
||||||
|
assert.Equal(t, 10, ast.Search.Web.MaxResults)
|
||||||
|
|
||||||
|
// Create SerperProvider with assistant's web config
|
||||||
|
provider := web.NewSerperProvider(ast.Search.Web)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
// Execute search
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "Yao App Engine",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
Limit: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// Verify result structure
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||||
|
assert.Equal(t, "Yao App Engine", result.Query)
|
||||||
|
assert.Equal(t, types.SourceAuto, result.Source)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
|
||||||
|
// Verify we got results
|
||||||
|
assert.Greater(t, result.Total, 0)
|
||||||
|
assert.NotEmpty(t, result.Items)
|
||||||
|
assert.Greater(t, result.Duration, int64(0))
|
||||||
|
|
||||||
|
// Verify result item structure
|
||||||
|
for _, item := range result.Items {
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, item.Type)
|
||||||
|
assert.Equal(t, types.SourceAuto, item.Source)
|
||||||
|
assert.NotEmpty(t, item.Title)
|
||||||
|
assert.NotEmpty(t, item.URL)
|
||||||
|
assert.Greater(t, item.Score, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Search returned %d results in %dms", result.Total, result.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerperProviderWithSiteRestriction tests SerperProvider with domain restriction
|
||||||
|
func TestSerperProviderWithSiteRestriction(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
skipIfNoSerperKey(t)
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serper test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serper")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create SerperProvider
|
||||||
|
provider := web.NewSerperProvider(ast.Search.Web)
|
||||||
|
|
||||||
|
// Execute search with site restriction
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "documentation",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceHook,
|
||||||
|
Sites: []string{"github.com"},
|
||||||
|
Limit: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||||
|
assert.Equal(t, types.SourceHook, result.Source)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
require.NotEmpty(t, result.Items, "Search should return results")
|
||||||
|
|
||||||
|
// All results should be from github.com
|
||||||
|
for _, item := range result.Items {
|
||||||
|
assert.Contains(t, item.URL, "github.com", "Result URL should be from github.com")
|
||||||
|
}
|
||||||
|
t.Logf("Site-restricted search returned %d results from github.com", result.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerperProviderWithMultipleSites tests SerperProvider with multiple domain restrictions
|
||||||
|
func TestSerperProviderWithMultipleSites(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
skipIfNoSerperKey(t)
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serper test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serper")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create SerperProvider
|
||||||
|
provider := web.NewSerperProvider(ast.Search.Web)
|
||||||
|
|
||||||
|
// Execute search with multiple site restrictions
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "golang tutorial",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
Sites: []string{"github.com", "golang.org"},
|
||||||
|
Limit: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
require.NotEmpty(t, result.Items, "Search should return results")
|
||||||
|
|
||||||
|
// Results should be from either github.com or golang.org
|
||||||
|
for _, item := range result.Items {
|
||||||
|
isValidSite := false
|
||||||
|
for _, site := range req.Sites {
|
||||||
|
if contains(item.URL, site) {
|
||||||
|
isValidSite = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, isValidSite, "Result URL should be from github.com or golang.org: %s", item.URL)
|
||||||
|
}
|
||||||
|
t.Logf("Multi-site search returned %d results", result.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerperProviderWithTimeRange tests SerperProvider with time range filter
|
||||||
|
func TestSerperProviderWithTimeRange(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
skipIfNoSerperKey(t)
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serper test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serper")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create SerperProvider
|
||||||
|
provider := web.NewSerperProvider(ast.Search.Web)
|
||||||
|
|
||||||
|
// Execute search with time range
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "artificial intelligence news",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
TimeRange: "week", // Last week
|
||||||
|
Limit: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
t.Logf("Time-ranged search (last week) returned %d results in %dms", result.Total, result.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerperProviderWithoutAPIKey tests graceful degradation when API key is missing
|
||||||
|
func TestSerperProviderWithoutAPIKey(t *testing.T) {
|
||||||
|
// Create provider with nil config (no API key)
|
||||||
|
provider := web.NewSerperProvider(nil)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "test query",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
|
||||||
|
// Should not return error, but result should have error message
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||||
|
assert.Equal(t, "test query", result.Query)
|
||||||
|
assert.NotEmpty(t, result.Error)
|
||||||
|
assert.Contains(t, result.Error, "API key")
|
||||||
|
assert.Empty(t, result.Items)
|
||||||
|
assert.Equal(t, 0, result.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerperProviderWithEmptyConfig tests provider with empty config
|
||||||
|
func TestSerperProviderWithEmptyConfig(t *testing.T) {
|
||||||
|
// Create provider with empty config
|
||||||
|
cfg := &types.WebConfig{}
|
||||||
|
provider := web.NewSerperProvider(cfg)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "test query",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.NotEmpty(t, result.Error)
|
||||||
|
assert.Contains(t, result.Error, "API key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSerperProviderMaxResults tests that max_results from config is respected
|
||||||
|
func TestSerperProviderMaxResults(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
skipIfNoSerperKey(t)
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-serper test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-serper")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create SerperProvider
|
||||||
|
provider := web.NewSerperProvider(ast.Search.Web)
|
||||||
|
|
||||||
|
// Execute search without limit (should use config's max_results)
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "machine learning",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
// No Limit set, should use config's max_results (10)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
|
||||||
|
// Should respect max_results from config (+1 for possible answer box)
|
||||||
|
assert.LessOrEqual(t, result.Total, ast.Search.Web.MaxResults+1)
|
||||||
|
t.Logf("Search without limit returned %d results (max: %d)", result.Total, ast.Search.Web.MaxResults)
|
||||||
|
|
||||||
|
// Execute search with explicit limit
|
||||||
|
req2 := &types.Request{
|
||||||
|
Query: "machine learning",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
Limit: 3, // Override config's max_results
|
||||||
|
}
|
||||||
|
|
||||||
|
result2, err := provider.Search(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result2)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result2.Error, "Search should succeed with valid API key, got error: %s", result2.Error)
|
||||||
|
|
||||||
|
// Should respect request's limit (+1 for possible answer box)
|
||||||
|
assert.LessOrEqual(t, result2.Total, 4)
|
||||||
|
t.Logf("Search with limit=3 returned %d results", result2.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains checks if s contains substr (uses containsSite from serpapi_test.go)
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return containsSite(s, substr)
|
||||||
|
}
|
||||||
192
agent/search/handlers/web/tavily.go
Normal file
192
agent/search/handlers/web/tavily.go
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/agent/search/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tavilyAPIURL = "https://api.tavily.com/search"
|
||||||
|
tavilyAPITimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// TavilyProvider implements web search using Tavily API
|
||||||
|
type TavilyProvider struct {
|
||||||
|
apiKey string
|
||||||
|
maxResults int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTavilyProvider creates a new Tavily provider
|
||||||
|
func NewTavilyProvider(cfg *types.WebConfig) *TavilyProvider {
|
||||||
|
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 &TavilyProvider{
|
||||||
|
apiKey: apiKey,
|
||||||
|
maxResults: maxResults,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tavilyRequest represents the request body for Tavily API
|
||||||
|
type tavilyRequest struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
SearchDepth string `json:"search_depth,omitempty"` // "basic" or "advanced"
|
||||||
|
IncludeAnswer bool `json:"include_answer,omitempty"` // Include AI-generated answer
|
||||||
|
IncludeRawContent bool `json:"include_raw_content,omitempty"` // Include raw HTML content
|
||||||
|
MaxResults int `json:"max_results,omitempty"` // Max number of results
|
||||||
|
IncludeDomains []string `json:"include_domains,omitempty"` // Limit to specific domains
|
||||||
|
ExcludeDomains []string `json:"exclude_domains,omitempty"` // Exclude specific domains
|
||||||
|
}
|
||||||
|
|
||||||
|
// tavilyResponse represents the response from Tavily API
|
||||||
|
type tavilyResponse struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Answer string `json:"answer,omitempty"`
|
||||||
|
Results []tavilyResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tavilyResult represents a single search result from Tavily
|
||||||
|
type tavilyResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
RawContent string `json:"raw_content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search executes a web search using Tavily API
|
||||||
|
func (p *TavilyProvider) 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: "Tavily API key not configured",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine max results
|
||||||
|
maxResults := p.maxResults
|
||||||
|
if req.Limit > 0 {
|
||||||
|
maxResults = req.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request body
|
||||||
|
tavilyReq := tavilyRequest{
|
||||||
|
APIKey: p.apiKey,
|
||||||
|
Query: req.Query,
|
||||||
|
SearchDepth: "basic",
|
||||||
|
IncludeAnswer: false,
|
||||||
|
MaxResults: maxResults,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add domain restrictions if specified
|
||||||
|
if len(req.Sites) > 0 {
|
||||||
|
tavilyReq.IncludeDomains = req.Sites
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute API call
|
||||||
|
tavilyResp, err := p.callAPI(&tavilyReq)
|
||||||
|
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("Tavily API error: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert results
|
||||||
|
items := make([]*types.ResultItem, 0, len(tavilyResp.Results))
|
||||||
|
for _, r := range tavilyResp.Results {
|
||||||
|
items = append(items, &types.ResultItem{
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Title: r.Title,
|
||||||
|
Content: r.Content,
|
||||||
|
URL: r.URL,
|
||||||
|
Score: r.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 request to Tavily API
|
||||||
|
func (p *TavilyProvider) callAPI(req *tavilyRequest) (*tavilyResponse, 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, tavilyAPIURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
client := &http.Client{Timeout: tavilyAPITimeout}
|
||||||
|
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 tavilyResp tavilyResponse
|
||||||
|
if err := json.Unmarshal(respBody, &tavilyResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tavilyResp, nil
|
||||||
|
}
|
||||||
223
agent/search/handlers/web/tavily_test.go
Normal file
223
agent/search/handlers/web/tavily_test.go
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/agent/search/handlers/web"
|
||||||
|
"github.com/yaoapp/yao/agent/search/types"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTavilyProviderWithAssistantConfig tests TavilyProvider using web-tavily assistant config
|
||||||
|
func TestTavilyProviderWithAssistantConfig(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-tavily test assistant to get its config
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-tavily")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Verify assistant config
|
||||||
|
assert.Equal(t, "tests.web-tavily", ast.ID)
|
||||||
|
assert.Equal(t, "tavily", ast.Search.Web.Provider)
|
||||||
|
assert.Equal(t, "$ENV.TAVILY_API_KEY", ast.Search.Web.APIKeyEnv)
|
||||||
|
assert.Equal(t, 10, ast.Search.Web.MaxResults)
|
||||||
|
|
||||||
|
// Create TavilyProvider with assistant's web config
|
||||||
|
provider := web.NewTavilyProvider(ast.Search.Web)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
// Execute search
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "Yao App Engine",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
Limit: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// Verify result structure
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||||
|
assert.Equal(t, "Yao App Engine", result.Query)
|
||||||
|
assert.Equal(t, types.SourceAuto, result.Source)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
|
||||||
|
// Verify we got results
|
||||||
|
assert.Greater(t, result.Total, 0)
|
||||||
|
assert.NotEmpty(t, result.Items)
|
||||||
|
assert.Greater(t, result.Duration, int64(0))
|
||||||
|
|
||||||
|
// Verify result item structure
|
||||||
|
for _, item := range result.Items {
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, item.Type)
|
||||||
|
assert.Equal(t, types.SourceAuto, item.Source)
|
||||||
|
assert.NotEmpty(t, item.Title)
|
||||||
|
assert.NotEmpty(t, item.URL)
|
||||||
|
// Content may be empty for some results
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Search returned %d results in %dms", result.Total, result.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTavilyProviderWithSiteRestriction tests TavilyProvider with domain restriction
|
||||||
|
func TestTavilyProviderWithSiteRestriction(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-tavily test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-tavily")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create TavilyProvider
|
||||||
|
provider := web.NewTavilyProvider(ast.Search.Web)
|
||||||
|
|
||||||
|
// Execute search with site restriction
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "documentation",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceHook,
|
||||||
|
Sites: []string{"github.com"},
|
||||||
|
Limit: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||||
|
assert.Equal(t, types.SourceHook, result.Source)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
require.NotEmpty(t, result.Items, "Search should return results")
|
||||||
|
|
||||||
|
// All results should be from github.com
|
||||||
|
for _, item := range result.Items {
|
||||||
|
assert.Contains(t, item.URL, "github.com", "Result URL should be from github.com")
|
||||||
|
}
|
||||||
|
t.Logf("Site-restricted search returned %d results from github.com", result.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTavilyProviderWithoutAPIKey tests graceful degradation when API key is missing
|
||||||
|
func TestTavilyProviderWithoutAPIKey(t *testing.T) {
|
||||||
|
// Create provider with nil config (no API key)
|
||||||
|
provider := web.NewTavilyProvider(nil)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "test query",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
|
||||||
|
// Should not return error, but result should have error message
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||||
|
assert.Equal(t, "test query", result.Query)
|
||||||
|
assert.NotEmpty(t, result.Error)
|
||||||
|
assert.Contains(t, result.Error, "API key")
|
||||||
|
assert.Empty(t, result.Items)
|
||||||
|
assert.Equal(t, 0, result.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTavilyProviderWithEmptyConfig tests provider with empty config
|
||||||
|
func TestTavilyProviderWithEmptyConfig(t *testing.T) {
|
||||||
|
// Create provider with empty config
|
||||||
|
cfg := &types.WebConfig{}
|
||||||
|
provider := web.NewTavilyProvider(cfg)
|
||||||
|
require.NotNil(t, provider)
|
||||||
|
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "test query",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.NotEmpty(t, result.Error)
|
||||||
|
assert.Contains(t, result.Error, "API key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTavilyProviderMaxResults tests that max_results from config is respected
|
||||||
|
func TestTavilyProviderMaxResults(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the web-tavily test assistant
|
||||||
|
ast, err := assistant.LoadPath("/assistants/tests/web-tavily")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, ast.Search)
|
||||||
|
require.NotNil(t, ast.Search.Web)
|
||||||
|
|
||||||
|
// Create TavilyProvider
|
||||||
|
provider := web.NewTavilyProvider(ast.Search.Web)
|
||||||
|
|
||||||
|
// Execute search without limit (should use config's max_results)
|
||||||
|
req := &types.Request{
|
||||||
|
Query: "artificial intelligence",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
// No Limit set, should use config's max_results (10)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Search(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result.Error, "Search should succeed with valid API key, got error: %s", result.Error)
|
||||||
|
|
||||||
|
// Should respect max_results from config
|
||||||
|
assert.LessOrEqual(t, result.Total, ast.Search.Web.MaxResults)
|
||||||
|
t.Logf("Search without limit returned %d results (max: %d)", result.Total, ast.Search.Web.MaxResults)
|
||||||
|
|
||||||
|
// Execute search with explicit limit
|
||||||
|
req2 := &types.Request{
|
||||||
|
Query: "artificial intelligence",
|
||||||
|
Type: types.SearchTypeWeb,
|
||||||
|
Source: types.SourceAuto,
|
||||||
|
Limit: 3, // Override config's max_results
|
||||||
|
}
|
||||||
|
|
||||||
|
result2, err := provider.Search(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result2)
|
||||||
|
|
||||||
|
// API key must be valid - search should succeed
|
||||||
|
require.Empty(t, result2.Error, "Search should succeed with valid API key, got error: %s", result2.Error)
|
||||||
|
|
||||||
|
// Should respect request's limit
|
||||||
|
assert.LessOrEqual(t, result2.Total, 3)
|
||||||
|
t.Logf("Search with limit=3 returned %d results", result2.Total)
|
||||||
|
}
|
||||||
|
|
@ -17,9 +17,10 @@ type Config struct {
|
||||||
// Note: uses.web determines the mode (builtin/agent/mcp)
|
// Note: uses.web determines the mode (builtin/agent/mcp)
|
||||||
// Provider is only used when uses.web = "builtin"
|
// Provider is only used when uses.web = "builtin"
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Provider string `json:"provider,omitempty" yaml:"provider,omitempty"` // "tavily" or "serper" (for builtin mode)
|
Provider string `json:"provider,omitempty" yaml:"provider,omitempty"` // "tavily", "serper", or "serpapi" (for builtin mode)
|
||||||
APIKeyEnv string `json:"api_key_env,omitempty" yaml:"api_key_env,omitempty"` // Environment variable for API key
|
APIKeyEnv string `json:"api_key_env,omitempty" yaml:"api_key_env,omitempty"` // Environment variable for API key
|
||||||
MaxResults int `json:"max_results,omitempty" yaml:"max_results,omitempty"` // Max results (default: 10)
|
MaxResults int `json:"max_results,omitempty" yaml:"max_results,omitempty"` // Max results (default: 10)
|
||||||
|
Engine string `json:"engine,omitempty" yaml:"engine,omitempty"` // Search engine for SerpAPI: "google", "bing", "baidu", "yandex", etc. (default: "google")
|
||||||
}
|
}
|
||||||
|
|
||||||
// KBConfig for knowledge base search settings
|
// KBConfig for knowledge base search settings
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue