Files
Cyrene_v2/internal/config/websearch_providers_test.go
T
AskaEth bff57bd1ae
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 结果条数上限 5 → 10,并让「上限」真的是上限
主人原话:「返回五条结果会不会太少了?」。裁定:**上限抬高到 10,且它是真上限**
(模型要 50 也只给 10);模型要更少(3)时**尊重模型**;缺省/非法(≤0/非数)⇒ 上限。

- plugins/tools/io/web_search.go:`n := resultLimit(s.maxResults, m)` 一处收口
  (原来是 `if count>0 { n = count }` ⇒ 模型的 count 能顶掉配置上限,与
  .env.example 写的「条数上限」不符 —— 那是一条真口径缺陷,本次一并修正)。
- internal/config/websearch.go:`WEB_SEARCH_MAX_RESULTS` 代码默认值 5 → 10
  (三处口径现已一致:env 默认 10 / io 包 defaultMaxResults 10 / 各家发给上游的
  请求区间是另一回事,未动)。
- 生产:`data/config/plugins/web-search.json` 的 `maxResults` 5 → 10 + 一次
  saveProvider 触发**热重载**(applied+registered),盘上已生效。

验收(主会话复跑 + 亲自打两个变异):
- io 包 / internal/config / cmd/cyrene 相关用例全绿;
- 变异 A(拆掉上限、恒取 50)⇒ **4 条用例变红**(含「默认上限 10」「count 非数值
  ⇒ 上限」「count=0 ⇒ 上限」「既有 MaxResultsOption」);
- 变异 B(默认值退回 5)⇒ internal/config 的默认值断言变红。

🔴 顺带挖出一条真缺陷(**本片只报不修,等派**):`maxResults` **在面板 UI 上
根本改不了** —— ① `search/panel.go` 的字段表里没有这个字段(输入框不显示);
② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;③ `mergeSelectionPatch`
只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**(「我改了它却没变」
成立)。修它要给面板加一个数字字段 + 三态落 `f.MaxResults`,属**管理面板对外契约
变更**(须同步 Docs/api-client.md),留给主会话。
2026-10-07 10:58:40 +08:00

