Files
Cyrene_v2/cmd/cyrene/wire_web_search_test.go
T
AskaEth 69a3b73777 feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)
主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。
- 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次**
- 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义)
- 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」)
- 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉)
- searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变)
- 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
2026-10-05 22:38:20 +08:00

380 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// 本文件钉住切片 WS1-A 的组合根接线:`WEB_SEARCH_ENDPOINT` / `WEB_SEARCH_TIMEOUT`
// 经 `a.ioToolOptions()` 传进 io 工具集,端点不可用时 web-search **不进注册面**。
//
// 断言落在**注册表**上(`toolNames`,即模型真正看到的那一面),而不是源码字符串 ——
// 源码接缝只作为最后一段的防回归补充。
// wsTestEnvFile 写一份只含 JWT_SECRET(唯一的必填键)加若干自定义行的 .env,
// 返回路径。走 config.Load 是为了让「未写这个键 ⇒ 用标签默认值」这条链真实发生。
func wsTestEnvFile(t *testing.T, extra ...string) string {
t.Helper()
lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
return path
}
// wsLoadConfig 按给定 .env 行加载配置(进程环境优先级更高,由 config 层负责)。
func wsLoadConfig(t *testing.T, extra ...string) *config.Config {
t.Helper()
cfg, err := config.Load(wsTestEnvFile(t, extra...))
if err != nil {
t.Fatalf("config.Load: %v", err)
}
return cfg
}
// wsSurfaceApp 用组合根的**同一条路**把 io 工具注册进能力面:ioToolOptions →
// iotools.New → dispatcher.Register。logOut 非空时用它当日志出口(用于断言 WARN)。
func wsSurfaceApp(t *testing.T, cfg *config.Config, logOut *bytes.Buffer) *app {
t.Helper()
caps := registry.New()
dispatcher, err := tool.New(caps)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{cfg: cfg, log: testLog()}
if logOut != nil {
a.log = logger.New(logger.Options{Out: logOut, Level: "debug"})
}
set, err := iotools.New(cfg, a.ioToolOptions()...)
if err != nil {
t.Fatalf("iotools.New: %v", err)
}
for _, tl := range set {
if rerr := dispatcher.Register(tl); rerr != nil {
t.Fatalf("register %s: %v", tl.Name(), rerr)
}
}
a.caps = caps
a.dispatcher = dispatcher
return a
}
// wsHasTool 报告注册表(模型可见的工具面)里有没有这个名字。
func wsHasTool(a *app, name string) bool {
for _, n := range toolNames(a) {
if n == name {
return true
}
}
return false
}
// isOffSentinel 报告取值是否属于「显式关闭」的三个哨兵词(大小写/空白不敏感)。
func isOffSentinel(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "off", "none":
return true
default:
return false
}
}
// TestWireIOTools_DefaultEndpointRegistersWebSearch 是判据 ①:**不写这个键**时
// 默认值生效(本机兜底),web-search 出现在注册面里。
func TestWireIOTools_DefaultEndpointRegistersWebSearch(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
const want = "http://127.0.0.1:38471"
if cfg.WebSearch.Endpoint != want {
t.Fatalf("默认端点 = %q, want %q", cfg.WebSearch.Endpoint, want)
}
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, "web-search") {
t.Fatalf("默认端点下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
}
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
if !wsHasTool(a, name) {
t.Errorf("其余 io 工具不得受影响,但 %q 不在注册面里", name)
}
}
}
// TestWireIOTools_UnusableEndpointOmitsWebSearch 是判据 ②④:端点显式关闭
// (空串 / off / none)或不是可用 http(s) 地址时,web-search **不进注册面** ——
// 模型看不见它,且启动不报错(是「少一把工具」,不是「启动失败」)。同时必须留下
// 一条带键名的 WARN:配了却没用,绝不静默。
func TestWireIOTools_UnusableEndpointOmitsWebSearch(t *testing.T) {
t.Parallel()
cases := []struct {
name string
envLine string
wantValue string
}{
{"显式留空", "WEB_SEARCH_ENDPOINT=", ""},
{"off", "WEB_SEARCH_ENDPOINT=off", "off"},
{"大写 NONE", "WEB_SEARCH_ENDPOINT=NONE", "NONE"},
{"非 http scheme", "WEB_SEARCH_ENDPOINT=ftp://searx.example.com", "ftp://searx.example.com"},
{"缺 scheme", "WEB_SEARCH_ENDPOINT=127.0.0.1:38471", "127.0.0.1:38471"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
cfg := wsLoadConfig(t, tc.envLine)
if cfg.WebSearch.Endpoint != tc.wantValue {
t.Fatalf("端点 = %q, want %q", cfg.WebSearch.Endpoint, tc.wantValue)
}
a := wsSurfaceApp(t, cfg, &buf)
if wsHasTool(a, "web-search") {
t.Fatalf("端点不可用时 web-search 不得进注册面,实际工具面 = %v", toolNames(a))
}
// 其余工具照常(关掉的只是一件工具,不是整套 io 能力)。
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
if !wsHasTool(a, name) {
t.Errorf("%q 不得因 web-search 关闭而消失", name)
}
}
// WARN 必须点出是哪个键、为什么(不打印 URL 本身,避免 URL 里带凭据)。
out := buf.String()
if !strings.Contains(out, webSearchEndpointEnv) {
t.Fatalf("日志里没有点出 %s:%q", webSearchEndpointEnv, out)
}
if !strings.Contains(out, "web-search tool not registered") {
t.Fatalf("日志里没有说明 web-search 未注册:%q", out)
}
// 关闭哨兵词本身会出现在原因文案里("空串 / off / none"),故只对
// 「像端点/主机」的那几个取值断言它没被打印。
if !isOffSentinel(tc.wantValue) && tc.wantValue != "" && strings.Contains(out, tc.wantValue) {
t.Fatalf("日志里出现了端点值本身(URL 可能带凭据,禁止打印):%q", out)
}
})
}
}
// TestWireIOTools_EndpointIsPassedThrough 是判据 ③:断言的不是「没报错」,而是
// **配置里的端点真的被用了** —— 用一个可注入的 httptest 出口收请求,核对路径/查询串。
func TestWireIOTools_EndpointIsPassedThrough(t *testing.T) {
t.Parallel()
hits := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits <- r
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`))
}))
defer srv.Close()
cfg := wsLoadConfig(t, "WEB_SEARCH_ENDPOINT="+srv.URL+"/search")
a := wsSurfaceApp(t, cfg, nil)
tl, err := a.caps.Get("web-search")
if err != nil {
t.Fatalf("web-search 不在注册面里: %v", err)
}
toolValue, ok := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
if !ok {
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
}
if _, err := toolValue.Execute(context.Background(), map[string]any{"query": "昔涟"}); err != nil {
t.Fatalf("Execute: %v", err)
}
select {
case r := <-hits:
if r.URL.Path != "/search" {
t.Errorf("请求路径 = %q, want /search(配置里的端点必须原样使用)", r.URL.Path)
}
if got := r.URL.Query().Get("q"); got != "昔涟" {
t.Errorf("q = %q", got)
}
if got := r.URL.Query().Get("format"); got != "json" {
t.Errorf("format = %q, want json", got)
}
case <-time.After(2 * time.Second):
t.Fatal("配置里的端点一个请求都没收到 —— 配置没被透传到工具")
}
}
// TestWireIOTools_TimeoutIsWired 钉住 WEB_SEARCH_TIMEOUT 真的接到了工具上:
// 一个慢端点 + 100ms 预算必须被掐断(默认 15s 够它跑完,因此「被掐断」只可能来自
// 这个配置)。负值按未配置处理并记一条 WARN。
func TestWireIOTools_TimeoutIsWired(t *testing.T) {
t.Parallel()
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
}
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
defer close(release)
cfg := wsLoadConfig(t,
"WEB_SEARCH_ENDPOINT="+srv.URL,
"WEB_SEARCH_TIMEOUT=100ms",
)
if cfg.WebSearch.Timeout != 100*time.Millisecond {
t.Fatalf("WEB_SEARCH_TIMEOUT 没被解析: %v", cfg.WebSearch.Timeout)
}
a := wsSurfaceApp(t, cfg, nil)
tl, err := a.caps.Get("web-search")
if err != nil {
t.Fatalf("web-search 不在注册面里: %v", err)
}
exec := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
if _, err := exec.Execute(context.Background(), map[string]any{"query": "cyrene"}); err == nil {
t.Fatal("100ms 预算下慢端点必须被掐断(说明配置没接到工具上)")
}
// 负值 = 笔误:按未配置处理(工具仍在),并留下一条带键名的 WARN。
var buf bytes.Buffer
negCfg := wsLoadConfig(t,
"WEB_SEARCH_ENDPOINT="+srv.URL,
"WEB_SEARCH_TIMEOUT=-5s",
)
negApp := wsSurfaceApp(t, negCfg, &buf)
if !wsHasTool(negApp, "web-search") {
t.Fatal("负超时只应退回默认,不该把工具从注册面摘掉")
}
if out := buf.String(); !strings.Contains(out, webSearchTimeoutEnv) {
t.Fatalf("负超时必须在日志里点出 %s:%q", webSearchTimeoutEnv, out)
}
}
// wsSearxngResultCount 用**裸 HTTP**问兜底服务(与工具实现无关)同一个查询,返回它此刻
// 真能产出的结果条数;不可达/应答不可解析时返回 -1。理由与命名同 io 侧的探针:上游
// 引擎被限流时兜底会「200 但 0 条」,那是外部降级,不该判成本仓的代码红。
func wsSearxngResultCount(endpoint, query string) int {
client := &http.Client{Timeout: 25 * time.Second}
resp, err := client.Get(endpoint + "/search?q=" + url.QueryEscape(query) + "&format=json")
if err != nil {
return -1
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return -1
}
var payload struct {
Results []struct {
URL string `json:"url"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return -1
}
return len(payload.Results)
}
// TestWireIOTools_RealSearXNG_EndToEnd 是**真机**端到端:默认配置(一个 WEB_SEARCH_*
// 键都不写)⇒ 组合根接线 ⇒ 注册面里的 web-search ⇒ 打本机兜底 SearXNG 真的拿到结果。
// 它只读外部服务,不碰后端进程、不碰仓库根产物。兜底没起、或其上游引擎被限流到产不出
// 结果时 **skip**(外部降级不判代码红;探针有结果而工具没有 = 真红)。
func TestWireIOTools_RealSearXNG_EndToEnd(t *testing.T) {
t.Parallel()
const query = "昔涟 崩坏星穹铁道"
cfg := wsLoadConfig(t)
upstream := wsSearxngResultCount(cfg.WebSearch.Endpoint, query)
if upstream < 0 {
t.Skipf("本机兜底 SearXNG 不可达(%s),跳过真机 e2e", cfg.WebSearch.Endpoint)
}
if upstream == 0 {
t.Skipf("兜底 SearXNG 当前产不出结果(上游引擎被限流/CAPTCHA),跳过真机 e2e")
}
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, "web-search") {
t.Fatalf("默认配置下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
}
tl, err := a.caps.Get("web-search")
if err != nil {
t.Fatalf("caps.Get(web-search): %v", err)
}
exec := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
if err != nil {
t.Fatalf("默认端点真机查询失败: %v", err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("返回值类型 = %T", got)
}
count, _ := m["count"].(int)
if count == 0 {
t.Fatalf("裸 HTTP 探针拿到 %d 条、工具却拿到 0 条 —— 这不再是外部降级: %v", upstream, m)
}
t.Logf("真机 e2e(默认配置 %s):返回 %d 条结果", cfg.WebSearch.Endpoint, count)
}
// TestWireIOTools_SeamsAndEnvTemplate 是防回归补充:组合根那两个接缝还在,
// env 键与 `.env.example` 逐字一致(AGENTS §10 的 E6 固定动作)。
func TestWireIOTools_SeamsAndEnvTemplate(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// io 工具集必须经这一个入口构造,否则端点/后端裁决会被绕过。
"iotools.New(a.cfg, a.ioToolOptions()...)",
},
"wire_io_tools.go": {
// WS1-B:各家接线 + 主 provider/降级链选定,是 web-search 后端唯一来源。
"iotools.WithSearchProviders(specs...)",
"iotools.WithSearchSelection(ws.Provider, ws.Fallback)",
"iotools.WithSearchMaxResults(ws.MaxResults)",
"iotools.WithoutSearch()",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— web-search 的接线/开关断了", file, want)
}
}
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
template := string(raw)
for _, line := range []string{
webSearchProviderEnv + "=",
webSearchEndpointEnv + "=",
webSearchTimeoutEnv + "=",
webSearchMaxResultsEnv + "=",
webSearchFallbackEnv + "=",
// 默认值必须在模板里如实写出(本机兜底 + searxng 主后端),否则照模板部署的人
// 看不出它接的是什么。
webSearchEndpointEnv + "=http://127.0.0.1:38471",
webSearchProviderEnv + "=searxng",
} {
if !strings.Contains(template, line) {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", line)
}
}
}