14e98334ad
The offline encyclopedia was the only world source, and it reads what was true when the ZIM was built. A self-hosted SearXNG now asks first and Kiwix is the fallback for an empty result, an unreachable instance or no line out. Owner's ruling, 2026-08-02. internal/websearch is deliberately thin: no rewriter (SearXNG ranks through real engines, so the Russian question goes out as he asked it), no page fetch, no cache. It cannot read the store, so only the query string can leave the box. The personal boundary is unchanged and still sits above this source, so a question about him is never searched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaeSbLMEVG5ey8tejU3y2
106 lines
3.6 KiB
Go
106 lines
3.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/websearch"
|
|
)
|
|
|
|
func searchHandler(t *testing.T, body string, status int) (*reactiveHandler, *string) {
|
|
t.Helper()
|
|
var seen string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = r.URL.RawQuery
|
|
if status != http.StatusOK {
|
|
http.Error(w, "no", status)
|
|
return
|
|
}
|
|
w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return &reactiveHandler{
|
|
// No phraser: querySearch then reads back the best evidence, which is
|
|
// what makes the claim visible without a llama-server in the test.
|
|
search: &searchWiring{client: websearch.New(srv.URL, websearch.Options{}), max: 3, runes: 1500},
|
|
}, &seen
|
|
}
|
|
|
|
const searchBody = `{"answers":["Небо голубое из-за рэлеевского рассеяния."],
|
|
"results":[{"title":"Рэлеевское рассеяние","url":"https://ru.wikipedia.org/x","content":"Рассеяние света."}]}`
|
|
|
|
func TestQuerySearchClaimsAndReadsBack(t *testing.T) {
|
|
h, _ := searchHandler(t, searchBody, http.StatusOK)
|
|
reply, ok := h.querySearch(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("querySearch passed on a search with hits")
|
|
}
|
|
if !strings.Contains(reply, "рэлеевского рассеяния") {
|
|
t.Fatalf("reply = %q", reply)
|
|
}
|
|
}
|
|
|
|
// No rewriter in front of this source: SearXNG ranks by meaning, and reducing
|
|
// the question to English keywords would throw away the language he asked in.
|
|
func TestQuerySearchSendsTheQuestionVerbatim(t *testing.T) {
|
|
h, seen := searchHandler(t, searchBody, http.StatusOK)
|
|
h.querySearch(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"},
|
|
})
|
|
if !strings.Contains(*seen, "q="+url.QueryEscape("почему небо голубое")) {
|
|
t.Fatalf("query string = %q", *seen)
|
|
}
|
|
}
|
|
|
|
// The whole reason the ordering is safe: an unreachable or empty instance
|
|
// passes the turn to Kiwix instead of claiming it with an apology.
|
|
func TestQuerySearchFallsThroughWhenItFails(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
body string
|
|
status int
|
|
}{
|
|
{"http error", "", http.StatusForbidden},
|
|
{"no hits", `{"answers":[],"results":[]}`, http.StatusOK},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h, _ := searchHandler(t, tc.body, tc.status)
|
|
if _, ok := h.querySearch(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"},
|
|
}); ok {
|
|
t.Fatal("querySearch claimed the turn; Kiwix never got its fallback")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Off unless configured, and silent about it: he never asked for a capability
|
|
// he did not enable.
|
|
func TestQuerySearchOffWithoutConfig(t *testing.T) {
|
|
h := &reactiveHandler{}
|
|
if _, ok := h.querySearch(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"},
|
|
}); ok {
|
|
t.Fatal("querySearch claimed a turn with no search block")
|
|
}
|
|
}
|
|
|
|
// The owner's ruling of 2026-08-02: the live search asks first, the ZIM is the
|
|
// fallback for a box with no line out.
|
|
func TestSearchRunsBeforeKiwix(t *testing.T) {
|
|
idx := map[string]int{}
|
|
for i, s := range querySources {
|
|
idx[s.name] = i
|
|
}
|
|
if idx["search"] > idx["kiwix"] {
|
|
t.Fatalf("search at %d, kiwix at %d: the ZIM is the fallback, not the first read", idx["search"], idx["kiwix"])
|
|
}
|
|
}
|