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:
AskaEth committed 2026-10-05 22:38:20 +08:00
1 parent 236b202fda
commit 69a3b73777
24 files changed
+3251 -171

No files matched your search

+107 -22
View File
@@ -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
}