feat(search): 结果条数上限 5 → 10,并让「上限」真的是上限
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人原话:「返回五条结果会不会太少了?」。裁定:**上限抬高到 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),留给主会话。
This commit is contained in:
AskaEth committed 2026-10-07 10:58:40 +08:00
1 parent 7f63c5bec4
commit bff57bd1ae
5 files changed
+298 -9

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
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)
}
}