Files
sift/internal/search/filter.go
T
kyle 8b26776cdd 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.
2026-09-29 15:30:19 -07:00

255 lines
5.0 KiB
Go

package search
import (
"strings"
"time"
"unicode/utf8"
)
const (
maxDomainList = 20
maxHostLen = 253
searxSiteCap = 3
)
var timeRangeLong = map[string]string{
"day": "day", "d": "day",
"week": "week", "w": "week",
"month": "month", "m": "month",
"year": "year", "y": "year",
}
// NormalizeSearch checks query-vs-queries and filter fields. errMsg is
// "invalid query" or "invalid search"; hint is empty or a field name.
func NormalizeSearch(query string, queries []string, f Filter) (qs []string, out Filter, errMsg, hint string) {
qs, errMsg = normalizeQueries(query, queries)
if errMsg != "" {
return nil, Filter{}, errMsg, ""
}
out, hint = normalizeFilter(f)
if hint != "" {
return nil, Filter{}, "invalid search", hint
}
if out.ExactMatch {
for _, q := range qs {
if !hasQuotedPhrase(q) {
return nil, Filter{}, "invalid search", "exact_match"
}
}
}
return qs, out, "", ""
}
func normalizeQueries(query string, queries []string) ([]string, string) {
q := strings.TrimSpace(query)
qs := make([]string, 0, len(queries))
for _, s := range queries {
qs = append(qs, strings.TrimSpace(s))
}
hasQ := q != ""
hasQS := len(qs) > 0
if hasQ && hasQS {
return nil, "invalid query"
}
if !hasQ && !hasQS {
return nil, "invalid query"
}
if hasQ {
qs = []string{q}
}
if len(qs) < 1 || len(qs) > 4 {
return nil, "invalid query"
}
for _, s := range qs {
if s == "" || utf8.RuneCountInString(s) > MaxQueryRunes {
return nil, "invalid query"
}
}
return qs, ""
}
func normalizeFilter(f Filter) (Filter, string) {
inc, hint := normalizeDomains(f.IncludeDomains, "include_domains")
if hint != "" {
return Filter{}, hint
}
exc, hint := normalizeDomains(f.ExcludeDomains, "exclude_domains")
if hint != "" {
return Filter{}, hint
}
mode := strings.TrimSpace(f.IncludeDomainsMode)
switch mode {
case "", "restrict", "prefer":
default:
return Filter{}, "include_domains_mode"
}
if mode != "" && len(inc) == 0 {
return Filter{}, "include_domains_mode"
}
if len(inc) > 0 && mode == "" {
mode = "restrict"
}
tr := strings.TrimSpace(f.TimeRange)
if tr != "" {
long, ok := timeRangeLong[tr]
if !ok {
return Filter{}, "time_range"
}
tr = long
}
start := strings.TrimSpace(f.StartDate)
end := strings.TrimSpace(f.EndDate)
if start != "" {
if !validDay(start) {
return Filter{}, "start_date"
}
}
if end != "" {
if !validDay(end) {
return Filter{}, "end_date"
}
}
if (start != "" || end != "") && tr != "" {
return Filter{}, "time_range"
}
if start != "" && end != "" {
st, _ := time.Parse("2006-01-02", start)
en, _ := time.Parse("2006-01-02", end)
if en.Before(st) {
return Filter{}, "end_date"
}
}
topic := strings.TrimSpace(f.Topic)
switch topic {
case "", "general", "news", "finance":
default:
return Filter{}, "topic"
}
ms := f.MinScore
if ms < 0 || ms > 1 {
return Filter{}, "min_score"
}
return Filter{
IncludeDomains: inc,
ExcludeDomains: exc,
IncludeDomainsMode: mode,
TimeRange: tr,
StartDate: start,
EndDate: end,
Topic: topic,
ExactMatch: f.ExactMatch,
MinScore: ms,
}, ""
}
func normalizeDomains(in []string, hint string) ([]string, string) {
if len(in) == 0 {
return nil, ""
}
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, raw := range in {
s := strings.TrimSpace(raw)
if s == "" {
continue
}
d, ok := normalizeDomain(s)
if !ok {
return nil, hint
}
if seen[d] {
continue
}
seen[d] = true
out = append(out, d)
}
if len(out) == 0 {
return nil, ""
}
if len(out) > maxDomainList {
return nil, hint
}
return out, ""
}
func normalizeDomain(s string) (string, bool) {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.TrimSuffix(s, ".")
if s == "" || len(s) > maxHostLen {
return "", false
}
if strings.ContainsAny(s, " :/@*?#[]") {
return "", false
}
labels := strings.Split(s, ".")
if len(labels) == 0 {
return "", false
}
for _, lab := range labels {
if !validLabel(lab) {
return "", false
}
}
return s, true
}
func validLabel(lab string) bool {
if lab == "" || len(lab) > 63 {
return false
}
if lab[0] == '-' || lab[len(lab)-1] == '-' {
return false
}
for i := 0; i < len(lab); i++ {
c := lab[i]
if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' {
continue
}
return false
}
return true
}
func validDay(s string) bool {
t, err := time.Parse("2006-01-02", s)
return err == nil && t.Format("2006-01-02") == s
}
// hasQuotedPhrase reports a balanced ASCII "…" pair with ≥1 rune inside and no unpaired quotes.
func hasQuotedPhrase(q string) bool {
in := false
n := 0
pairs := 0
for _, r := range q {
if r != '"' {
if in {
n++
}
continue
}
if !in {
in = true
n = 0
continue
}
if n == 0 {
return false
}
in = false
pairs++
}
return !in && pairs >= 1
}
func QueryRuneLen(qs []string) int {
n := 0
for _, q := range qs {
n += utf8.RuneCountInString(q)
}
return n
}