129 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 本文件钉住切片 WS1-B 的搜索配置键(design/27 §3):
// WEB_SEARCH_PROVIDER / _FALLBACK / _MAX_RESULTS 与各家独立一组键
// (WEB_SEARCH_<NAME>_API_KEY / _BASE_URL),以及 ResolveProvider 的关闭语义。
// wsTestLoad 写一份只含必填键加 extra 行的 .env,加载出配置。
func wsTestLoad(t *testing.T, extra ...string) (*Config, error) {
t.Helper()
lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
return Load(path)
}
// TestWebSearchProvider_Defaults 钉住三个新键的默认值:provider=searxng、
// maxResults=10、fallback 为空(= 只管手选那一家)。
func TestWebSearchProvider_Defaults(t *testing.T) {
cfg, err := wsTestLoad(t)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.WebSearch.Provider != "searxng" {
t.Fatalf("WEB_SEARCH_PROVIDER 默认值 = %q, want searxng", cfg.WebSearch.Provider)
}
if cfg.WebSearch.MaxResults != 10 {
t.Fatalf("WEB_SEARCH_MAX_RESULTS 默认值 = %d, want 10", cfg.WebSearch.MaxResults)
}
if len(cfg.WebSearch.Fallback) != 0 {
t.Fatalf("WEB_SEARCH_FALLBACK 未配置时应为空, got %v", cfg.WebSearch.Fallback)
}
// 云家的 base URL 有默认值、key 一律为空(未配置 ⇒ 该家不出现在可用清单里)。
if cfg.WebSearch.ZhipuBaseURL == "" || cfg.WebSearch.BochaBaseURL == "" ||
cfg.WebSearch.AliyunIQSBaseURL == "" || cfg.WebSearch.VolcengineBaseURL == "" ||
cfg.WebSearch.BaiduQianfanBaseURL == "" {
t.Fatalf("各家的 BASE_URL 必须有默认值: %+v", cfg.WebSearch)
}
if cfg.WebSearch.ZhipuAPIKey != "" || cfg.WebSearch.BochaAPIKey != "" ||
cfg.WebSearch.AliyunIQSAPIKey != "" || cfg.WebSearch.VolcengineAPIKey != "" ||
cfg.WebSearch.BaiduQianfanAPIKey != "" {
t.Fatalf("各家 API key 未配置时必须为空: %+v", cfg.WebSearch)
}
}
// TestWebSearchProvider_EnvOverrides 钉住新键都能被 .env 覆盖(含逗号分隔的降级链)。
func TestWebSearchProvider_EnvOverrides(t *testing.T) {
cfg, err := wsTestLoad(t,
"WEB_SEARCH_PROVIDER=Bocha",
"WEB_SEARCH_FALLBACK=zhipu,bocha,searxng",
"WEB_SEARCH_MAX_RESULTS=9",
"WEB_SEARCH_ZHIPU_API_KEY=zk",
"WEB_SEARCH_ZHIPU_BASE_URL=https://zhipu.example",
"WEB_SEARCH_ALIYUN_IQS_API_KEY=ak",
"WEB_SEARCH_ALIYUN_IQS_BASE_URL=https://iqs.example",
"WEB_SEARCH_BOCHA_API_KEY=bk",
"WEB_SEARCH_BOCHA_BASE_URL=https://bocha.example",
"WEB_SEARCH_VOLCENGINE_API_KEY=vk",
"WEB_SEARCH_VOLCENGINE_BASE_URL=https://volc.example",
"WEB_SEARCH_BAIDU_QIANFAN_API_KEY=qk",
"WEB_SEARCH_BAIDU_QIANFAN_BASE_URL=https://qianfan.example",
)
if err != nil {
t.Fatalf("Load: %v", err)
}
ws := cfg.WebSearch
if ws.Provider != "Bocha" {
t.Fatalf("Provider = %q(大小写应原样保留,比较时再归一化)", ws.Provider)
}
if got := ws.Fallback; len(got) != 3 || got[0] != "zhipu" || got[2] != "searxng" {
t.Fatalf("Fallback = %v, want [zhipu bocha searxng]", got)
}
if ws.MaxResults != 9 {
t.Fatalf("MaxResults = %d, want 9", ws.MaxResults)
}
for name, got := range map[string]string{
"Zhipu": ws.ZhipuAPIKey, "AliyunIQS": ws.AliyunIQSAPIKey, "Bocha": ws.BochaAPIKey,
"Volcengine": ws.VolcengineAPIKey, "BaiduQianfan": ws.BaiduQianfanAPIKey,
} {
if got == "" {
t.Errorf("%s 的 API key 没被读进来", name)
}
}
for name, got := range map[string]string{
"Zhipu": ws.ZhipuBaseURL, "AliyunIQS": ws.AliyunIQSBaseURL, "Bocha": ws.BochaBaseURL,
"Volcengine": ws.VolcengineBaseURL, "BaiduQianfan": ws.BaiduQianfanBaseURL,
} {
if !strings.HasSuffix(got, ".example") {
t.Errorf("%s 的 BASE_URL 没被覆盖: %q", name, got)
}
}
}
// TestWebSearchConfig_ResolveProvider 是关闭语义的表驱动:空 / off / none ⇒ 关闭
// (组合根据此不注册工具);其余 ⇒ 选定名。
func TestWebSearchConfig_ResolveProvider(t *testing.T) {
cases := []struct {
name string
value string
wantDisable bool
wantSel string
}{
{"默认 searxng", "searxng", false, "searxng"},
{"大写 Zhipu", " Zhipu ", false, "Zhipu"},
{"空串 = 关闭", "", true, ""},
{"off", "off", true, ""},
{"none", "NONE", true, ""},
{"带空白的 None", " none ", true, ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := WebSearchConfig{Provider: tc.value}.ResolveProvider()
if got.Disabled != tc.wantDisable || got.Selected != tc.wantSel {
t.Fatalf("ResolveProvider(%q) = %+v, want disabled=%v selected=%q",
tc.value, got, tc.wantDisable, tc.wantSel)
}
})
}
}