Files
Cyrene_v2/cmd/cyrene/websearch_maxresults_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

121 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 main
// 本文件钉住「面板文件的 `maxResults` 就是运行期的条数上限」这条路**通了**
// (主人 2026-10-07 裁定;口径见 design/27 §3 与 `cmd/cyrene/wire_io_tools.go` 的
// `effectiveSearchConfig` —— 面板文件是可变源,`.env` 只播种)。
//
// 夹具一律写进**本用例自己的** `$DATA_DIR`(`wsLoadConfig` 的 `t.TempDir()`):
// 生产文件 `data/config/plugins/web-search.json` 不在测试的触碰范围内。
import (
"context"
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// wsManyResultsBody 造一份 searxng 形状、n 条结果的假上游应答。
// link 逐条不同:`search.Finalize` 按 link 去重,重复的 link 会让「截断到 N」看不出效果。
func wsManyResultsBody(n int) string {
items := make([]string, 0, n)
for i := 0; i < n; i++ {
items = append(items, fmt.Sprintf(
`{"title":"T%d","url":"https://r%d.example/%d","content":"c%d"}`, i, i, i, i))
}
return `{"results":[` + strings.Join(items, ",") + `]}`
}
// wsPanelWithMaxResults 把一份夹具面板配置写进 cfg 的数据目录(maxResults 给 0 = 未声明)。
func wsPanelWithMaxResults(t *testing.T, cfg *config.Config, maxResults int, upstream string) {
t.Helper()
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Provider: search.NameSearXNG,
Fallback: []string{},
MaxResults: maxResults,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameSearXNG, BaseURL: upstream, Enabled: runtimeconfig.EnableFlag(true)},
},
}); err != nil {
t.Fatalf("写面板配置: %v", err)
}
}
// wsSearchCount 走注册面里那把 web-search(组合根装配出来的那一把)打一次,返回条数。
func wsSearchCount(t *testing.T, a *app, args map[string]any) int {
t.Helper()
got, err := a.dispatcher.Invoke(context.Background(), webSearchToolName, args)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("工具返回值类型 = %T", got)
}
n, ok := m["count"].(int)
if !ok {
t.Fatalf("count 不是 int: %#v", m["count"])
}
return n
}
// TestWebSearchPanelMaxResultsIsTheRuntimeLimit 是这条路的主判据:`.env` 写 5、
// 面板文件写 20 ⇒ 生效值必须是 **20**(面板优先),且它仍是**真上限**
// (模型要 50 也只给 20、要 3 就给 3)。
func TestWebSearchPanelMaxResultsIsTheRuntimeLimit(t *testing.T) {
t.Parallel()
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_MAX_RESULTS=5",
)
wsPanelWithMaxResults(t, cfg, 20, up.url)
a := wsSurfaceApp(t, cfg, nil)
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
t.Fatalf("count = %d, want 20(模型要 50,上限是面板文件里的 20)", got)
}
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 3}); got != 3 {
t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got)
}
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene"}); got != 20 {
t.Fatalf("count = %d, want 20(模型没给 count ⇒ 用面板文件的上限)", got)
}
}
// TestWebSearchPanelMaxResultsUnsetFallsBackToEnv 钉住投影的另一半:
// 面板文件里 `maxResults` **缺位**(0 = 未声明)⇒ 沿用 `.env` 那条既有键的值。
func TestWebSearchPanelMaxResultsUnsetFallsBackToEnv(t *testing.T) {
t.Parallel()
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_MAX_RESULTS=7",
)
wsPanelWithMaxResults(t, cfg, 0, up.url)
a := wsSurfaceApp(t, cfg, nil)
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 7 {
t.Fatalf("count = %d, want 7(面板 maxResults 未声明 ⇒ 回落 .env 的 7)", got)
}
}
// TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen 钉住代码默认值这一档:
// `.env` 与面板文件都没给条数 ⇒ io 包的共享上限(= 10),且它同样是真上限。
func TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen(t *testing.T) {
t.Parallel()
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
wsPanelWithMaxResults(t, cfg, 0, up.url) // 0 = 未声明;.env 也没给
a := wsSurfaceApp(t, cfg, nil)
if got := wsSearchCount(t, a, map[string]any{"query": "cyrene", "count": 50}); got != 10 {
t.Fatalf("count = %d, want 10(代码默认值)", got)
}
}