Files
sift/internal/search/engine.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

252 lines
5.0 KiB
Go

package search
import (
"context"
"log/slog"
"sort"
"sync"
"time"
"git.wntrmute.dev/kyle/sift/internal/dcache"
)
type Engine struct {
Sources []Source
Timeout time.Duration
DefaultK int
RerankOn bool
Cache *dcache.DB
CacheTTL time.Duration
Embedder Embedder
Log *slog.Logger
Degraded []string // static: missing keys
TavilyCap int
Now func() time.Time
NoRerank map[string]bool
mu sync.Mutex
Queries map[string]map[string]uint64
Credits map[string]map[string]uint64
Errors map[string]map[string]uint64
CacheHit uint64
CacheMiss uint64
RerankFallback uint64
DailyCredits map[string]uint64
Capped map[string]uint64
}
type RunResult struct {
Hits []Hit
Warnings []string
SourcesOK []string
SourcesFailed []string
SearchMS int64
RerankMS int64
Cached bool
Attempted []string
}
type readySrc interface {
Name() string
Ready() bool
}
// Health is missing keys plus sources that still have no pin.
func (e *Engine) Health() []string {
if e == nil {
return nil
}
out := append([]string(nil), e.Degraded...)
for _, s := range e.Sources {
r, ok := s.(readySrc)
if !ok || r.Ready() {
continue
}
out = append(out, "search."+r.Name()+": unresolved")
}
return out
}
func (e *Engine) now() time.Time {
if e != nil && e.Now != nil {
return e.Now()
}
return time.Now()
}
func (e *Engine) ttl() time.Duration {
if e != nil && e.CacheTTL > 0 {
return e.CacheTTL
}
return time.Hour
}
func (e *Engine) timeout() time.Duration {
if e != nil && e.Timeout > 0 {
return e.Timeout
}
return 8 * time.Second
}
func (e *Engine) Run(ctx context.Context, query string, k int, doRerank bool, caller, class string) RunResult {
return e.RunFilter(ctx, []string{query}, k, doRerank, caller, class, Filter{})
}
func (e *Engine) attemptedNames(class string) []string {
var n []string
for _, s := range e.Sources {
if s.Name() == NameTavily && e.tavilyCapped(class) {
continue
}
n = append(n, s.Name())
}
sort.Strings(n)
return n
}
func tavilyCapSource(class string) string {
if class == dcache.ClassRestricted {
return NameTavily + ":restricted"
}
return NameTavily
}
func (e *Engine) tavilyCapped(class string) bool {
if e == nil || e.TavilyCap <= 0 {
return false
}
if class == dcache.ClassRestricted {
return false
}
n := 0
if e.Cache != nil {
n = e.Cache.GetSearchCredits(NameTavily, e.now().UTC().Format("2006-01-02"))
}
return n >= e.TavilyCap
}
func clampHits(hits []Hit, k int) []Hit {
if k < 0 {
k = 0
}
if len(hits) > k {
return hits[:k]
}
return hits
}
func (e *Engine) addQuery(caller, source string) {
e.mu.Lock()
defer e.mu.Unlock()
if e.Queries == nil {
e.Queries = map[string]map[string]uint64{}
}
if e.Queries[caller] == nil {
e.Queries[caller] = map[string]uint64{}
}
e.Queries[caller][source]++
}
func (e *Engine) addCredits(caller, source string, n int) {
if n <= 0 {
return
}
e.mu.Lock()
defer e.mu.Unlock()
if e.Credits == nil {
e.Credits = map[string]map[string]uint64{}
}
if e.Credits[caller] == nil {
e.Credits[caller] = map[string]uint64{}
}
e.Credits[caller][source] += uint64(n)
if e.DailyCredits == nil {
e.DailyCredits = map[string]uint64{}
}
e.DailyCredits[source] += uint64(n)
}
func (e *Engine) addErr(source, warn string) {
kind := "error"
if len(warn) > 0 {
kind = warn
if i := lastColon(warn); i >= 0 && i+1 < len(warn) {
kind = warn[i+1:]
if len(kind) > 0 && kind[0] == ' ' {
kind = kind[1:]
}
}
}
e.mu.Lock()
defer e.mu.Unlock()
if e.Errors == nil {
e.Errors = map[string]map[string]uint64{}
}
if e.Errors[source] == nil {
e.Errors[source] = map[string]uint64{}
}
e.Errors[source][kind]++
}
func (e *Engine) addCapped(source string) {
e.mu.Lock()
defer e.mu.Unlock()
if e.Capped == nil {
e.Capped = map[string]uint64{}
}
e.Capped[source]++
}
func lastColon(s string) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == ':' {
return i
}
}
return -1
}
func (e *Engine) Metrics() map[string]any {
if e == nil {
return nil
}
e.mu.Lock()
defer e.mu.Unlock()
return map[string]any{
"search_queries": clone2(e.Queries),
"search_credits": clone2(e.Credits),
"search_errors": clone2(e.Errors),
"search_cache_hit": e.CacheHit,
"search_cache_miss": e.CacheMiss,
"search_rerank_fallback": e.RerankFallback,
"search_daily_credits": clone1(e.DailyCredits),
"search_capped": clone1(e.Capped),
}
}
func clone2(m map[string]map[string]uint64) map[string]map[string]uint64 {
if m == nil {
return map[string]map[string]uint64{}
}
out := make(map[string]map[string]uint64, len(m))
for k, v := range m {
inner := make(map[string]uint64, len(v))
for k2, v2 := range v {
inner[k2] = v2
}
out[k] = inner
}
return out
}
func clone1(m map[string]uint64) map[string]uint64 {
if m == nil {
return map[string]uint64{}
}
out := make(map[string]uint64, len(m))
for k, v := range m {
out[k] = v
}
return out
}