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
+166
View File
@@ -0,0 +1,166 @@
package search
import (
"net/url"
"strings"
)
func resultHost(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return ""
}
h := strings.ToLower(u.Hostname())
return strings.TrimSuffix(h, ".")
}
func hostMatch(host, d string) bool {
return host == d || strings.HasSuffix(host, "."+d)
}
func filterDomains(hits []Hit, f Filter) []Hit {
if len(f.IncludeDomains) == 0 && len(f.ExcludeDomains) == 0 {
return hits
}
mode := f.IncludeDomainsMode
if mode == "" {
mode = "restrict"
}
restrict := mode != "prefer" && len(f.IncludeDomains) > 0
out := make([]Hit, 0, len(hits))
for _, h := range hits {
host := resultHost(h.URL)
if host == "" {
if restrict {
continue
}
} else {
drop := false
for _, d := range f.ExcludeDomains {
if hostMatch(host, d) {
drop = true
break
}
}
if drop {
continue
}
if restrict {
ok := false
for _, d := range f.IncludeDomains {
if hostMatch(host, d) {
ok = true
break
}
}
if !ok {
continue
}
}
}
out = append(out, h)
}
return out
}
func filterMinScore(hits []Hit, min float64) []Hit {
if min <= 0 {
return hits
}
out := make([]Hit, 0, len(hits))
for _, h := range hits {
if h.Score >= min {
out = append(out, h)
}
}
return out
}
func dedupeStrings(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
func searxQueryAndNotes(q string, f Filter) (string, []string) {
var notes []string
if f.IncludeDomainsMode == "prefer" && len(f.IncludeDomains) > 0 {
notes = append(notes, "searxng: include_domains_mode=prefer not applied")
} else if n := len(f.IncludeDomains); n == 1 {
q = "site:" + f.IncludeDomains[0] + " " + q
} else if n > 1 {
max := n
if max > searxSiteCap {
max = searxSiteCap
notes = append(notes, "searxng: extra include_domains dropped")
}
var b strings.Builder
for i := 0; i < max; i++ {
if i > 0 {
b.WriteString(" OR ")
}
b.WriteString("site:")
b.WriteString(f.IncludeDomains[i])
}
b.WriteByte(' ')
b.WriteString(q)
q = b.String()
}
if f.Topic == "finance" {
notes = append(notes, "searxng: topic ignored")
}
if f.StartDate != "" || f.EndDate != "" {
notes = append(notes, "searxng: start_date/end_date not applied")
}
if f.MinScore > 0 {
notes = append(notes, "searxng: min_score not applied")
}
return q, notes
}
func braveNotes(f Filter) []string {
var notes []string
if f.TimeRange != "" {
notes = append(notes, "brave_llm: time_range not applied")
}
if f.Topic != "" && f.Topic != "general" {
notes = append(notes, "brave_llm: topic not applied")
}
if f.ExactMatch {
notes = append(notes, "brave_llm: exact_match not applied")
}
if f.IncludeDomainsMode == "prefer" && len(f.IncludeDomains) > 0 {
notes = append(notes, "brave_llm: include_domains_mode=prefer not applied")
}
if f.MinScore > 0 {
notes = append(notes, "brave_llm: min_score not applied")
}
return notes
}
func FilterKinds(f Filter) string {
var k []string
if len(f.IncludeDomains) > 0 || len(f.ExcludeDomains) > 0 {
k = append(k, "domains")
}
if f.TimeRange != "" || f.StartDate != "" || f.EndDate != "" {
k = append(k, "time")
}
if f.Topic != "" && f.Topic != "general" {
k = append(k, "topic")
}
if f.ExactMatch {
k = append(k, "exact")
}
if f.MinScore > 0 {
k = append(k, "score")
}
return strings.Join(k, ",")
}