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

+95
View File
@@ -0,0 +1,95 @@
package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// volcengineProfile 是火山引擎·豆包搜索(SearchInfinity,design/27 §2.1;分册 13 官方原文确证)。
//
// 接口形态:**POST `/search_api/web_search` + `Authorization: Bearer <API_KEY>`**
// ⇒ 标准库即可,无需签名/SDK(不必走 IAM AK/SK 的 TOP 网关那条路)。
// 请求字段是**大写驼峰**(Query/SearchType/Count/Filter),与其它家不同 —— 这是上游口径。
//
// 分册 13 确证的口径:`Query` 1~100 字符且**不支持多词搜索**;账号维度默认 10 QPS;
// 每账号每月 500 次免费(自 2026-03-17 起对个人账号生效);按量 0.020 元/次。
//
// ⚠️ 诚实边界(分册 13「未确证」):**响应字段未逐字取全**(含「如意」结构化卡)
// ⇒ 用共享提取器按多组候选键容错,不把猜测的字段当事实;拿到真实 key 后按官方文档收窄。
type volcengine struct{ cfg Config }
// volcenginePath 是 API Key 接入的端点路径
// (分册 13 官方原文:POST https://open.feedcoopapi.com/search_api/web_search)。
const volcenginePath = "/search_api/web_search"
// volcengineSearchTypeWeb 是搜索类型枚举(分册 13 官方原文:目前支持 `web`,需开通 web搜索)。
const volcengineSearchTypeWeb = "web"
// volcengineDefaultCount / volcengineMaxCount 是条数口径
// (分册 13 官方原文:Count 最多 50 条,默认 10 条)。
const (
volcengineDefaultCount = 10
volcengineMaxCount = 50
)
// newVolcengine 造一家火山·豆包搜索 provider。
func newVolcengine(cfg Config) Provider { return &volcengine{cfg: cfg} }
// Name 实现 Provider。
func (p *volcengine) Name() string { return NameVolcengine }
// Search 实现 Provider。
func (p *volcengine) Search(ctx context.Context, q Query) ([]Result, error) {
payload := map[string]any{
"Query": q.Text,
"SearchType": volcengineSearchTypeWeb,
"Count": volcengineCount(q.Count),
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: volcengine: encode request: %w", err)
}
data, err := do(ctx, p.cfg, request{
Method: http.MethodPost,
URL: baseURLJoin(p.cfg.BaseURL, volcenginePath),
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
Body: body,
})
if err != nil {
return nil, err
}
return extractResults(data, volcengineSpec())
}
// volcengineCount 把调用方要的条数收进官方允许区间(分册 13:最多 50,默认 10)。
func volcengineCount(n int) int {
if n <= 0 {
return volcengineDefaultCount
}
if n > volcengineMaxCount {
return volcengineMaxCount
}
return n
}
// volcengineSpec 是火山·豆包搜索响应的**候选**形状。
//
// TODO(未确证):分册 13 只逐字取到了请求字段;响应结构(外层 Result.WebResults 与
// 每条结果的 Title/Url/Snippet/Summary/PublishTime/RankScore 等)**未确证**。
// 这里按可信度从高到低列候选键做容错解析 —— 它是「尽量认得出来」,不是「已确证」;
// 拿到真实 key 后必须按官方文档复核并删掉不成立的候选。
func volcengineSpec() spec {
return spec{
Arrays: []string{"Result.WebResults", "WebResults", "results", "Result.Results"},
Title: []string{"Title", "title"},
Link: []string{"Url", "url", "Link"},
Snippet: []string{
"Snippet", "snippet", "Summary", "summary",
},
Content: []string{"Content", "content", "Summary", "summary"},
Published: []string{"PublishTime", "publishTime", "PublishTimeMs"},
Score: []string{"RankScore", "rankScore", "Score", "score"},
}
}