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
+2 -2
View File
@@ -19,7 +19,7 @@ The server binds `127.0.0.1:8890` and is published on the tailnet by `tailscale
| `POST /v2/scrape` | Firecrawl-compatible fetch (`url`, `formats`) — what Hermes `web_extract` calls |
| `POST /v2/search` | Firecrawl-compatible search (`query`, `limit`) — what Hermes `web_search` calls |
| `POST /v1/fetch` | native fetch: `url`, `mode` (`clean`/`raw`/`digest`), `question`, `jq`, `context`, `recover`, and for PDFs `pages`, `grep` |
| `POST /v1/search` | native search: `query`, `question`, `k`, `rerank`, `digest` |
| `POST /v1/search` | native search: `query` or `queries`, `k`, `rerank`, `digest`, domain/time/topic/`exact_match`/`min_score` |
| `GET /metrics`, `/metrics.json` | Prometheus text or JSON; open on loopback, bearer elsewhere |
| `GET /healthz` | liveness |
@@ -34,7 +34,7 @@ sift serve [-listen addr] [-config /etc/sift/config.toml] [-keys /var/lib/sift/
sift fetch <url> [--question q] [--mode clean|raw|digest] [--jq filter] [--pages a-b] [--grep re]
[--no-context] [--recover] [--json]
sift extract <file.pdf> [--pages a-b] [--grep re] [--question q] [--mode clean|digest] [--json]
sift search "<query>" [--k n] [--json]
sift search "<query>" [--k n] [--json] [--include-domain host]... [--topic news] [--time-range month]
sift batch submit|status|get|cancel
sift keys add --caller name [--workspace ws] [--out file] | list | revoke --caller name
sift version
+89 -7
View File
@@ -8,15 +8,35 @@ import (
"io"
"net/http"
"os"
"strings"
)
type stringList []string
func (s *stringList) String() string { return strings.Join(*s, ",") }
func (s *stringList) Set(v string) error {
*s = append(*s, v)
return nil
}
func searchMain() {
fs := flag.NewFlagSet("search", flag.ContinueOnError)
fs.SetOutput(io.Discard)
k := fs.Int("k", 0, "result cap")
asJSON := fs.Bool("json", false, "native envelope")
var include, exclude stringList
fs.Var(&include, "include-domain", "restrict or prefer this host")
fs.Var(&exclude, "exclude-domain", "drop this host")
domainsMode := fs.String("domains-mode", "", "restrict|prefer")
timeRange := fs.String("time-range", "", "day|week|month|year")
startDate := fs.String("start-date", "", "YYYY-MM-DD")
endDate := fs.String("end-date", "", "YYYY-MM-DD")
topic := fs.String("topic", "", "general|news|finance")
exact := fs.Bool("exact-match", false, "require quoted phrases")
minScore := fs.Float64("min-score", 0, "Tavily score cut")
queriesFile := fs.String("queries-file", "", "JSON array of 1-4 queries")
args := os.Args[1:]
flagArgs, pos, err := splitAroundPositional(args, map[string]bool{"k": true})
flagArgs, pos, err := splitAroundPositional(args, searchValueFlags)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
@@ -25,20 +45,65 @@ func searchMain() {
fmt.Fprintln(os.Stderr, searchUsage)
os.Exit(2)
}
body := map[string]any{}
if *queriesFile != "" {
if len(pos) != 0 {
fmt.Fprintln(os.Stderr, searchUsage)
os.Exit(2)
}
raw, err := os.ReadFile(*queriesFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
var qs []string
if err := json.Unmarshal(raw, &qs); err != nil {
fmt.Fprintln(os.Stderr, "queries-file: want JSON array of strings")
os.Exit(2)
}
body["queries"] = qs
} else {
if len(pos) != 1 {
fmt.Fprintln(os.Stderr, searchUsage)
os.Exit(2)
}
query := pos[0]
body["query"] = pos[0]
}
if *k > 0 {
body["k"] = *k
}
if len(include) > 0 {
body["include_domains"] = []string(include)
}
if len(exclude) > 0 {
body["exclude_domains"] = []string(exclude)
}
if *domainsMode != "" {
body["include_domains_mode"] = *domainsMode
}
if *timeRange != "" {
body["time_range"] = *timeRange
}
if *startDate != "" {
body["start_date"] = *startDate
}
if *endDate != "" {
body["end_date"] = *endDate
}
if *topic != "" {
body["topic"] = *topic
}
if *exact {
body["exact_match"] = true
}
if *minScore > 0 {
body["min_score"] = *minScore
}
base, key, err := loadClient()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(5)
}
body := map[string]any{"query": query}
if *k > 0 {
body["k"] = *k
}
raw, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, base+"/v1/search", bytes.NewReader(raw))
if err != nil {
@@ -80,7 +145,24 @@ func searchMain() {
if sn, _ := m["snippet"].(string); sn != "" {
fmt.Println(sn)
}
if d, _ := m["published_date"].(string); d != "" {
fmt.Println(d)
}
}
}
const searchUsage = `usage: sift search "<query>" [--k N] [--json]`
var searchValueFlags = map[string]bool{
"k": true, "include-domain": true, "exclude-domain": true,
"domains-mode": true, "time-range": true, "start-date": true, "end-date": true,
"topic": true, "min-score": true, "queries-file": true,
}
const searchUsage = `usage: sift search "<query>" [--k n] [--json]
[--include-domain host]... [--exclude-domain host]...
[--domains-mode restrict|prefer]
[--time-range day|week|month|year]
[--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD]
[--topic general|news|finance]
[--exact-match]
[--min-score 0.5]
[--queries-file path]`
+1
View File
@@ -26,6 +26,7 @@ One row per task session that ends in a commit. Newest last.
| p6/07-api-endpoints | 2026-09-26 | done | 1 | gate: ok | none | The four /v1/batch handlers in new `internal/api/batch.go`: `batchSubmit` (POST) decodes `{"targets":[{url,mode,question,pages,grep,jq,context,recover}]}` flat like `/v1/fetch`, 400 "invalid batch" on bad json/empty list/empty url/bad mode (only empty,clean,raw,digest), then `PreCheck` per target → 422 "blocked: target <idx>: <reason>" with no job, then `Submit` mapping ErrTooManyTargets→422 "too many targets (max 32)", ErrCallerBusy→429+Retry-After 30, ErrBusy→429+Retry-After 60, ErrNoTargets→400, and 202 {success,job_id,status,targets,reused} (status "queued" new, current status reused). `batchStatus` (GET) and `batchResult` (GET {id}/{idx}) and `batchCancel` (DELETE) share `ownership` (unknown or not `c.Name` → 404, never 403; nil batch → 503); status is a summary with no results unless `?results=1` inlines the envelope (Meta+content) on done targets, idx validated 0..targets_total-1 else 404, cancel of a terminal job → 409 "job already finished" else 200 {success,status:cancelled}. `api.go` scoped to the rule-2 changes: `batchMaxBody` (256 KiB) const, the four routes in `New`, and `ServeHTTP` applying the per-path cap once (`/v1/batch`→256 KiB, `/v1/extract`→pdfMaxUpload, else 64 KiB) and collapsing every batch path to a fixed metrics label via `batchObservePath` (`/v1/batch` or `/v1/batch/{id}`, never a job id). Each handler logs one `batch` line with caller/job_id/targets/status-or-reused and never a URL/hostname/idem_key. Given test byte-identical; all api tests pass under -race -count=3; make gate ok. |
| p6/08-serve-config | 2026-09-26 | done | 1 | gate: ok | none | `[batch]` config in `internal/config`: the `Batch` type (disabled/targets_per_job/jobs_per_caller/jobs_total/workers/ttl/max_bytes) and `File.Batch`, plus `ParseSize` (trims spaces, accepts only binary KiB/MiB/GiB suffixes or a plain byte count, zero/negative/other → def). serve.go opens the dcache store when batch is on (`!cfg.Batch.Disabled`) alongside dixie/search — same path, same call, Warn on failure — then, before building the runner, reaps in-flight jobs a restart killed via `MarkInFlightFailed("restart", now)` with one `ObserveBatchJob("restart")` per reaped job and a `batch reaped` info log when n>0; builds `srv.Batch` from the config (TTL via `ParseDuration`, MaxBytes via `ParseSize`, runner defaults fill the zeros) only when enabled and the store opened; closes `srv.Batch` in both shutdown branches when non-nil. Added a commented `[batch]` block to dist/sift.toml.example. Given test byte-identical; all config and cmd tests pass under -race -count=3. |
| p6/09-cli-docs | 2026-09-26 | done | 1 | gate: ok | none | `cmd/sift/batchcmd.go`: `parseBatchArgs` (verb + unchanged `rest`; submit takes one arg, status one job id optionally preceded by `--results`, get a job id plus a non-negative-integer idx, cancel one job id, else a usage error), `batchMain` dispatch, one small function per verb, and `batchDo` (bearer header, decode the JSON envelope, print it indented on stdout, on non-2xx print `env["error"]` to stderr and `exitFor`). `submitMain` reads the file or stdin and `buildTargetsBody` accepts a JSON object with a non-empty `targets` array, a bare array of target objects, or a bare array of URL strings (each wrapped `{"url": s}`), validating locally before the call. `main.go`: one `case "batch"` and one help line. README "Batch" section (four endpoints, serial-per-job/one-worker/round-robin model, 32 targets/1-per-caller/8-total caps, 256 KiB body, idempotency, lease/TTL/restart, summary-vs-results, restricted redaction, `sift_batch_*` metrics); SKILL.md "Batch" section. Given test byte-identical; all cmd/sift tests pass under -race -count=3. |
| p3.1 | 2026-09-29 | done | 1 | gofmt/vet/race ok; make gate blocked by pre-existing staticcheck 0.6.1 vs go 1.26 | none | Native filters + multi-query RRF: Filter on Source.Search, SourceOut.Notes, Tavily auto_parameters false, SearXNG site:/time_range/categories, Brave notes, domain post-filter, Tavily min_score, sequential queries, JSON cache key, CLI flags, skill recipe. §11 tests in p31_*.go and search_filter_test.go. Guard/auth/orion pin untouched. |
## P5a review (owner, 2026-09-26)
+50 -10
View File
@@ -11,12 +11,22 @@ import (
type v1SearchReq struct {
Query string `json:"query"`
Queries []string `json:"queries"`
Question string `json:"question"`
K int `json:"k"`
Rerank *bool `json:"rerank"`
Digest bool `json:"digest"`
URL string `json:"url"`
Limit int `json:"limit"`
IncludeDomains []string `json:"include_domains"`
ExcludeDomains []string `json:"exclude_domains"`
IncludeDomainsMode string `json:"include_domains_mode"`
TimeRange string `json:"time_range"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Topic string `json:"topic"`
ExactMatch bool `json:"exact_match"`
MinScore *float64 `json:"min_score"`
}
type v2SearchReq struct {
@@ -42,9 +52,11 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, v2 bool) {
}
dec := json.NewDecoder(r.Body)
query := ""
var queries []string
k := 0
doRerank := true
digest := false
var f search.Filter
if v2 {
var req v2SearchReq
if err := dec.Decode(&req); err != nil && err.Error() != "EOF" {
@@ -55,13 +67,17 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, v2 bool) {
k = req.Limit
digest = req.Digest
_ = req.URL
if msg := search.ValidateQuery(query); msg != "" {
writeErr(w, 400, msg, "")
return
}
queries = []string{strings.TrimSpace(query)}
} else {
var req v1SearchReq
if err := dec.Decode(&req); err != nil && err.Error() != "EOF" {
writeErr(w, 400, "invalid query", "")
return
}
query = req.Query
k = req.K
if k == 0 {
k = req.Limit
@@ -72,11 +88,26 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, v2 bool) {
digest = req.Digest
_ = req.URL
_ = req.Question
raw := search.Filter{
IncludeDomains: req.IncludeDomains,
ExcludeDomains: req.ExcludeDomains,
IncludeDomainsMode: req.IncludeDomainsMode,
TimeRange: req.TimeRange,
StartDate: req.StartDate,
EndDate: req.EndDate,
Topic: req.Topic,
ExactMatch: req.ExactMatch,
}
if msg := search.ValidateQuery(query); msg != "" {
writeErr(w, 400, msg, "")
if req.MinScore != nil {
raw.MinScore = *req.MinScore
}
var errMsg, hint string
queries, f, errMsg, hint = search.NormalizeSearch(req.Query, req.Queries, raw)
if errMsg != "" {
writeErr(w, 400, errMsg, hint)
return
}
}
defK := search.DefaultK
if v2 {
defK = search.DefaultV2K
@@ -86,7 +117,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, v2 bool) {
writeErr(w, 422, "no search results", "")
return
}
res := s.Search.Run(r.Context(), strings.TrimSpace(query), k, doRerank, c.Name, s.callerClass(c))
res := s.Search.RunFilter(r.Context(), queries, k, doRerank, c.Name, s.callerClass(c), f)
warns := append([]string(nil), res.Warnings...)
if digest {
warns = append(warns, "digest ignored")
@@ -95,7 +126,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, v2 bool) {
writeErr(w, 422, "no search results", "")
return
}
s.logSearch(c, query, res)
s.logSearch(c, queries, f, res)
if v2 {
web := make([]map[string]any, 0, len(res.Hits))
for i, h := range res.Hits {
@@ -111,13 +142,17 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, v2 bool) {
}
results := make([]map[string]any, 0, len(res.Hits))
for _, h := range res.Hits {
results = append(results, map[string]any{
row := map[string]any{
"url": h.URL,
"title": h.Title,
"snippet": h.Snippet,
"score": h.Score,
"source": h.Source,
})
}
if h.PublishedDate != "" {
row["published_date"] = h.PublishedDate
}
results = append(results, row)
}
writeJSON(w, 200, map[string]any{
"results": results,
@@ -132,8 +167,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, v2 bool) {
})
}
func (s *Server) logSearch(c *auth.Caller, query string, res search.RunResult) {
qLen := len([]rune(query))
func (s *Server) logSearch(c *auth.Caller, queries []string, f search.Filter, res search.RunResult) {
qLen := search.QueryRuneLen(queries)
cache := "miss"
if res.Cached {
cache = "hit"
@@ -143,5 +178,10 @@ func (s *Server) logSearch(c *auth.Caller, query string, res search.RunResult) {
s.Log.Info("search", "caller", c.Name, "q_len", qLen, "sources", src, "n", len(res.Hits), "cache", cache)
return
}
s.Log.Info("search", "caller", c.Name, "q_len", qLen, "sources", src, "n", len(res.Hits), "cache", cache, "rerank_ms", res.RerankMS)
kinds := search.FilterKinds(f)
attrs := []any{"caller", c.Name, "q_len", qLen, "sources", src, "n", len(res.Hits), "cache", cache, "rerank_ms", res.RerankMS, "n_queries", len(queries)}
if kinds != "" {
attrs = append(attrs, "filters", kinds)
}
s.Log.Info("search", attrs...)
}
+117
View File
@@ -0,0 +1,117 @@
package api
import (
"context"
"encoding/json"
"strings"
"testing"
"git.wntrmute.dev/kyle/sift/internal/search"
)
type stubSearch struct {
hits []search.Hit
}
func (s stubSearch) Name() string { return search.NameSearxNG }
func (s stubSearch) Search(_ context.Context, _ string, _ int, _ search.Filter) search.SourceOut {
hits := s.hits
if hits == nil {
hits = []search.Hit{{URL: "https://ok.example/a", Title: "t", Snippet: "s", Source: search.NameSearxNG, PublishedDate: "2026-09-01"}}
}
return search.SourceOut{Hits: hits}
}
func TestSearchValidationTable(t *testing.T) {
s, key, _, _, _ := searchServer(t)
s.Search.Sources = []search.Source{stubSearch{}}
cases := []struct {
body map[string]any
err string
hint string
}{
{map[string]any{"query": ""}, "invalid query", ""},
{map[string]any{"query": "a", "queries": []string{"b"}}, "invalid query", ""},
{map[string]any{"queries": []string{"a", "b", "c", "d", "e"}}, "invalid query", ""},
{map[string]any{"queries": []string{"a", ""}}, "invalid query", ""},
{map[string]any{"query": "q", "include_domains": []string{"a.com OR b.com"}}, "invalid search", "include_domains"},
{map[string]any{"query": "q", "include_domains_mode": "prefer"}, "invalid search", "include_domains_mode"},
{map[string]any{"query": "q", "time_range": "hour"}, "invalid search", "time_range"},
{map[string]any{"query": "q", "time_range": "day", "start_date": "2026-09-01"}, "invalid search", "time_range"},
{map[string]any{"query": "q", "start_date": "2026-09-10", "end_date": "2026-09-01"}, "invalid search", "end_date"},
{map[string]any{"query": "q", "topic": "sports"}, "invalid search", "topic"},
{map[string]any{"query": "q", "min_score": -1}, "invalid search", "min_score"},
{map[string]any{"query": "q", "min_score": 1.1}, "invalid search", "min_score"},
{map[string]any{"query": "no quotes", "exact_match": true}, "invalid search", "exact_match"},
}
for _, tc := range cases {
rec := postJSON(t, s, "/v1/search", key, tc.body)
if rec.Code != 400 {
t.Fatalf("%v: %d %s", tc.body, rec.Code, rec.Body.String())
}
var env map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env["error"] != tc.err {
t.Fatalf("%v error %v want %q", tc.body, env["error"], tc.err)
}
h, _ := env["hint"].(string)
if h != tc.hint {
t.Fatalf("%v hint %q want %q", tc.body, h, tc.hint)
}
}
rec := postJSON(t, s, "/v1/search", key, map[string]any{"query": "q", "include_domains": []string{"oracle.com"}})
if rec.Code != 200 {
t.Fatalf("oracle.com %d %s", rec.Code, rec.Body.String())
}
rec = postJSON(t, s, "/v1/search", key, map[string]any{"query": `find "phrase"`, "exact_match": true})
if rec.Code != 200 {
t.Fatalf("exact_match quoted %d %s", rec.Code, rec.Body.String())
}
}
func TestV2SearchNoPublishedDate(t *testing.T) {
s, key, _, _, _ := searchServer(t)
s.Search.Sources = []search.Source{stubSearch{}}
rec := postJSON(t, s, "/v2/search", key, map[string]any{"query": "contract", "limit": 3, "include_domains": []string{"secret.example"}})
if rec.Code != 200 {
t.Fatalf("%d %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "published_date") {
t.Fatalf("v2 leaked published_date: %s", rec.Body.String())
}
var env map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
data, _ := env["data"].(map[string]any)
web, _ := data["web"].([]any)
if len(web) < 1 {
t.Fatalf("%v", env)
}
}
func TestSearchFilterLogsNoSecrets(t *testing.T) {
s, fleet, gs, h, _ := searchServer(t)
s.Search.Sources = []search.Source{stubSearch{}}
body := map[string]any{
"queries": []string{"alpha secret", "beta secret", "gamma secret"},
"include_domains": []string{"secret.example"},
"rerank": false,
}
rec := postJSON(t, s, "/v1/search", gs, body)
if rec.Code != 200 {
t.Fatalf("gs %d %s", rec.Code, rec.Body.String())
}
rec = postJSON(t, s, "/v1/search", fleet, body)
if rec.Code != 200 {
t.Fatalf("fleet %d %s", rec.Code, rec.Body.String())
}
dump := h.dump()
for _, bad := range []string{"secret.example", "alpha secret", "beta secret", "gamma secret"} {
if strings.Contains(dump, bad) {
t.Fatalf("log leak %q:\n%s", bad, dump)
}
}
}
-13
View File
@@ -11,8 +11,6 @@ import (
"log/slog"
"math"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -181,17 +179,6 @@ func QuestionEmbedKey(question, embedModel, callerClass string) string {
return hex.EncodeToString(sum[:])
}
func SearchKey(query string, sources []string, k int, hour time.Time, callerClass string) string {
if callerClass == "" {
callerClass = ClassFleet
}
src := append([]string(nil), sources...)
sort.Strings(src)
bucket := hour.UTC().Format("2006-01-02T15")
sum := sha256.Sum256([]byte(NormalizeQuestion(query) + "\x00" + strings.Join(src, ",") + "\x00" + strconv.Itoa(k) + "\x00" + bucket + "\x00" + callerClass))
return hex.EncodeToString(sum[:])
}
type DigestHit struct {
Text string
ChunksUsed int
+6
View File
@@ -109,6 +109,12 @@ func TestSearchKeyPartitions(t *testing.T) {
if SearchKey("hello", []string{"searxng", "tavily"}, 8, now.Add(time.Hour), ClassFleet) == a {
t.Fatal("hour")
}
if EncodeSearchKey(SearchKeyInput{Queries: []string{"hello"}, Sources: []string{"searxng"}, K: 8, Hour: now, CallerClass: ClassFleet}) == EncodeSearchKey(SearchKeyInput{Queries: []string{"hello"}, Sources: []string{"searxng"}, K: 8, Hour: now, CallerClass: ClassFleet, IncludeDomains: []string{"a.com"}}) {
t.Fatal("include_domains")
}
if EncodeSearchKey(SearchKeyInput{Queries: []string{"hello"}, Sources: []string{"searxng"}, K: 8, Hour: now, CallerClass: ClassFleet}) != EncodeSearchKey(SearchKeyInput{Queries: []string{"hello"}, Sources: []string{"searxng"}, K: 8, Hour: now, CallerClass: ClassFleet, Topic: "general"}) {
t.Fatal("topic general")
}
}
func TestCorruptDBBypass(t *testing.T) {
+108
View File
@@ -0,0 +1,108 @@
package dcache
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strconv"
"time"
)
// SearchKeyInput is the canonical cache identity for a search. Callers pass
// already-normalised filter fields (long time_range, sorted domains optional).
type SearchKeyInput struct {
Queries []string
Sources []string
K int
Hour time.Time
CallerClass string
IncludeDomains []string
ExcludeDomains []string
IncludeDomainsMode string
TimeRange string
StartDate string
EndDate string
Topic string
ExactMatch bool
MinScore float64
}
func SearchKey(query string, sources []string, k int, hour time.Time, callerClass string) string {
return EncodeSearchKey(SearchKeyInput{
Queries: []string{query},
Sources: sources,
K: k,
Hour: hour,
CallerClass: callerClass,
})
}
func EncodeSearchKey(in SearchKeyInput) string {
if in.CallerClass == "" {
in.CallerClass = ClassFleet
}
src := append([]string(nil), in.Sources...)
sort.Strings(src)
qs := make([]string, len(in.Queries))
for i, q := range in.Queries {
qs[i] = NormalizeQuestion(q)
}
topic := in.Topic
if topic == "" {
topic = "general"
}
m := map[string]any{
"caller_class": in.CallerClass,
"exact_match": in.ExactMatch,
"hour": in.Hour.UTC().Format("2006-01-02T15"),
"k": in.K,
"min_score": strconv.FormatFloat(in.MinScore, 'f', -1, 64),
"queries": qs,
"sources": src,
"topic": topic,
}
if inc := sortedUnique(in.IncludeDomains); len(inc) > 0 {
m["include_domains"] = inc
mode := in.IncludeDomainsMode
if mode == "" {
mode = "restrict"
}
m["include_domains_mode"] = mode
}
if exc := sortedUnique(in.ExcludeDomains); len(exc) > 0 {
m["exclude_domains"] = exc
}
if in.TimeRange != "" {
m["time_range"] = in.TimeRange
}
if in.StartDate != "" {
m["start_date"] = in.StartDate
}
if in.EndDate != "" {
m["end_date"] = in.EndDate
}
b, _ := json.Marshal(m)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func sortedUnique(in []string) []string {
if len(in) == 0 {
return nil
}
out := append([]string(nil), in...)
sort.Strings(out)
n := 0
for _, s := range out {
if s == "" {
continue
}
if n > 0 && out[n-1] == s {
continue
}
out[n] = s
n++
}
return out[:n]
}
+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, ",")
}
+1 -99
View File
@@ -2,7 +2,6 @@ package search
import (
"context"
"encoding/json"
"log/slog"
"sort"
"sync"
@@ -90,104 +89,7 @@ func (e *Engine) timeout() time.Duration {
}
func (e *Engine) Run(ctx context.Context, query string, k int, doRerank bool, caller, class string) RunResult {
start := e.now()
k = ClampK(k, e.DefaultK)
if e.DefaultK <= 0 {
k = ClampK(k, DefaultK)
}
if class == "" {
class = dcache.ClassFleet
}
attempted := e.attemptedNames(class)
key := dcache.SearchKey(query, attempted, k, e.now(), class)
if b, ok := e.Cache.GetSearch(key); ok {
var hits []Hit
if json.Unmarshal(b, &hits) == nil {
e.mu.Lock()
e.CacheHit++
e.mu.Unlock()
return RunResult{Hits: clampHits(hits, k), Cached: true, Attempted: attempted, SearchMS: e.now().Sub(start).Milliseconds()}
}
}
e.mu.Lock()
e.CacheMiss++
e.mu.Unlock()
type one struct {
name string
out SourceOut
skip bool
}
outs := make([]one, len(e.Sources))
var wg sync.WaitGroup
for i, src := range e.Sources {
name := src.Name()
if name == NameTavily && e.tavilyCapped(class) {
outs[i] = one{name: name, out: SourceOut{Warn: "tavily: daily cap"}, skip: true}
e.addCapped(NameTavily)
continue
}
wg.Add(1)
go func(i int, src Source, name string) {
defer wg.Done()
sctx, cancel := context.WithTimeout(ctx, e.timeout())
defer cancel()
outs[i] = one{name: name, out: src.Search(sctx, query, k)}
}(i, src, name)
}
wg.Wait()
var lists []namedHits
var hits []Hit
var warns []string
var okNames, failNames, att []string
degraded := false
for _, o := range outs {
if o.skip {
if o.out.Warn != "" {
warns = append(warns, o.out.Warn)
}
continue
}
att = append(att, o.name)
e.addQuery(caller, o.name)
if o.out.Warn != "" {
warns = append(warns, o.out.Warn)
failNames = append(failNames, o.name)
degraded = true
e.addErr(o.name, o.out.Warn)
continue
}
okNames = append(okNames, o.name)
lists = append(lists, namedHits{name: o.name, hits: o.out.Hits})
if o.out.Credits > 0 {
e.addCredits(caller, o.name, o.out.Credits)
if o.name == NameTavily && e.Cache != nil {
e.Cache.AddSearchCredits(tavilyCapSource(class), e.now().UTC().Format("2006-01-02"), o.out.Credits)
}
}
}
hits = mergeSearch(e, lists, doRerank)
var rerankMS int64
hits = clampHits(hits, k)
if !degraded && e.Cache != nil && len(att) > 0 {
payload, _ := json.Marshal(hits)
ck := dcache.SearchKey(query, att, k, e.now(), class)
e.Cache.PutSearch(ck, payload, e.ttl())
}
return RunResult{
Hits: hits,
Warnings: warns,
SourcesOK: okNames,
SourcesFailed: failNames,
SearchMS: e.now().Sub(start).Milliseconds(),
RerankMS: rerankMS,
Attempted: att,
}
return e.RunFilter(ctx, []string{query}, k, doRerank, caller, class, Filter{})
}
func (e *Engine) attemptedNames(class string) []string {
+3 -2
View File
@@ -14,12 +14,13 @@ type fakeSrc struct {
name string
hits []Hit
warn string
notes []string
delay time.Duration
credits int
}
func (f fakeSrc) Name() string { return f.name }
func (f fakeSrc) Search(ctx context.Context, query string, k int) SourceOut {
func (f fakeSrc) Search(ctx context.Context, query string, k int, _ Filter) SourceOut {
if f.delay > 0 {
select {
case <-time.After(f.delay):
@@ -27,7 +28,7 @@ func (f fakeSrc) Search(ctx context.Context, query string, k int) SourceOut {
return SourceOut{Warn: f.name + ": timeout"}
}
}
return SourceOut{Hits: f.hits, Warn: f.warn, Credits: f.credits}
return SourceOut{Hits: f.hits, Warn: f.warn, Credits: f.credits, Notes: f.notes}
}
type stubEmb struct {
+254
View File
@@ -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
}
+159
View File
@@ -0,0 +1,159 @@
package search
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.wntrmute.dev/kyle/sift/internal/search/dial"
)
func TestTavilyFiltersOutbound(t *testing.T) {
var gotBody []byte
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{"answer":"SECRET","results":[{"url":"https://ex.com/a","title":"A","content":"snip","score":0.9,"published_date":"2026-09-01"}]}`))
}))
defer ts.Close()
c, err := dial.New(ts.URL, dial.Options{Loopback: true, Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
f := Filter{
IncludeDomains: []string{"ex.com"},
IncludeDomainsMode: "restrict",
TimeRange: "month",
Topic: "news",
ExactMatch: true,
}
out := (&Tavily{Client: c, Key: "k"}).Search(context.Background(), `docs "phrase"`, 5, f)
if out.Warn != "" {
t.Fatal(out.Warn)
}
var req map[string]any
if err := json.Unmarshal(gotBody, &req); err != nil {
t.Fatal(err)
}
if req["include_answer"] != false || req["auto_parameters"] != false || req["search_depth"] != "basic" {
t.Fatalf("%+v", req)
}
if req["query"] != `docs "phrase"` {
t.Fatalf("query rewritten: %v", req["query"])
}
if req["exact_match"] != true {
t.Fatalf("exact_match %v", req["exact_match"])
}
if _, ok := req["start_date"]; ok {
t.Fatal("time_range with dates")
}
if req["time_range"] != "month" || req["topic"] != "news" {
t.Fatalf("%+v", req)
}
if req["include_published_date"] != true {
t.Fatal("want include_published_date")
}
if _, ok := req["include_domains_mode"]; !ok {
t.Fatal("mode missing")
}
if strings.Contains(string(gotBody), "extract") || strings.Contains(string(gotBody), "/crawl") {
t.Fatal(string(gotBody))
}
if len(out.Hits) != 1 || out.Hits[0].PublishedDate != "2026-09-01" {
t.Fatalf("%+v", out.Hits)
}
}
func TestTavilyModeOmittedWithoutInclude(t *testing.T) {
var gotBody []byte
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{"results":[{"url":"https://ex.com/a","title":"A","content":"s","score":0.2}]}`))
}))
defer ts.Close()
c, err := dial.New(ts.URL, dial.Options{Loopback: true, Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
out := (&Tavily{Client: c, Key: "k"}).Search(context.Background(), "q", 5, Filter{IncludeDomainsMode: "prefer"})
if out.Warn != "" {
t.Fatal(out.Warn)
}
var req map[string]any
_ = json.Unmarshal(gotBody, &req)
if _, ok := req["include_domains_mode"]; ok {
t.Fatalf("mode without include: %+v", req)
}
if out.Hits[0].PublishedDate != "" {
t.Fatal("missing date must omit")
}
}
func TestSearxNGSiteAndParams(t *testing.T) {
var gotQ, gotTR, gotCat string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQ = r.URL.Query().Get("q")
gotTR = r.URL.Query().Get("time_range")
gotCat = r.URL.Query().Get("categories")
if strings.Contains(gotQ, "!news") || strings.Contains(gotQ, "!month") {
t.Errorf("bang in q: %s", gotQ)
}
_, _ = w.Write([]byte(`{"results":[{"url":"https://a.example/","title":"a","content":"aa","score":1,"publishedDate":"Mon, 01 Sep 2026 00:00:00 GMT"}]}`))
}))
defer ts.Close()
c, err := dial.New(ts.URL, dial.Options{Loopback: true, Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
f := Filter{IncludeDomains: []string{"a.example"}, TimeRange: "month", Topic: "news", ExactMatch: true}
out := (&SearxNG{Client: c}).Search(context.Background(), `"phrase" here`, 8, f)
if !strings.HasPrefix(gotQ, "site:a.example ") {
t.Fatalf("q %q", gotQ)
}
if !strings.Contains(gotQ, `"phrase" here`) || strings.Count(gotQ, `"phrase"`) != 1 {
t.Fatalf("wrapped q %q", gotQ)
}
if gotTR != "month" || gotCat != "news" {
t.Fatalf("params tr=%q cat=%q", gotTR, gotCat)
}
if out.Hits[0].PublishedDate == "" {
t.Fatal("publishedDate")
}
}
func TestBraveNotesAndUnchangedURL(t *testing.T) {
var gotURL string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotURL = r.URL.String()
_, _ = w.Write([]byte(`{"sources":[{"url":"https://off.example/x","title":"S","snippet":"hello"}]}`))
}))
defer ts.Close()
c, err := dial.New(ts.URL, dial.Options{Loopback: true, Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
f := Filter{TimeRange: "month", Topic: "news", ExactMatch: true, MinScore: 0.5, IncludeDomains: []string{"openai.com"}, IncludeDomainsMode: "prefer"}
out := (&BraveLLM{Client: c, Key: "k"}).Search(context.Background(), `"phrase"`, 5, f)
if !strings.Contains(gotURL, "q=") {
t.Fatalf("url %s", gotURL)
}
if strings.Contains(gotURL, "time_range") || strings.Contains(gotURL, "site:") {
t.Fatalf("brave grew params: %s", gotURL)
}
note := strings.Join(out.Notes, " ")
for _, want := range []string{
"brave_llm: time_range not applied",
"brave_llm: topic not applied",
"brave_llm: exact_match not applied",
"brave_llm: include_domains_mode=prefer not applied",
"brave_llm: min_score not applied",
} {
if !strings.Contains(note, want) {
t.Fatalf("missing %q in %v", want, out.Notes)
}
}
}
+269
View File
@@ -0,0 +1,269 @@
package search
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"git.wntrmute.dev/kyle/sift/internal/dcache"
)
type qSrc struct {
name string
byQuery map[string][]Hit
notes []string
credits int
}
func (q qSrc) Name() string { return q.name }
func (q qSrc) Search(_ context.Context, query string, _ int, _ Filter) SourceOut {
return SourceOut{Hits: q.byQuery[query], Notes: q.notes, Credits: q.credits}
}
func TestNotesDoNotFailSource(t *testing.T) {
db, err := dcache.Open(filepath.Join(t.TempDir(), "c.db"), 1<<20, nil)
if err != nil {
t.Fatal(err)
}
defer db.Close()
e := &Engine{
Sources: []Source{fakeSrc{
name: NameSearxNG,
hits: []Hit{{URL: "https://ok.example/a", Source: NameSearxNG}},
notes: []string{"searxng: min_score not applied"},
}},
DefaultK: 8, Cache: db, RerankOn: false,
}
r1 := e.RunFilter(context.Background(), []string{"q"}, 8, false, "titan", dcache.ClassFleet, Filter{MinScore: 0.5})
if len(r1.Hits) != 1 {
t.Fatalf("hits %+v", r1.Hits)
}
if len(r1.SourcesOK) != 1 || r1.SourcesOK[0] != NameSearxNG {
t.Fatalf("ok %v fail %v", r1.SourcesOK, r1.SourcesFailed)
}
if r1.Cached {
t.Fatal("first miss")
}
joined := strings.Join(r1.Warnings, " ")
if !strings.Contains(joined, "min_score not applied") {
t.Fatalf("warnings %v", r1.Warnings)
}
r2 := e.RunFilter(context.Background(), []string{"q"}, 8, false, "titan", dcache.ClassFleet, Filter{MinScore: 0.5})
if !r2.Cached {
t.Fatal("notes must still cache")
}
}
func TestWarnStillDisablesCache(t *testing.T) {
db, err := dcache.Open(filepath.Join(t.TempDir(), "c.db"), 1<<20, nil)
if err != nil {
t.Fatal(err)
}
defer db.Close()
e := &Engine{
Sources: []Source{
fakeSrc{name: NameSearxNG, hits: []Hit{{URL: "https://ok.example/a", Source: NameSearxNG}}},
fakeSrc{name: NameBraveLLM, warn: "brave_llm: 500"},
},
DefaultK: 8, Cache: db, RerankOn: false,
}
e.Run(context.Background(), "q", 8, false, "titan", dcache.ClassFleet)
r := e.Run(context.Background(), "q", 8, false, "titan", dcache.ClassFleet)
if r.Cached {
t.Fatal("warn cached")
}
}
func TestMinScoreTavilyOnly(t *testing.T) {
e := &Engine{
Sources: []Source{
fakeSrc{name: NameSearxNG, hits: []Hit{{URL: "https://s.example/a", Score: 0.01, Source: NameSearxNG}}, notes: []string{"searxng: min_score not applied"}},
fakeSrc{name: NameBraveLLM, hits: []Hit{{URL: "https://b.example/a", Source: NameBraveLLM}}, notes: []string{"brave_llm: min_score not applied"}},
fakeSrc{name: NameTavily, hits: []Hit{
{URL: "https://t.example/low", Score: 0.4, Source: NameTavily},
{URL: "https://t.example/high", Score: 0.9, Source: NameTavily},
}},
},
DefaultK: 8, RerankOn: false,
}
r := e.RunFilter(context.Background(), []string{"q"}, 8, false, "titan", dcache.ClassFleet, Filter{MinScore: 0.5})
var urls []string
for _, h := range r.Hits {
urls = append(urls, h.URL)
if h.URL == "https://t.example/low" {
t.Fatal("tavily 0.4 kept")
}
}
joined := strings.Join(urls, " ")
if !strings.Contains(joined, "s.example") || !strings.Contains(joined, "b.example") || !strings.Contains(joined, "t.example/high") {
t.Fatalf("hits %v", urls)
}
w := strings.Join(r.Warnings, " ")
if !strings.Contains(w, "searxng: min_score not applied") || !strings.Contains(w, "brave_llm: min_score not applied") {
t.Fatalf("warnings %v", r.Warnings)
}
}
func TestDomainPostFilter(t *testing.T) {
hits := []Hit{
{URL: "https://openai.com/blog", Source: NameBraveLLM},
{URL: "https://sub.openai.com/x", Source: NameSearxNG},
{URL: "https://pinterest.com/pin", Source: NameBraveLLM},
{URL: "https://example.com/a", Source: NameSearxNG},
}
f := Filter{IncludeDomains: []string{"openai.com"}, IncludeDomainsMode: "restrict"}
got := filterDomains(hits, f)
if len(got) != 2 {
t.Fatalf("%+v", got)
}
f.IncludeDomainsMode = "prefer"
got = filterDomains(hits, f)
if len(got) != 4 {
t.Fatalf("prefer %+v", got)
}
got = filterDomains(hits, Filter{ExcludeDomains: []string{"pinterest.com"}})
if len(got) != 3 {
t.Fatalf("exclude %+v", got)
}
e := &Engine{
Sources: []Source{fakeSrc{name: NameBraveLLM, hits: []Hit{{URL: "https://off.example/x", Source: NameBraveLLM}}}},
DefaultK: 8, RerankOn: false,
}
r := e.RunFilter(context.Background(), []string{"q"}, 8, false, "titan", dcache.ClassFleet, Filter{IncludeDomains: []string{"openai.com"}, IncludeDomainsMode: "restrict"})
if len(r.Hits) != 0 {
t.Fatalf("brave off-domain kept %+v", r.Hits)
}
r = e.RunFilter(context.Background(), []string{"q"}, 8, false, "titan", dcache.ClassFleet, Filter{IncludeDomains: []string{"openai.com"}, IncludeDomainsMode: "prefer"})
if len(r.Hits) != 1 {
t.Fatalf("prefer dropped brave %+v", r.Hits)
}
}
func TestNormalizeDomainOR(t *testing.T) {
_, _, msg, hint := NormalizeSearch("q", nil, Filter{IncludeDomains: []string{"a.com OR b.com"}})
if msg != "invalid search" || hint != "include_domains" {
t.Fatalf("%q %q", msg, hint)
}
_, out, msg, hint := NormalizeSearch("q", nil, Filter{IncludeDomains: []string{"oracle.com"}})
if msg != "" || hint != "" || len(out.IncludeDomains) != 1 || out.IncludeDomains[0] != "oracle.com" {
t.Fatalf("%q %q %+v", msg, hint, out)
}
}
func TestMultiQueryRRF(t *testing.T) {
q1 := make([]Hit, 0, 3)
for _, u := range []string{"https://a.example/1", "https://a.example/2", "https://a.example/3"} {
q1 = append(q1, Hit{URL: u, Source: NameSearxNG})
}
e := &Engine{
Sources: []Source{qSrc{
name: NameSearxNG,
byQuery: map[string][]Hit{
"one": q1,
"two": {{URL: "https://b.example/only", Source: NameSearxNG}},
},
}},
DefaultK: 3, RerankOn: true,
}
r := e.RunFilter(context.Background(), []string{"one", "two"}, 3, true, "titan", dcache.ClassFleet, Filter{})
found := false
for _, h := range r.Hits {
if h.URL == "https://b.example/only" {
found = true
}
}
if !found {
t.Fatalf("q2 hit missing: %+v", r.Hits)
}
}
func TestCapMidBatchNoCache(t *testing.T) {
db, err := dcache.Open(filepath.Join(t.TempDir(), "c.db"), 1<<20, nil)
if err != nil {
t.Fatal(err)
}
defer db.Close()
e := &Engine{
Sources: []Source{qSrc{
name: NameTavily,
byQuery: map[string][]Hit{
"one": {{URL: "https://t.example/1", Source: NameTavily}},
"two": {{URL: "https://t.example/2", Source: NameTavily}},
},
credits: 1,
}},
DefaultK: 8, Cache: db, TavilyCap: 1, RerankOn: false,
}
r := e.RunFilter(context.Background(), []string{"one", "two"}, 8, false, "titan", dcache.ClassFleet, Filter{})
n := 0
for _, w := range r.Warnings {
if strings.Contains(w, "daily cap") {
n++
}
}
if n != 1 {
t.Fatalf("warnings %v", r.Warnings)
}
r2 := e.RunFilter(context.Background(), []string{"one", "two"}, 8, false, "titan", dcache.ClassFleet, Filter{})
if r2.Cached {
t.Fatal("mid-batch cap must not cache")
}
}
func TestCacheKeyFilters(t *testing.T) {
db, err := dcache.Open(filepath.Join(t.TempDir(), "c.db"), 1<<20, nil)
if err != nil {
t.Fatal(err)
}
defer db.Close()
now := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC)
src := fakeSrc{name: NameSearxNG, hits: []Hit{{URL: "https://ok.example/a", Source: NameSearxNG}}}
e := &Engine{Sources: []Source{src}, DefaultK: 8, Cache: db, RerankOn: false, Now: func() time.Time { return now }}
base := Filter{}
e.RunFilter(context.Background(), []string{"hello"}, 8, false, "titan", dcache.ClassFleet, base)
if e.RunFilter(context.Background(), []string{"hello"}, 8, false, "titan", dcache.ClassFleet, Filter{IncludeDomains: []string{"openai.com"}, IncludeDomainsMode: "restrict"}).Cached {
t.Fatal("different include_domains must miss")
}
e.RunFilter(context.Background(), []string{"hello"}, 8, false, "titan", dcache.ClassFleet, Filter{IncludeDomains: []string{"openai.com"}, IncludeDomainsMode: "restrict"})
if !e.RunFilter(context.Background(), []string{"hello"}, 8, false, "titan", dcache.ClassFleet, Filter{IncludeDomains: []string{"openai.com"}, IncludeDomainsMode: "restrict"}).Cached {
t.Fatal("same filters must hit")
}
e2 := &Engine{Sources: []Source{src}, DefaultK: 8, Cache: db, RerankOn: false, Now: func() time.Time { return now }}
e2.RunFilter(context.Background(), []string{"topicq"}, 8, false, "titan", dcache.ClassFleet, Filter{})
if !e2.RunFilter(context.Background(), []string{"topicq"}, 8, false, "titan", dcache.ClassFleet, Filter{Topic: "general"}).Cached {
t.Fatal("omitted topic and general must share a key")
}
_, day, _, _ := NormalizeSearch("q", nil, Filter{TimeRange: "d"})
_, long, _, _ := NormalizeSearch("q", nil, Filter{TimeRange: "day"})
if day.TimeRange != long.TimeRange || day.TimeRange != "day" {
t.Fatalf("%q %q", day.TimeRange, long.TimeRange)
}
e3 := &Engine{Sources: []Source{src}, DefaultK: 8, Cache: db, RerankOn: false, Now: func() time.Time { return now }}
e3.RunFilter(context.Background(), []string{"timeq"}, 8, false, "titan", dcache.ClassFleet, day)
if !e3.RunFilter(context.Background(), []string{"timeq"}, 8, false, "titan", dcache.ClassFleet, long).Cached {
t.Fatal("d and day must share a key")
}
}
func TestQuotedPhrase(t *testing.T) {
if !hasQuotedPhrase(`docs "phrase" x`) || hasQuotedPhrase(`docs phrase`) || hasQuotedPhrase(`"unclosed`) || hasQuotedPhrase(`""`) {
t.Fatal("quoted phrase")
}
}
func TestNormalizeQueries(t *testing.T) {
_, _, msg, _ := NormalizeSearch("a", []string{"b"}, Filter{})
if msg != "invalid query" {
t.Fatalf("both: %q", msg)
}
qs, _, msg, _ := NormalizeSearch("", []string{"a", "b", "c", "d", "e"}, Filter{})
if msg != "invalid query" || qs != nil {
t.Fatalf("five: %q", msg)
}
_, _, msg, hint := NormalizeSearch(`no quotes`, nil, Filter{ExactMatch: true})
if msg != "invalid search" || hint != "exact_match" {
t.Fatalf("%q %q", msg, hint)
}
}
+223
View File
@@ -0,0 +1,223 @@
package search
import (
"context"
"encoding/json"
"sync"
"git.wntrmute.dev/kyle/sift/internal/dcache"
)
type srcOut struct {
name string
out SourceOut
skip bool
}
type queryFan struct {
hits []Hit
warns []string
notes []string
okNames []string
failNames []string
att []string
degraded bool
tavSkip bool
tavUsed bool
}
func (e *Engine) RunFilter(ctx context.Context, queries []string, k int, doRerank bool, caller, class string, f Filter) RunResult {
start := e.now()
k = ClampK(k, e.DefaultK)
if e.DefaultK <= 0 {
k = ClampK(k, DefaultK)
}
if class == "" {
class = dcache.ClassFleet
}
if len(queries) == 0 {
queries = []string{""}
}
attempted := e.attemptedNames(class)
key := e.searchKey(queries, attempted, k, class, f)
if b, ok := e.Cache.GetSearch(key); ok {
var hits []Hit
if json.Unmarshal(b, &hits) == nil {
e.mu.Lock()
e.CacheHit++
e.mu.Unlock()
return RunResult{Hits: clampHits(hits, k), Cached: true, Attempted: attempted, SearchMS: e.now().Sub(start).Milliseconds()}
}
}
e.mu.Lock()
e.CacheMiss++
e.mu.Unlock()
var lists [][]Hit
var warns, notes []string
okSeen, failSeen := map[string]bool{}, map[string]bool{}
var okNames, failNames []string
degraded := false
capMid := false
sawTavily := false
var lastAtt []string
for _, q := range queries {
one := e.fanInQuery(ctx, q, k, doRerank, caller, class, f)
if one.tavUsed {
sawTavily = true
}
if one.tavSkip && sawTavily {
capMid = true
}
if one.degraded {
degraded = true
}
warns = append(warns, one.warns...)
notes = append(notes, one.notes...)
for _, n := range one.okNames {
if !okSeen[n] {
okSeen[n] = true
okNames = append(okNames, n)
}
}
for _, n := range one.failNames {
if !failSeen[n] {
failSeen[n] = true
failNames = append(failNames, n)
}
}
if len(one.att) > 0 {
lastAtt = one.att
}
lists = append(lists, one.hits)
}
var hits []Hit
if len(lists) <= 1 {
if len(lists) == 1 {
hits = lists[0]
}
} else {
hits = rrfMerge(lists)
hits = Dedupe(hits)
}
hits = clampHits(hits, k)
warns = append(warns, notes...)
warns = dedupeStrings(warns)
att := lastAtt
if len(att) == 0 {
att = attempted
}
if !degraded && !capMid && e.Cache != nil && len(att) > 0 {
payload, _ := json.Marshal(hits)
ck := e.searchKey(queries, att, k, class, f)
e.Cache.PutSearch(ck, payload, e.ttl())
}
return RunResult{
Hits: hits,
Warnings: warns,
SourcesOK: okNames,
SourcesFailed: failNames,
SearchMS: e.now().Sub(start).Milliseconds(),
Attempted: att,
}
}
func (e *Engine) searchKey(queries []string, sources []string, k int, class string, f Filter) string {
return dcache.EncodeSearchKey(dcache.SearchKeyInput{
Queries: queries,
Sources: sources,
K: k,
Hour: e.now(),
CallerClass: class,
IncludeDomains: f.IncludeDomains,
ExcludeDomains: f.ExcludeDomains,
IncludeDomainsMode: f.IncludeDomainsMode,
TimeRange: f.TimeRange,
StartDate: f.StartDate,
EndDate: f.EndDate,
Topic: f.Topic,
ExactMatch: f.ExactMatch,
MinScore: f.MinScore,
})
}
func (e *Engine) fanInQuery(ctx context.Context, query string, k int, doRerank bool, caller, class string, f Filter) queryFan {
outs := make([]srcOut, len(e.Sources))
var wg sync.WaitGroup
tavSkip := false
for i, src := range e.Sources {
name := src.Name()
if name == NameTavily && e.tavilyCapped(class) {
outs[i] = srcOut{name: name, out: SourceOut{Warn: "tavily: daily cap"}, skip: true}
e.addCapped(NameTavily)
tavSkip = true
continue
}
wg.Add(1)
go func(i int, src Source, name string) {
defer wg.Done()
sctx, cancel := context.WithTimeout(ctx, e.timeout())
defer cancel()
outs[i] = srcOut{name: name, out: src.Search(sctx, query, k, f)}
}(i, src, name)
}
wg.Wait()
var lists []namedHits
var warns, notes []string
var okNames, failNames, att []string
degraded := false
tavUsed := false
for _, o := range outs {
if o.skip {
if o.out.Warn != "" {
warns = append(warns, o.out.Warn)
}
continue
}
att = append(att, o.name)
e.addQuery(caller, o.name)
if o.name == NameTavily {
tavUsed = true
}
if o.out.Warn != "" {
warns = append(warns, o.out.Warn)
failNames = append(failNames, o.name)
degraded = true
e.addErr(o.name, o.out.Warn)
continue
}
okNames = append(okNames, o.name)
notes = append(notes, o.out.Notes...)
hits := o.out.Hits
if o.name == NameTavily && f.MinScore > 0 {
hits = filterMinScore(hits, f.MinScore)
}
lists = append(lists, namedHits{name: o.name, hits: hits})
if o.out.Credits > 0 {
e.addCredits(caller, o.name, o.out.Credits)
if o.name == NameTavily && e.Cache != nil {
e.Cache.AddSearchCredits(tavilyCapSource(class), e.now().UTC().Format("2006-01-02"), o.out.Credits)
}
}
}
merged := mergeSearch(e, lists, doRerank)
merged = filterDomains(merged, f)
return queryFan{
hits: merged,
warns: warns,
notes: notes,
okNames: okNames,
failNames: failNames,
att: att,
degraded: degraded,
tavSkip: tavSkip,
tavUsed: tavUsed,
}
}
+2
View File
@@ -105,6 +105,7 @@ func rrfMerge(lists [][]Hit) []Hit {
sc := 1.0 / float64(rrfK+r+1)
if a, ok := seen[c]; ok {
a.s += sc
keepDate := firstDate(a.h.PublishedDate, h.PublishedDate)
if len(h.Snippet) > len(a.h.Snippet) {
src := a.h.Source
keep := a.h.URL
@@ -112,6 +113,7 @@ func rrfMerge(lists [][]Hit) []Hit {
a.h.Source = src
a.h.URL = keep
}
a.h.PublishedDate = keepDate
continue
}
cp := h
+14 -6
View File
@@ -59,7 +59,7 @@ func (s *SearxNG) Name() string { return NameSearxNG }
func (s *SearxNG) Ready() bool { return s != nil && s.Client.Ready() }
func (s *BraveLLM) Ready() bool { return s != nil && s.Client.Ready() }
func (s *SearxNG) Search(ctx context.Context, query string, k int) SourceOut {
func (s *SearxNG) Search(ctx context.Context, query string, k int, f Filter) SourceOut {
if s == nil || s.Client == nil {
return SourceOut{Warn: NameSearxNG + ": error"}
}
@@ -71,10 +71,17 @@ func (s *SearxNG) Search(ctx context.Context, query string, k int) SourceOut {
if err != nil {
return SourceOut{Warn: warnErr(NameSearxNG, err)}
}
qtext, notes := searxQueryAndNotes(query, f)
q := u.Query()
q.Set("q", query)
q.Set("q", qtext)
q.Set("format", "json")
q.Set("pageno", "1")
if f.TimeRange != "" {
q.Set("time_range", f.TimeRange)
}
if f.Topic == "news" {
q.Set("categories", "news")
}
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
@@ -96,6 +103,7 @@ func (s *SearxNG) Search(ctx context.Context, query string, k int) SourceOut {
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score"`
PublishedDate string `json:"publishedDate"`
} `json:"results"`
}
if err := readJSON(resp, &env); err != nil {
@@ -107,9 +115,9 @@ func (s *SearxNG) Search(ctx context.Context, query string, k int) SourceOut {
if r.URL == "" {
continue
}
hits = append(hits, Hit{URL: r.URL, Title: r.Title, Snippet: r.Content, Score: r.Score, Source: NameSearxNG})
hits = append(hits, Hit{URL: r.URL, Title: r.Title, Snippet: r.Content, Score: r.Score, Source: NameSearxNG, PublishedDate: r.PublishedDate})
}
return SourceOut{Hits: hits}
return SourceOut{Hits: hits, Notes: notes}
}
// BraveLLM is GET {url}?q= with X-Subscription-Token.
@@ -120,7 +128,7 @@ type BraveLLM struct {
func (s *BraveLLM) Name() string { return NameBraveLLM }
func (s *BraveLLM) Search(ctx context.Context, query string, k int) SourceOut {
func (s *BraveLLM) Search(ctx context.Context, query string, k int, f Filter) SourceOut {
if s == nil || s.Client == nil {
return SourceOut{Warn: NameBraveLLM + ": error"}
}
@@ -159,7 +167,7 @@ func (s *BraveLLM) Search(ctx context.Context, query string, k int) SourceOut {
if len(hits) >= 0 {
credits = 1
}
return SourceOut{Hits: hits, Credits: credits}
return SourceOut{Hits: hits, Credits: credits, Notes: braveNotes(f)}
}
func mapBrave(raw []byte) []Hit {
+10 -7
View File
@@ -31,17 +31,20 @@ func TestTavilyOutboundAndDropsAnswer(t *testing.T) {
if err != nil {
t.Fatal(err)
}
out := (&Tavily{Client: c, Key: "test-key"}).Search(context.Background(), "q", 5)
out := (&Tavily{Client: c, Key: "test-key"}).Search(context.Background(), "q", 5, Filter{})
if out.Warn != "" {
t.Fatal(out.Warn)
}
var req tavilyReq
var req map[string]any
if err := json.Unmarshal(gotBody, &req); err != nil {
t.Fatal(err)
}
if req.IncludeAnswer || req.IncludeRawContent || req.IncludeImages || req.SearchDepth != "basic" {
if req["include_answer"] != false || req["include_raw_content"] != false || req["include_images"] != false || req["search_depth"] != "basic" {
t.Fatalf("%+v", req)
}
if req["auto_parameters"] != false {
t.Fatalf("auto_parameters %+v", req["auto_parameters"])
}
if strings.Contains(string(gotBody), "extract") || strings.Contains(string(gotBody), "crawl") {
t.Fatal(string(gotBody))
}
@@ -70,7 +73,7 @@ func TestSearxNGMapsAndSorts(t *testing.T) {
if err != nil {
t.Fatal(err)
}
out := (&SearxNG{Client: c}).Search(context.Background(), "q", 8)
out := (&SearxNG{Client: c}).Search(context.Background(), "q", 8, Filter{})
if out.Warn != "" || len(out.Hits) != 2 || out.Hits[0].Title != "a" {
t.Fatalf("%+v", out)
}
@@ -88,7 +91,7 @@ func TestBraveMapsSources(t *testing.T) {
if err != nil {
t.Fatal(err)
}
out := (&BraveLLM{Client: c, Key: "k"}).Search(context.Background(), "q", 5)
out := (&BraveLLM{Client: c, Key: "k"}).Search(context.Background(), "q", 5, Filter{})
if len(out.Hits) != 1 || out.Hits[0].URL != "https://s.example/x" {
t.Fatalf("%+v", out)
}
@@ -110,7 +113,7 @@ func TestBraveMapsGroundingGeneric(t *testing.T) {
if err != nil {
t.Fatal(err)
}
out := (&BraveLLM{Client: c, Key: "k"}).Search(context.Background(), "q", 5)
out := (&BraveLLM{Client: c, Key: "k"}).Search(context.Background(), "q", 5, Filter{})
if len(out.Hits) != 3 {
t.Fatalf("hits %d %+v", len(out.Hits), out.Hits)
}
@@ -139,7 +142,7 @@ func TestTavilyBadJSONIsError(t *testing.T) {
if err != nil {
t.Fatal(err)
}
out := (&Tavily{Client: c, Key: "k"}).Search(context.Background(), "q", 5)
out := (&Tavily{Client: c, Key: "k"}).Search(context.Background(), "q", 5, Filter{})
if out.Warn != "tavily: error" {
t.Fatalf("warn %q", out.Warn)
}
+50 -19
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,6 +60,49 @@ 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"`
@@ -84,6 +111,7 @@ func mapTavily(raw []byte) ([]Hit, int, error) {
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 {
+26 -2
View File
@@ -30,17 +30,32 @@ type Hit struct {
Snippet string `json:"snippet"`
Score float64 `json:"score"`
Source string `json:"source"`
PublishedDate string `json:"published_date,omitempty"`
}
// Filter is native search knobs. The zero value is P3 behaviour.
type Filter struct {
IncludeDomains []string
ExcludeDomains []string
IncludeDomainsMode string // restrict | prefer; empty means restrict when include is set
TimeRange string // day|week|month|year
StartDate string
EndDate string
Topic string // general|news|finance; empty means general
ExactMatch bool
MinScore float64
}
// Source is one upstream. Config-fixed endpoint; never a request URL.
type Source interface {
Name() string
Search(ctx context.Context, query string, k int) SourceOut
Search(ctx context.Context, query string, k int, f Filter) SourceOut
}
type SourceOut struct {
Hits []Hit
Warn string
Warn string // failure only (timeout, HTTP error, pin)
Notes []string // honesty: filters not applied, recall loss
Credits int
}
@@ -80,6 +95,7 @@ func Dedupe(in []Hit) []Hit {
continue
}
if s, ok := seen[canon]; ok {
keepDate := firstDate(s.h.PublishedDate, h.PublishedDate)
if len(h.Snippet) > len(s.h.Snippet) {
src := s.h.Source
keep := s.h.URL
@@ -91,6 +107,7 @@ func Dedupe(in []Hit) []Hit {
s.h.Score = sc
}
}
s.h.PublishedDate = keepDate
continue
}
cp := h
@@ -104,6 +121,13 @@ func Dedupe(in []Hit) []Hit {
return out
}
func firstDate(a, b string) string {
if a != "" {
return a
}
return b
}
func sortBySourceOrder(hits []Hit) {
sort.SliceStable(hits, func(i, j int) bool {
ri, rj := sourceRank[hits[i].Source], sourceRank[hits[j].Source]
+31 -3
View File
@@ -21,7 +21,9 @@ markdown, JSON → JSON), optionally **digested** for your question by a local m
**what the fleet already knows** attached from Honcho — so you read 1k tokens, not 20k.
`sift search` is the first step for web search. Use `web_search` only if sift is
unreachable. Then `sift fetch` the URLs you need. Restricted callers use the same path.
unreachable. Hermes `web_search` hits `/v2/search` and does **not** get native
filters. Then `sift fetch` / `sift batch` the URLs you need — do not fetch every
hit. Restricted callers use the same path.
## Rule: sift is the explicit first step
@@ -38,8 +40,9 @@ Whenever you need the content of a URL:
## Commands
```bash
# Web search (native). Then sift fetch the URLs you need.
sift search "HBM wafer share 2026" --k 8
# Web search (native). Then score/filter, then batch-fetch — not every hit.
sift search "HBM wafer share 2026" --k 8 --json
sift search "OpenAI" --include-domain openai.com --topic news --time-range month --json
# Page or document → cleaned content. Add --question to get a focused digest.
sift fetch "https://example.com/article" --question "what does it say about HBM wafer share?"
@@ -55,6 +58,31 @@ Useful flags: `--mode digest|clean|raw` (default `digest` when `--question` is g
else `clean`), `--no-context` (skip Honcho lookup), `--recover` (server-side archive ladder
for blocked pages), `--json` (full envelope: content, source, context, sizes, warnings).
## Search
```bash
sift search "<query>" [--k n] [--json]
[--include-domain host]... [--exclude-domain host]...
[--domains-mode restrict|prefer]
[--time-range day|week|month|year]
[--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD]
[--topic general|news|finance]
[--exact-match]
[--min-score 0.5]
[--queries-file path]
```
Product News Tracker shape (official domain + news + last month):
```bash
sift search "product launch" --include-domain openai.com --topic news --time-range month --json
```
**Recipe:** after `sift search --json`, do not `sift fetch` every URL. Drop hits below a
Tavily score you care about (`--min-score`, Tavily's 0–1 scale) or take top N, then
`sift batch submit`. `web_search` is only the fallback when sift is unreachable, and it
still hits `/v2/search` with no filters.
## Batch
When you will read the content of several URLs later (a shortlist to compare), submit them once