Files
Cyrene_v2/cmd/cyrene/wire_web_search_providers_test.go
T
AskaEth ce9af42945
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。

- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
  两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
  Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
  (UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
  RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
  实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
  .env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
  计数不变(14 + 25 + 40 = 79)。
2026-10-07 13:42:41 +08:00

253 lines
9.7 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,
search.NameTencentWSA, search.NameMetaso,
} {
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,
search.NameTencentWSA: true, search.NameMetaso: true,
}
got := search.KnownNames()
if len(got) != len(want) {
t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan/tencent_wsa/metaso)",
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)
}