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
|
||||
}
|
||||
Reference in new issue
Block a user