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),并有「未登记⇒启动报错」的防回归断言
This commit is contained in:
1 parent
236b202fda
commit
69a3b73777
24 files changed
+3251
-171
No files matched your search
+107
-22
@@ -1,31 +1,42 @@
|
||||
package main
|
||||
|
||||
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及
|
||||
// **web-search 的端点与超时**(切片 WS1-A)。
|
||||
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层)。
|
||||
//
|
||||
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
|
||||
// 而本片新增的裁决(端点关闭/非法 ⇒ 工具不进注册面)需要一个能单测的落点。
|
||||
// 而本片新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
|
||||
// ⇒ 启动报错)需要一个能单测的落点。
|
||||
//
|
||||
// 三条口径:
|
||||
// 四条口径:
|
||||
//
|
||||
// 1. **端点默认开箱即用**:`WEB_SEARCH_ENDPOINT` 未配置时取 config 标签默认值
|
||||
// (本机兜底 http://127.0.0.1:38471,配套服务见 scripts/searxng/settings.yml)。
|
||||
// 2. **拿不到可用端点就不注册工具**(诚意要求,主人 2026-10-05 报障的核心诉求):
|
||||
// 端点显式关闭(空串 / off / none)或不是 http(s) 绝对地址时,web-search
|
||||
// **不进注册面** —— 模型根本看不见它,而不是看得见、一调就报错。
|
||||
// 3. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` 未配置(0)时沿用 io 包既有超时;
|
||||
// 1. **后端由配置选定**:`WEB_SEARCH_PROVIDER` 选主 provider,`WEB_SEARCH_FALLBACK`
|
||||
// 给降级链;各家的 key/base URL 走 `WEB_SEARCH_<NAME>_*`(design/27 §3)。
|
||||
// 2. **拿不到可用后端就不注册工具**(主人 2026-10-05 报障的核心诉求):端点显式关闭
|
||||
// (空串 / off / none)或不是 http(s) 绝对地址时,web-search **不进注册面** ——
|
||||
// 模型根本看不见它,而不是看得见、一调就报错。
|
||||
// 3. **未配置的家不进可用清单**:`search.Configured` 判定(云家看 key、searxng 看端点),
|
||||
// false ⇒ 该家不出现在注册表里(fail-closed)。选定 provider 未配置时由 io 工具集在
|
||||
// **启动期报错**,绝不静默回落到兜底。
|
||||
// 4. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` 未配置(0)时沿用 io 包既有超时;
|
||||
// 负值是笔误,按未配置处理并记一条带 env 键名的 WARN(不静默)。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// web-search 的两个 env 键名:必须与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
|
||||
// web-search 的 env 键名:必须与 `.env.example` 逐字一致(AGENTS §10 的 E6 条目),
|
||||
// 由 wire_web_search_test.go 逐字核对。
|
||||
const (
|
||||
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
|
||||
webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT"
|
||||
webSearchProviderEnv = "WEB_SEARCH_PROVIDER"
|
||||
webSearchEndpointEnv = "WEB_SEARCH_ENDPOINT"
|
||||
webSearchTimeoutEnv = "WEB_SEARCH_TIMEOUT"
|
||||
webSearchMaxResultsEnv = "WEB_SEARCH_MAX_RESULTS"
|
||||
webSearchFallbackEnv = "WEB_SEARCH_FALLBACK"
|
||||
)
|
||||
|
||||
// ioLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
|
||||
@@ -41,26 +52,100 @@ func (a *app) ioToolOptions() []iotools.Option {
|
||||
return append(opts, a.webSearchOptions()...)
|
||||
}
|
||||
|
||||
// webSearchOptions 把 web-search 的配置翻译成 io 选项。端点裁决见
|
||||
// config.WebSearchConfig.ResolveEndpoint(关闭 / 非法 / 可用三种去处都在那里定死)。
|
||||
// webSearchOptions 把 web-search 的配置翻译成 io 选项。
|
||||
// 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。
|
||||
func (a *app) webSearchOptions() []iotools.Option {
|
||||
log := logger.WithSource(a.log, ioLogSource)
|
||||
decision := a.cfg.WebSearch.ResolveEndpoint()
|
||||
if !decision.Enabled {
|
||||
// 端点的值**绝不写进日志**(URL 里可能带凭据),只记键名与原因。
|
||||
ws := a.cfg.WebSearch
|
||||
|
||||
// ① 后端显式关闭(WEB_SEARCH_PROVIDER = 空 / off / none)⇒ 整件工具不注册。
|
||||
if decision := ws.ResolveProvider(); decision.Disabled {
|
||||
// 配置值**绝不写进日志**(只记键名与原因)。
|
||||
log.Warn("web-search tool not registered",
|
||||
"env", webSearchEndpointEnv, "reason", decision.Reason,
|
||||
"env", webSearchProviderEnv, "reason", config.WebSearchReasonOff,
|
||||
"effect", "the model will not see a web-search tool at all")
|
||||
return []iotools.Option{iotools.WithoutSearch()}
|
||||
}
|
||||
|
||||
opts := []iotools.Option{iotools.WithSearchEndpoint(decision.URL)}
|
||||
specs := a.searchProviderSpecs()
|
||||
|
||||
// ② searxng 的可用性 = 端点裁决(WS1-A 的既有语义):端点显式关闭 / 不是 http(s)
|
||||
// 绝对地址时**整件工具不注册** —— 那两种取值是「关掉搜索」的显式选择,不是配置
|
||||
// 错误,因此不 fatal,只留一条带键名的 WARN。
|
||||
if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && a.selectsSearXNG() {
|
||||
log.Warn("web-search tool not registered",
|
||||
"env", webSearchEndpointEnv, "reason", endpoint.Reason,
|
||||
"effect", "the model will not see a web-search tool at all")
|
||||
return []iotools.Option{iotools.WithoutSearch()}
|
||||
}
|
||||
|
||||
log.Info("web-search providers configured",
|
||||
"env", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)),
|
||||
"env_fallback", webSearchFallbackEnv, "fallback", ws.Fallback,
|
||||
"available", availableProviderNames(specs))
|
||||
|
||||
opts := []iotools.Option{
|
||||
iotools.WithSearchProviders(specs...),
|
||||
// 选定主 provider + 降级链:未登记(如本期未做的 tavily)或未配置(缺 key)
|
||||
// ⇒ io 工具集在启动期报错(不许静默回落)。
|
||||
iotools.WithSearchSelection(ws.Provider, ws.Fallback),
|
||||
iotools.WithSearchMaxResults(ws.MaxResults),
|
||||
}
|
||||
switch {
|
||||
case a.cfg.WebSearch.Timeout > 0:
|
||||
opts = append(opts, iotools.WithSearchTimeout(a.cfg.WebSearch.Timeout))
|
||||
case a.cfg.WebSearch.Timeout < 0:
|
||||
case ws.Timeout > 0:
|
||||
opts = append(opts, iotools.WithSearchTimeout(ws.Timeout))
|
||||
case ws.Timeout < 0:
|
||||
log.Warn("web-search timeout is negative, treated as unset",
|
||||
"env", webSearchTimeoutEnv, "effect", "io package default timeout applies")
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// selectsSearXNG 报告当前配置选的是不是 searxng(大小写与空白不敏感)。
|
||||
func (a *app) selectsSearXNG() bool {
|
||||
selected := strings.TrimSpace(a.cfg.WebSearch.Provider)
|
||||
return strings.EqualFold(selected, search.NameSearXNG)
|
||||
}
|
||||
|
||||
// searchProviderSpecs 把 `WEB_SEARCH_*` 配置翻译成各家接线(一家一行)。
|
||||
//
|
||||
// Configured 的口径由 search.Configured 统一判定:searxng 看端点、云家看 API key;
|
||||
// false ⇒ 该家不进注册表、不出现在可用清单里。**密钥只在这里流转,绝不写日志。**
|
||||
func (a *app) searchProviderSpecs() []iotools.SearchProviderSpec {
|
||||
ws := a.cfg.WebSearch
|
||||
endpoint := ws.ResolveEndpoint()
|
||||
|
||||
specs := []iotools.SearchProviderSpec{{
|
||||
Name: search.NameSearXNG,
|
||||
BaseURL: endpoint.URL,
|
||||
Configured: endpoint.Enabled,
|
||||
}}
|
||||
// 各家独立一组键(design/27 §3):新增一家 = 这里加一行 + search 包加一个文件。
|
||||
for _, p := range []struct{ name, key, base string }{
|
||||
{search.NameZhipu, ws.ZhipuAPIKey, ws.ZhipuBaseURL},
|
||||
{search.NameAliyunIQS, ws.AliyunIQSAPIKey, ws.AliyunIQSBaseURL},
|
||||
{search.NameBocha, ws.BochaAPIKey, ws.BochaBaseURL},
|
||||
{search.NameVolcengine, ws.VolcengineAPIKey, ws.VolcengineBaseURL},
|
||||
{search.NameBaiduQianfan, ws.BaiduQianfanAPIKey, ws.BaiduQianfanBaseURL},
|
||||
} {
|
||||
specs = append(specs, iotools.SearchProviderSpec{
|
||||
Name: p.name,
|
||||
APIKey: p.key,
|
||||
BaseURL: p.base,
|
||||
Configured: search.Configured(p.name, p.key, p.base),
|
||||
})
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
// availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。
|
||||
// **不含任何密钥** —— 只回名字。
|
||||
func availableProviderNames(specs []iotools.SearchProviderSpec) []string {
|
||||
out := make([]string, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
if s.Configured {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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)
|
||||
}
|
||||
@@ -328,16 +328,19 @@ func TestWireIOTools_RealSearXNG_EndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestWireIOTools_SeamsAndEnvTemplate 是防回归补充:组合根那两个接缝还在,
|
||||
// 两个 env 键与 `.env.example` 逐字一致(AGENTS §10 的 E6 固定动作)。
|
||||
// env 键与 `.env.example` 逐字一致(AGENTS §10 的 E6 固定动作)。
|
||||
func TestWireIOTools_SeamsAndEnvTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
seams := map[string][]string{
|
||||
"wire_runtime.go": {
|
||||
// io 工具集必须经这一个入口构造,否则端点裁决会被绕过。
|
||||
// io 工具集必须经这一个入口构造,否则端点/后端裁决会被绕过。
|
||||
"iotools.New(a.cfg, a.ioToolOptions()...)",
|
||||
},
|
||||
"wire_io_tools.go": {
|
||||
"iotools.WithSearchEndpoint(decision.URL)",
|
||||
// WS1-B:各家接线 + 主 provider/降级链选定,是 web-search 后端唯一来源。
|
||||
"iotools.WithSearchProviders(specs...)",
|
||||
"iotools.WithSearchSelection(ws.Provider, ws.Fallback)",
|
||||
"iotools.WithSearchMaxResults(ws.MaxResults)",
|
||||
"iotools.WithoutSearch()",
|
||||
},
|
||||
}
|
||||
@@ -359,10 +362,15 @@ func TestWireIOTools_SeamsAndEnvTemplate(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user