feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

接入位表六家扩到八家,端点形状与错误码一字未改(契约 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)。
This commit is contained in:
AskaEth committed 2026-10-07 13:42:41 +08:00
1 parent 8e3311cea2
commit ce9af42945
21 files changed
+1219 -36

No files matched your search

@@ -0,0 +1,91 @@
package main
// 本文件钉住本片新增的两家(`tencent_wsa` / `metaso`)在**组合根里真的接通了**:
// 它们**没有 `.env` 字段**(`.env` 已冻结),因此凭据与地址只能来自面板文件
// `$DATA_DIR/config/plugins/web-search.json`,而组合根**零改动**地按接入位表把它们投影出来
// (`resolveSearchProviders` 逐家走 `search.Descriptors()`)。
//
// 为什么值得端到端一条:`cmd/cyrene/websearch_providers.go` 里**一行都没改**——这正是
// 「按接入位表投影」的设计意图;没有这条用例,「零改动」就只是一句没有证据的话。
//
// 夹具(假上游、`wsSurfaceApp`、`wsExecuteSearch`)在 websearch_testenv_test.go /
// websearch_projection_test.go;`.env` 字段表的完整性由 wire_web_search_*_test.go 钉住。
import (
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// TestWebSearchPanelOnlyProvidersWiredEndToEnd 是判据:面板文件里配好一家**没有 env 字段**
// 的家 ⇒ 工具进注册面 ⇒ 真的把请求打到面板里那个地址、带上面板里那把 key,且结果里如实
// 标注 provider。
func TestWebSearchPanelOnlyProvidersWiredEndToEnd(t *testing.T) {
t.Parallel()
cases := []struct {
name string
path string
fixture string
}{
{
name: search.NameTencentWSA,
path: "/SearchPro",
// Pages 的元素是 JSON **字符串**(官方 Array of String 形状)。
fixture: `{"Response":{"Pages":["{\"title\":\"元宝结果\",\"url\":\"https://t.example/1\",` +
`\"passage\":\"元宝摘要\"}"],"Query":"昔涟","RequestId":"r"}}`,
},
{
name: search.NameMetaso,
path: "/api/v1/search",
fixture: `{"webpages":[{"title":"秘塔结果","link":"https://m.example/1","snippet":"秘塔摘要"}]}`,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
const key = "test-panel-only-key"
up := newWSFakeUpstream(t, http.StatusOK, tc.fixture)
// `.env` 只给「谁当主家」:这两家在 `.env` 里没有字段,凭据与地址只能来自面板文件。
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+tc.name)
// 前提:它确实不在 `.env` 字段表里(否则这条用例会退化成既有 6 家的回归)。
for _, p := range searchProviderVector(cfg.WebSearch) {
if strings.EqualFold(p.Name, tc.name) {
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", tc.name)
}
}
// 前提:接入位表里确有它(否则组合根根本投影不出来)。
if !search.Known(tc.name) {
t.Fatalf("前提被破坏:%s 应已在接入位表里登记", tc.name)
}
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
Provider: tc.name,
Providers: []runtimeconfig.ProviderEntry{
{Provider: tc.name, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
},
})
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("面板文件里配好的 %s 应让工具进注册面,实际工具面 = %v", tc.name, toolNames(a))
}
if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != tc.name {
t.Fatalf("provider = %v, want %q(如实标注实际应答的家)", got["provider"], tc.name)
}
select {
case r := <-up.hits:
if r.URL.Path != tc.path {
t.Errorf("请求路径 = %q, want %q(各家固定路径)", r.URL.Path, tc.path)
}
if got := r.Header.Get("Authorization"); got != "Bearer "+key {
t.Errorf("Authorization = %q,want 面板文件里那把 key(凭据必须以文件为准)", got)
}
default:
t.Fatalf("%s 一个请求都没收到 —— 面板里的配置没接到 provider 上", tc.name)
}
})
}
}
+10 -5
View File
@@ -35,11 +35,15 @@ import (
//
// 它在 search 包里**没有**登记(`search.Known` 为 false),也没有对应的 `.env` 字段 ——
// 于是它正好是判据 ③ 的载体:「没有 env 字段的家,只认文件也能被投影出生效配置」。
//
// ⚠️ 名字取 `fake_next_provider` 而不是某个真接入位(早先这里写的是 `tencent_wsa`):
// `tencent_wsa` 已经**真的登记**(本片新增),沿用旧名会让这条用例的前提断言直接失败 ——
// 假接入位必须是一个**永远不会被登记**的名字,用例才钉得住「没 env 字段的家」这件事。
func wsFakeNewProviderDef() search.Descriptor {
return search.Descriptor{
Name: "tencent_wsa",
Name: "fake_next_provider",
NeedKey: true,
DefaultBaseURL: "https://wsa.tencentcloudapi.com",
DefaultBaseURL: "https://fake-next.example",
}
}
@@ -233,11 +237,12 @@ func TestWebSearchProviderFromEnvSeedLayer(t *testing.T) {
// --- ③ 没有 env 字段的家:只认文件(本片的**核心判据**) ----------------------
// TestWebSearchProviderWithoutEnvFieldIsProjected 是判据 ③ 的投影层:**没有 env 字段的家**
// (测试专用的假接入位 `tencent_wsa`)在文件里有 key ⇒ 照样被投影出生效配置。
// (测试专用的假接入位 `fake_next_provider`)在文件里有 key ⇒ 照样被投影出生效配置。
//
// 这条判据就是本片存在的理由:旧形状的逐家 switch 只认 `.env` 字段表里的 6 家,第 7 家
// 想进来只能先加 env 键(破红线)。新形状按**接入位表**逐家投影,因此第 7 家只需在
// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动。
// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动
// —— 本片新接的 `tencent_wsa` / `metaso` 走的正是这条路(它们没有 env 键)。
//
// 最后一跳(进注册表)由同文件的 TestWebSearchFileOnlyProviderEntersRegistry 用真实接入位
// 走完:注册表按名字查的正是同一张接入位表。
@@ -252,7 +257,7 @@ func TestWebSearchProviderWithoutEnvFieldIsProjected(t *testing.T) {
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", fake.Name)
}
}
// 前提二:它确实还没登记(登记工作是下一片的:加接入位文件 + 表里一行)。
// 前提二:它确实还没登记(假接入位的全部意义就在于此)。
if search.Known(fake.Name) {
t.Fatalf("前提被破坏:假接入位 %s 不该已经登记", fake.Name)
}
+6 -2
View File
@@ -15,7 +15,8 @@ package main
// (既有 6 家的键与值逐字不变,主人已在用的配置不受影响)。
//
// ⇒ 加一家 provider = `plugins/tools/io/search` 加一个接入位文件 + 表里一行,**本文件一行
// 都不用改**,`.env` 一个键都不用加。
// 都不用改**,`.env` 一个键都不用加。`tencent_wsa` / `metaso` 就是这么进来的(两家都
// **没有** env 字段:`.env` 已冻结,它们的 key / 基地址只从面板文件取)。
import (
"strings"
@@ -55,7 +56,10 @@ type searchProviderWire struct {
// 云家部分)。它只服务两处:`.env` 缺失时的播种内容(seedWebSearchFile),以及
// 「没有可用文件」时的生效投影(envEffectiveWebSearch)。
//
// ⚠️ 它**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。
// ⚠️ 它**不是**「本期内置几家」的名单:登记表里还有两家**没有 env 字段**的家
// (tencent_wsa / metaso,`.env` 已冻结 ⇒ 不给它们加键),它们只从面板文件生效
// (`resolveSearchProviders` 按 `search.Descriptors()` 逐家投影,不经过本函数)。
// 它也**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。
// 这条口径由 `wire_web_search_panel_test.go` 的
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv` 钉住。
func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire {
+12 -2
View File
@@ -73,8 +73,18 @@ func TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative(t *testing.T) {
if f.Provider != search.NameBocha {
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameBocha)
}
if len(f.Providers) != len(search.KnownNames()) {
t.Fatalf("播种必须列全本期内置的家:%d 条, want %d", len(f.Providers), len(search.KnownNames()))
// 播种内容 = `.env` 字段表那张表(searxng + 5 家云家)。**没有 env 字段的家**
// (tencent_wsa / metaso)不进播种:`.env` 已冻结(不加键),它们只从面板文件配。
seededNames := envSearchProviders(first.WebSearch)
if len(f.Providers) != len(seededNames) {
t.Fatalf("播种必须列全 `.env` 字段表里的家:%d 条, want %d", len(f.Providers), len(seededNames))
}
for _, absent := range []string{search.NameTencentWSA, search.NameMetaso} {
for _, e := range f.Providers {
if strings.EqualFold(e.Provider, absent) {
t.Fatalf("没有 env 字段的家 %s 不得被播种(它只从面板文件配)", absent)
}
}
}
seeded := false
for _, e := range f.Providers {
+7 -3
View File
@@ -112,7 +112,10 @@ func TestWireIOTools_AvailableListExcludesUnconfigured(t *testing.T) {
if !strings.Contains(out, search.NameBocha) {
t.Fatalf("可用清单里应有 bocha: %q", out)
}
for _, absent := range []string{search.NameZhipu, search.NameAliyunIQS, search.NameVolcengine, search.NameBaiduQianfan} {
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)
}
@@ -170,16 +173,17 @@ func TestWireIOTools_SelectedCloudProviderIsUsed(t *testing.T) {
}
// TestWireIOTools_ProviderVectorSummary 是一句「本期内置哪几家」的防回归:
// 六家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。
// 八家的名字都必须被 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)",
t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan/tencent_wsa/metaso)",
got, len(want))
}
for _, n := range got {