p3: search API, pinned sources, cache, CLI
POST /v1/search and /v2/search fan in SearXNG, Brave LLM Context, and Tavily from config-fixed endpoints. Public sources refuse private A records; SearXNG is a loopback-only client, not the fetch guard. Cache keys include caller class. Restricted callers search like the fleet; logs omit query, hosts, URLs, and upstream errors. Degraded results are not stored. Tavily sends search_depth=basic and drops answer. Missing keys surface in /healthz. Default-on sources stay off until the bake-off. Query harvest is not in this commit.
This commit is contained in:
+104
-5
@@ -11,6 +11,8 @@ import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -41,6 +43,8 @@ type DB struct {
|
||||
DigestM uint64
|
||||
EmbedH uint64
|
||||
EmbedM uint64
|
||||
SearchH uint64
|
||||
SearchM uint64
|
||||
Evictions uint64
|
||||
}
|
||||
|
||||
@@ -75,6 +79,19 @@ func Open(path string, maxBytes int64, log *slog.Logger) (*DB, error) {
|
||||
last_hit INTEGER NOT NULL,
|
||||
nbytes INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS search (
|
||||
k TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL,
|
||||
last_hit INTEGER NOT NULL,
|
||||
nbytes INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS search_cap (
|
||||
source TEXT NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
credits INTEGER NOT NULL,
|
||||
PRIMARY KEY (source, day)
|
||||
);
|
||||
`); err != nil {
|
||||
_ = sq.Close()
|
||||
return nil, err
|
||||
@@ -118,6 +135,17 @@ 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
|
||||
@@ -193,11 +221,79 @@ func (d *DB) Size() int64 {
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
var n sql.NullInt64
|
||||
var n, m, s sql.NullInt64
|
||||
_ = d.sql.QueryRow(`SELECT IFNULL(SUM(nbytes),0) FROM digest`).Scan(&n)
|
||||
var m sql.NullInt64
|
||||
_ = d.sql.QueryRow(`SELECT IFNULL(SUM(nbytes),0) FROM embed`).Scan(&m)
|
||||
return n.Int64 + m.Int64
|
||||
_ = d.sql.QueryRow(`SELECT IFNULL(SUM(nbytes),0) FROM search`).Scan(&s)
|
||||
return n.Int64 + m.Int64 + s.Int64
|
||||
}
|
||||
|
||||
func (d *DB) GetSearch(key string) (payload []byte, ok bool) {
|
||||
if d == nil {
|
||||
return nil, false
|
||||
}
|
||||
var b string
|
||||
var exp int64
|
||||
err := d.sql.QueryRow(`SELECT payload, expires FROM search WHERE k = ?`, key).Scan(&b, &exp)
|
||||
if err != nil {
|
||||
d.mu.Lock()
|
||||
d.SearchM++
|
||||
d.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
if exp > 0 && time.Now().Unix() > exp {
|
||||
_, _ = d.sql.Exec(`DELETE FROM search WHERE k = ?`, key)
|
||||
d.mu.Lock()
|
||||
d.SearchM++
|
||||
d.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
_, _ = d.sql.Exec(`UPDATE search SET last_hit = ? WHERE k = ?`, time.Now().Unix(), key)
|
||||
d.mu.Lock()
|
||||
d.SearchH++
|
||||
d.mu.Unlock()
|
||||
return []byte(b), true
|
||||
}
|
||||
|
||||
func (d *DB) PutSearch(key string, payload []byte, ttl time.Duration) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
exp := time.Now().Add(ttl).Unix()
|
||||
n := len(key) + len(payload)
|
||||
_, err := d.sql.Exec(`INSERT OR REPLACE INTO search(k, payload, expires, last_hit, nbytes) VALUES(?,?,?,?,?)`,
|
||||
key, string(payload), exp, time.Now().Unix(), n)
|
||||
if err != nil && d.log != nil {
|
||||
d.log.Warn("search cache write", "err", err)
|
||||
}
|
||||
d.evict()
|
||||
}
|
||||
|
||||
func (d *DB) GetSearchCredits(source, day string) int {
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
var n int
|
||||
err := d.sql.QueryRow(`SELECT credits FROM search_cap WHERE source = ? AND day = ?`, source, day).Scan(&n)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (d *DB) AddSearchCredits(source, day string, delta int) int {
|
||||
if d == nil || delta == 0 {
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
return d.GetSearchCredits(source, day)
|
||||
}
|
||||
_, err := d.sql.Exec(`INSERT INTO search_cap(source, day, credits) VALUES(?,?,?)
|
||||
ON CONFLICT(source, day) DO UPDATE SET credits = credits + excluded.credits`, source, day, delta)
|
||||
if err != nil && d.log != nil {
|
||||
d.log.Warn("search cap write", "err", err)
|
||||
}
|
||||
return d.GetSearchCredits(source, day)
|
||||
}
|
||||
|
||||
func (d *DB) evict() {
|
||||
@@ -208,15 +304,18 @@ func (d *DB) evict() {
|
||||
var k string
|
||||
err := d.sql.QueryRow(`
|
||||
SELECT k FROM (
|
||||
SELECT k, last_hit, 0 AS kind FROM digest
|
||||
SELECT k, last_hit FROM digest
|
||||
UNION ALL
|
||||
SELECT k, last_hit, 1 FROM embed
|
||||
SELECT k, last_hit FROM embed
|
||||
UNION ALL
|
||||
SELECT k, last_hit FROM search
|
||||
) ORDER BY last_hit ASC LIMIT 1`).Scan(&k)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = d.sql.Exec(`DELETE FROM digest WHERE k = ?`, k)
|
||||
_, _ = d.sql.Exec(`DELETE FROM embed WHERE k = ?`, k)
|
||||
_, _ = d.sql.Exec(`DELETE FROM search WHERE k = ?`, k)
|
||||
d.mu.Lock()
|
||||
d.Evictions++
|
||||
d.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user