1f38e71d1a
Verification, as the task asked. Drove что такое фотосинтез through /api/chat with the search reachable, with the container stopped, and with the host blackholed. Kiwix claims the turn in both failure cases, and a stopped container costs nothing: DNS fails and the ZIM answers inside the same second. The blackhole is the case that hurts. The search waited its full 8-second budget before the ZIM was asked and the turn took 15.4s against 3.5, which he sits through with nothing being said. So the connect phase alone is now capped at 1.5s. A reachable instance that is merely slow keeps the whole budget, because it is fanning out to real engines. The RU Wikipedia ZIM is on the box (owner moved it into the kiwix zims dir), and kiwix-serve picked it up. A Cyrillic question now searches book_ru verbatim and skips the RU->EN rewrite: that rewriter is the workaround for an English book, and against a Russian one it is a translation of his own words back at him. Catalog names come from the filename, not the <name> field — books.name=wikipedia_ru_all returns nothing. Measurement in docs/evals/2026-08-05-kiwix-offline-fallback.md. The RU book answering a driven turn needs a rebuild and is not verified yet.
260 lines
8.8 KiB
Go
260 lines
8.8 KiB
Go
// Package websearch reads a self-hosted SearXNG instance.
|
|
//
|
|
// Why this exists at all: "never phones home" stopped being a hard constraint
|
|
// on 2026-07-31. A 1.7B does not know enough to answer a world question, and
|
|
// reading beats recalling at that size. SearXNG is the reading surface for
|
|
// anything the offline ZIMs do not hold, and it is off unless configured.
|
|
//
|
|
// What is NOT here, on purpose:
|
|
//
|
|
// - No query rewriting. SearXNG ranks with real engines, so the Russian
|
|
// question goes out as he asked it. That is the whole reason it sits ahead
|
|
// of Kiwix, whose keyword ranker needs kiwix.Rewriter to see anything.
|
|
// - No page fetching. A snippet per result is the evidence; following a link
|
|
// is crawl.Crawler's job and carries robots and allowlist rules with it.
|
|
// - No cache and no retries. Boring on purpose, same posture as kiwix.Client.
|
|
//
|
|
// Only the query string leaves this process. This package cannot read the
|
|
// store, so his notes, facts, persona block and history cannot travel with a
|
|
// search even by accident. The personal boundary in the query chain is what
|
|
// keeps a question ABOUT him from becoming a query at all.
|
|
package websearch
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Result is one search hit, already reduced to what a phraser can read.
|
|
type Result struct {
|
|
Title string
|
|
URL string
|
|
Content string // the engine's snippet, plain text
|
|
Engine string // which upstream engine produced it, e.g. "duckduckgo"
|
|
}
|
|
|
|
// Response is one search. Answers comes from SearXNG's answerer plugins and
|
|
// from instant answers upstream; it is a direct reply to the question and is
|
|
// worth more than any snippet, so it is kept separate rather than mixed in.
|
|
type Response struct {
|
|
Answers []string
|
|
Results []Result
|
|
}
|
|
|
|
// Empty reports whether the search found nothing usable. The caller passes the
|
|
// turn on when it does — an empty search is not a failure worth announcing.
|
|
func (r Response) Empty() bool { return len(r.Answers) == 0 && len(r.Results) == 0 }
|
|
|
|
// DefaultTimeout — the whole request. SearXNG fans out to upstream engines and
|
|
// waits on the slowest, so this is longer than a LAN call but short enough that
|
|
// a dead engine does not hold a voice turn open.
|
|
const DefaultTimeout = 8 * time.Second
|
|
|
|
// dialTimeout — how long a connection to the instance may take before the turn
|
|
// gives up on it and falls through to the ZIM.
|
|
//
|
|
// It is separate from DefaultTimeout because the two failures are different
|
|
// (V-508). A reachable instance that is merely slow deserves the full budget:
|
|
// it is fanning out to real engines. A host that never answers a SYN deserves
|
|
// almost none, and the difference was measured. With the container stopped, DNS
|
|
// failed and the ZIM answered inside the same second. With the host blackholed,
|
|
// the search sat for the whole 8 seconds and the turn took 15.4 seconds instead
|
|
// of 3.5, which is a wait he sits through with nothing being said.
|
|
//
|
|
// The instance is on the LAN or the same host, so a connection it will ever
|
|
// accept is accepted in milliseconds.
|
|
const dialTimeout = 1500 * time.Millisecond
|
|
|
|
// maxBodyBytes caps the JSON read. A 20-result reply is tens of kilobytes; this
|
|
// is slack for a wide one and a hard stop against a misconfigured endpoint.
|
|
const maxBodyBytes = 4 << 20
|
|
|
|
// Client is a SearXNG HTTP client.
|
|
type Client struct {
|
|
base string
|
|
language string
|
|
engines string
|
|
http *http.Client
|
|
}
|
|
|
|
// Options are the per-instance knobs, all optional.
|
|
type Options struct {
|
|
// Language — SearXNG's `language` parameter, e.g. "ru" or "auto". Empty ⇒
|
|
// the instance default.
|
|
Language string
|
|
// Engines — comma-separated engine names to restrict the search to. Empty ⇒
|
|
// whatever the instance has enabled.
|
|
Engines string
|
|
// Timeout — per-request budget. 0 ⇒ DefaultTimeout.
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// New makes a client for a SearXNG base URL like http://searxng:9563.
|
|
//
|
|
// The instance must have the JSON format enabled (`search.formats: [html,
|
|
// json]` in its settings.yml); a stock install answers 403 to format=json and
|
|
// every search will fail with that status.
|
|
func New(baseURL string, opt Options) *Client {
|
|
t := opt.Timeout
|
|
if t <= 0 {
|
|
t = DefaultTimeout
|
|
}
|
|
return &Client{
|
|
base: strings.TrimRight(baseURL, "/"),
|
|
language: strings.TrimSpace(opt.Language),
|
|
engines: strings.TrimSpace(opt.Engines),
|
|
http: &http.Client{
|
|
Timeout: t,
|
|
// Cloned from the default so the rest of the transport (proxy,
|
|
// keep-alives, HTTP/2) keeps stock behaviour and only the dial
|
|
// budget changes.
|
|
Transport: dialCappedTransport(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Search runs one query and returns up to limit results plus any instant
|
|
// answers. The query goes out verbatim.
|
|
func (c *Client) Search(ctx context.Context, query string, limit int) (Response, error) {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return Response{}, fmt.Errorf("websearch: empty query")
|
|
}
|
|
q := url.Values{}
|
|
q.Set("q", query)
|
|
q.Set("format", "json")
|
|
if c.language != "" {
|
|
q.Set("language", c.language)
|
|
}
|
|
if c.engines != "" {
|
|
q.Set("engines", c.engines)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/search?"+q.Encode(), nil)
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Response{}, fmt.Errorf("websearch: http %d (json format enabled in searxng?)", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
return ParseResponse(body, limit)
|
|
}
|
|
|
|
// dialCappedTransport is http.DefaultTransport with dialTimeout on the connect
|
|
// phase. A read that has already connected still gets the full request budget.
|
|
func dialCappedTransport() *http.Transport {
|
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
|
tr.DialContext = (&net.Dialer{Timeout: dialTimeout, KeepAlive: 30 * time.Second}).DialContext
|
|
return tr
|
|
}
|
|
|
|
// wire mirrors just the fields of the SearXNG JSON reply we read.
|
|
type wire struct {
|
|
Answers []json.RawMessage `json:"answers"`
|
|
Results []struct {
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
Content string `json:"content"`
|
|
Engine string `json:"engine"`
|
|
} `json:"results"`
|
|
}
|
|
|
|
// ParseResponse turns a SearXNG JSON reply into a Response, keeping at most
|
|
// limit results. Exported so the parser is testable from a captured reply with
|
|
// no instance running.
|
|
func ParseResponse(body []byte, limit int) (Response, error) {
|
|
var doc wire
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
return Response{}, fmt.Errorf("websearch: bad json: %w", err)
|
|
}
|
|
if limit <= 0 {
|
|
limit = 5
|
|
}
|
|
out := Response{}
|
|
for _, raw := range doc.Answers {
|
|
if s := answerText(raw); s != "" {
|
|
out.Answers = append(out.Answers, s)
|
|
}
|
|
}
|
|
for _, r := range doc.Results {
|
|
title := clean(r.Title)
|
|
content := clean(r.Content)
|
|
if title == "" && content == "" {
|
|
// A hit with no text is a link with nothing to read. It cannot be
|
|
// evidence, and counting it toward the limit would push a usable
|
|
// snippet out of the reply.
|
|
continue
|
|
}
|
|
out.Results = append(out.Results, Result{
|
|
Title: title,
|
|
URL: strings.TrimSpace(r.URL),
|
|
Content: content,
|
|
Engine: strings.TrimSpace(r.Engine),
|
|
})
|
|
if len(out.Results) == limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// answerText reads one entry of `answers`. SearXNG changed its shape: older
|
|
// versions emit a bare string, newer ones an object with an `answer` field.
|
|
// Both are in the wild depending on when the instance was pulled, so both are
|
|
// read rather than pinning a version we do not control.
|
|
func answerText(raw json.RawMessage) string {
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
return clean(s)
|
|
}
|
|
var obj struct {
|
|
Answer string `json:"answer"`
|
|
}
|
|
if err := json.Unmarshal(raw, &obj); err == nil {
|
|
return clean(obj.Answer)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Snippets renders the response as evidence lines for a phraser: instant
|
|
// answers first, then "Title — snippet" per result.
|
|
//
|
|
// Answers lead because they are a reply to the question, where a result is a
|
|
// page that might contain one. The URL is deliberately left out: it is not
|
|
// evidence, and piper reads one out character by character.
|
|
func (r Response) Snippets() []string {
|
|
out := make([]string, 0, len(r.Answers)+len(r.Results))
|
|
out = append(out, r.Answers...)
|
|
for _, res := range r.Results {
|
|
switch {
|
|
case res.Content == "":
|
|
out = append(out, res.Title)
|
|
case res.Title == "":
|
|
out = append(out, res.Content)
|
|
default:
|
|
out = append(out, res.Title+" — "+res.Content)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// clean collapses whitespace. Snippets arrive with newlines and runs of spaces
|
|
// from the upstream page, and piper reads a reply built out of them badly.
|
|
func clean(s string) string { return strings.Join(strings.Fields(s), " ") }
|