Files
Cyrene_v2/cmd/cyrene/wire_web_search_providers_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

249 lines
9.6 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"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// 本文件钉住切片 WS1-B 的组合根接线语义(design/27 §3):
// - 选定 provider 未登记 / 未配置 ⇒ **启动报错**(不许静默回落);
// - 后端显式关闭(off / 空)⇒ web-search **不进注册面**;
// - 缺 key 的家**不出现在可用清单**里;
// - 选定的云 provider 真的被用上(配置 → provider → 请求)且如实标注 provider。
// wsNewTools 走组合根的同一入口构造 io 工具集,返回它的错误(用于启动期断言)。
func wsNewTools(t *testing.T, cfg *config.Config) error {
t.Helper()
a := &app{cfg: cfg, log: testLog()}
_, err := iotools.New(cfg, a.ioToolOptions()...)
return err
}
// TestWireIOTools_ProviderOffOmitsWebSearch 钉住 WEB_SEARCH_PROVIDER 的关闭语义:
// 空 / off / none ⇒ 不注册工具(模型看不见),且**不报错**(是「少一把工具」)。
func TestWireIOTools_ProviderOffOmitsWebSearch(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, line string }{
{"显式留空", "WEB_SEARCH_PROVIDER="},
{"off", "WEB_SEARCH_PROVIDER=off"},
{"大写 NONE", "WEB_SEARCH_PROVIDER=NONE"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
cfg := wsLoadConfig(t, tc.line)
a := wsSurfaceApp(t, cfg, &buf)
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("%q 不得因 web-search 关闭而消失", name)
}
}
out := buf.String()
if !strings.Contains(out, webSearchProviderEnv) {
t.Fatalf("关闭必须留下带键名的 WARN: %q", out)
}
if !strings.Contains(out, "web-search tool not registered") {
t.Fatalf("WARN 必须说清工具没注册: %q", out)
}
})
}
}
// TestWireIOTools_UnavailableSelectionFailsStartup 钉住「未登记 / 未配置」两条都
// **启动报错**,且绝不静默回落到配好的 searxng 兜底。
func TestWireIOTools_UnavailableSelectionFailsStartup(t *testing.T) {
t.Parallel()
cases := []struct {
name string
lines []string
wantErr error
}{
{"未登记的名字(tavily)", []string{"WEB_SEARCH_PROVIDER=tavily"}, search.ErrUnknownProvider},
{"未登记的名字(typo)", []string{"WEB_SEARCH_PROVIDER=searxgn"}, search.ErrUnknownProvider},
{"已登记但缺 key(zhipu)", []string{"WEB_SEARCH_PROVIDER=zhipu"}, search.ErrNotConfigured},
{"已登记但缺 key(bocha)", []string{"WEB_SEARCH_PROVIDER=bocha"}, search.ErrNotConfigured},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t, tc.lines...)
err := wsNewTools(t, cfg)
if err == nil {
t.Fatal("选定不可用的 provider 必须启动报错(默认 searxng 兜底配好了也不许悄悄换过去)")
}
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, want errors.Is(..., %v)", err, tc.wantErr)
}
})
}
}
// TestWireIOTools_AvailableListExcludesUnconfigured 钉住「未配置的家不出现在可用清单里」:
// 启动日志的 available 只列已配齐的家。
func TestWireIOTools_AvailableListExcludesUnconfigured(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER=bocha",
"WEB_SEARCH_BOCHA_API_KEY=test-bocha-key",
)
a := wsSurfaceApp(t, cfg, &buf)
if !wsHasTool(a, "web-search") {
t.Fatalf("已配置的 provider 应让工具进注册面,实际 = %v", toolNames(a))
}
out := buf.String()
if !strings.Contains(out, "web-search providers configured") {
t.Fatalf("启动应如实列出可用清单: %q", out)
}
if !strings.Contains(out, search.NameBocha) {
t.Fatalf("可用清单里应有 bocha: %q", out)
}
for _, absent := range []string{search.NameZhipu, search.NameAliyunIQS, search.NameVolcengine, search.NameBaiduQianfan} {
if strings.Contains(out, absent) {
t.Fatalf("缺 key 的 %s 不得出现在可用清单里: %q", absent, out)
}
}
// 密钥绝不进日志(只列名字)。
if strings.Contains(out, "test-bocha-key") {
t.Fatalf("密钥进了日志: %q", out)
}
}
// TestWireIOTools_SelectedCloudProviderIsUsed 是「配置 → provider → 请求」的真透传断言:
// 选定的 bocha 真的带 Bearer 打到配置的 base URL 上,结果里 provider 也是 bocha。
func TestWireIOTools_SelectedCloudProviderIsUsed(t *testing.T) {
t.Parallel()
const key = "test-bocha-secret-key"
got := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got <- r
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`))
}))
defer srv.Close()
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER=bocha",
"WEB_SEARCH_BOCHA_API_KEY="+key,
"WEB_SEARCH_BOCHA_BASE_URL="+srv.URL,
)
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)
})
res, err := exec.Execute(context.Background(), map[string]any{"query": "昔涟"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if m, ok := res.(map[string]any); !ok || m["provider"] != search.NameBocha {
t.Fatalf("provider 必须如实是 bocha: %v", res)
}
select {
case r := <-got:
if r.URL.Path != "/v1/web-search" {
t.Errorf("请求路径 = %q, want /v1/web-search(各家固定路径)", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer "+key {
t.Errorf("Authorization = %q, want Bearer <key>(密钥必须真的被用上)", r.Header.Get("Authorization"))
}
default:
t.Fatal("选定的云 provider 一个请求都没发 —— 配置没接到 provider 上")
}
}
// TestWireIOTools_ProviderVectorSummary 是一句「本期内置哪几家」的防回归:
// 六家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。
func TestWireIOTools_ProviderVectorSummary(t *testing.T) {
t.Parallel()
want := map[string]bool{
search.NameSearXNG: true, search.NameZhipu: true, search.NameAliyunIQS: true,
search.NameBocha: true, search.NameVolcengine: true, search.NameBaiduQianfan: true,
}
got := search.KnownNames()
if len(got) != len(want) {
t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan)",
got, len(want))
}
for _, n := range got {
if !want[n] {
t.Errorf("登记了计划外的 provider %q", n)
}
}
// 本期明确不实现的 B 档:必须**未登记**(选它 ⇒ 启动报错,而不是静默回落)。
for _, n := range []string{"tavily", "exa", "firecrawl", "serper", "serpapi"} {
if search.Known(n) {
t.Errorf("%q 本期不实现,不该是已登记的名字(设计 §2 B 档)", n)
}
}
}
// TestWireIOTools_RealSearXNG_ProviderEndToEnd 是 WS1-B 的**真机**端到端:
// 默认配置(一个 WEB_SEARCH_* 键都不写)⇒ 组合根接线 ⇒ 注册面里的 web-search
// ⇒ 走 provider 注册表打本机兜底 SearXNG,真的拿到结果,并**如实标注 provider=searxng**。
//
// 它只读外部服务,不碰后端进程、不碰仓库根产物。本机兜底的上游只剩 `mwmbl`
// (英文爬取索引,无中文索引)⇒ 中文查询会「200 但 0 条」,那是外部覆盖面,不是代码红:
// 探针(裸 HTTP,与工具实现无关)用**同一个查询**先探测,0 条就 skip;
// 探针有结果而工具没有 = 真红。
func TestWireIOTools_RealSearXNG_ProviderEndToEnd(t *testing.T) {
t.Parallel()
// mwmbl 是英文索引:中文查询在本机兜底上恒为 0 条(分册 08 实测),
// 因此真机 e2e 用英文查询,才能真的检验「配置 → provider → 结果」这条链。
const query = "cyrene"
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("真机查询失败(探针报了 %d 条,工具却报错): %v", upstream, 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)
}
if m["provider"] != search.NameSearXNG {
t.Fatalf("provider = %v, want %q(如实标注实际应答的家)", m["provider"], search.NameSearXNG)
}
t.Logf("真机 e2e(WS1-B,端点 %s,query=%q):provider=%v 返回 %d 条结果",
cfg.WebSearch.Endpoint, query, m["provider"], count)
}