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.
188 lines
5.0 KiB
Go
188 lines
5.0 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.wntrmute.dev/kyle/sift/internal/auth"
|
|
"git.wntrmute.dev/kyle/sift/internal/search"
|
|
)
|
|
|
|
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 {
|
|
Query string `json:"query"`
|
|
Limit int `json:"limit"`
|
|
URL string `json:"url"`
|
|
Digest bool `json:"digest"`
|
|
}
|
|
|
|
func (s *Server) v1search(w http.ResponseWriter, r *http.Request) {
|
|
s.handleSearch(w, r, false)
|
|
}
|
|
|
|
func (s *Server) v2search(w http.ResponseWriter, r *http.Request) {
|
|
s.handleSearch(w, r, true)
|
|
}
|
|
|
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, v2 bool) {
|
|
s.reqN.Add(1)
|
|
c := s.caller(w, r)
|
|
if c == nil {
|
|
return
|
|
}
|
|
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" {
|
|
writeErr(w, 400, "invalid query", "")
|
|
return
|
|
}
|
|
query = req.Query
|
|
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
|
|
}
|
|
k = req.K
|
|
if k == 0 {
|
|
k = req.Limit
|
|
}
|
|
if req.Rerank != nil {
|
|
doRerank = *req.Rerank
|
|
}
|
|
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 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
|
|
}
|
|
k = search.ClampK(k, defK)
|
|
if s.Search == nil {
|
|
writeErr(w, 422, "no search results", "")
|
|
return
|
|
}
|
|
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")
|
|
}
|
|
if !res.Cached && len(res.SourcesOK) == 0 && len(res.Hits) == 0 {
|
|
writeErr(w, 422, "no search results", "")
|
|
return
|
|
}
|
|
s.logSearch(c, queries, f, res)
|
|
if v2 {
|
|
web := make([]map[string]any, 0, len(res.Hits))
|
|
for i, h := range res.Hits {
|
|
web = append(web, map[string]any{
|
|
"url": h.URL,
|
|
"title": h.Title,
|
|
"description": h.Snippet,
|
|
"position": i + 1,
|
|
})
|
|
}
|
|
writeJSON(w, 200, map[string]any{"success": true, "data": map[string]any{"web": web}})
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(res.Hits))
|
|
for _, h := range res.Hits {
|
|
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,
|
|
"warnings": warns,
|
|
"sizes": map[string]any{
|
|
"results": len(results),
|
|
"sources_ok": res.SourcesOK,
|
|
"sources_failed": res.SourcesFailed,
|
|
},
|
|
"timings": map[string]any{"search_ms": res.SearchMS, "rerank_ms": res.RerankMS},
|
|
"cached": res.Cached,
|
|
})
|
|
}
|
|
|
|
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"
|
|
}
|
|
src := strings.Join(res.Attempted, ",")
|
|
if s.restrictedLog(c) {
|
|
s.Log.Info("search", "caller", c.Name, "q_len", qLen, "sources", src, "n", len(res.Hits), "cache", cache)
|
|
return
|
|
}
|
|
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...)
|
|
}
|