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:
2026-09-23 12:17:51 -07:00
parent 3687636ed7
commit d7873403ff
27 changed files with 2361 additions and 21 deletions
+104 -5
View File
@@ -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()