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:
@@ -0,0 +1,254 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user