feat(search): P3.1 filters and multi-query merge

Native include/exclude domains, time_range, topic, exact_match, min_score,
and up to 4 queries fused with RRF. Honesty notes stay off the Warn/fail
path. Tavily keeps include_answer false and sets auto_parameters false.
Cache key is canonical JSON. CLI flags and skill recipe included.
This commit is contained in:
2026-09-29 15:30:19 -07:00
parent 5c6a4a40ec
commit 8b26776cdd
21 changed files with 1606 additions and 195 deletions
+54 -23
View File
@@ -19,16 +19,7 @@ type Tavily struct {
func (s *Tavily) Name() string { return NameTavily }
func (s *Tavily) Ready() bool { return s != nil && s.Client.Ready() }
type tavilyReq struct {
Query string `json:"query"`
MaxResults int `json:"max_results"`
IncludeAnswer bool `json:"include_answer"`
IncludeRawContent bool `json:"include_raw_content"`
IncludeImages bool `json:"include_images"`
SearchDepth string `json:"search_depth"`
}
func (s *Tavily) Search(ctx context.Context, query string, k int) SourceOut {
func (s *Tavily) Search(ctx context.Context, query string, k int, f Filter) SourceOut {
if s == nil || s.Client == nil {
return SourceOut{Warn: NameTavily + ": error"}
}
@@ -41,14 +32,7 @@ func (s *Tavily) Search(ctx context.Context, query string, k int) SourceOut {
if k > 20 {
k = 20
}
payload, _ := json.Marshal(tavilyReq{
Query: query,
MaxResults: k,
IncludeAnswer: false,
IncludeRawContent: false,
IncludeImages: false,
SearchDepth: "basic",
})
payload, _ := json.Marshal(tavilyBody(query, k, f))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.Client.URL.String(), bytes.NewReader(payload))
if err != nil {
return SourceOut{Warn: warnErr(NameTavily, err)}
@@ -76,14 +60,58 @@ func (s *Tavily) Search(ctx context.Context, query string, k int) SourceOut {
return SourceOut{Hits: hits, Credits: credits}
}
func tavilyBody(query string, k int, f Filter) map[string]any {
body := map[string]any{
"query": query,
"max_results": k,
"include_answer": false,
"include_raw_content": false,
"include_images": false,
"search_depth": "basic",
"auto_parameters": false,
}
if len(f.IncludeDomains) > 0 {
body["include_domains"] = f.IncludeDomains
mode := f.IncludeDomainsMode
if mode == "" {
mode = "restrict"
}
body["include_domains_mode"] = mode
}
if len(f.ExcludeDomains) > 0 {
body["exclude_domains"] = f.ExcludeDomains
}
if f.TimeRange != "" {
body["time_range"] = f.TimeRange
} else {
if f.StartDate != "" {
body["start_date"] = f.StartDate
}
if f.EndDate != "" {
body["end_date"] = f.EndDate
}
}
if f.Topic != "" && f.Topic != "general" {
body["topic"] = f.Topic
}
if f.ExactMatch {
body["exact_match"] = true
}
if f.Topic == "news" || f.TimeRange != "" || f.StartDate != "" || f.EndDate != "" {
body["include_published_date"] = true
}
return body
}
func mapTavily(raw []byte) ([]Hit, int, error) {
var env struct {
Answer string `json:"answer"`
Results []struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score"`
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score"`
PublishedDate string `json:"published_date"`
} `json:"results"`
Usage struct {
Credits int `json:"credits"`
@@ -98,7 +126,10 @@ func mapTavily(raw []byte) ([]Hit, int, error) {
if r.URL == "" {
continue
}
hits = append(hits, Hit{URL: r.URL, Title: r.Title, Snippet: r.Content, Score: r.Score, Source: NameTavily})
hits = append(hits, Hit{
URL: r.URL, Title: r.Title, Snippet: r.Content,
Score: r.Score, Source: NameTavily, PublishedDate: r.PublishedDate,
})
}
credits := env.Usage.Credits
if credits <= 0 {