Files
Cyrene_v2/plugins/tools/io/web_search_cap_test.go
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

147 lines
5.6 KiB
Go
Raw Permalink 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 io
// 本文件钉住「配置的条数上限就是**上限**」(主人 2026-10-07 裁定)。
//
// 旧口径(`n = count`)让模型给的 `count` 直接**顶掉**配置里的 `WEB_SEARCH_MAX_RESULTS`
// —— 与 `.env.example` 写的「条数上限」语义不符,是一条已登记的口径缺陷。新口径:
// 模型只能把条数**要少**,要多了只给上限,缺省/非法一律用上限。
//
// 与 provider 自己的条数收敛(bocha/zhipu 等把 Count 收进官方区间)无关:这里判的是
// **工具层最终返回给模型的条数**(`search.Finalize` 的 Limit),上限在 provider 之上。
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// capUpstream 造一个 searxng 假上游:恒返回 n 条结果。
func capUpstream(t *testing.T, n int) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, capResultsBody(n))
}))
t.Cleanup(srv.Close)
return srv
}
// capResultsBody 造 n 条 searxng 形状的结果。link 逐条不同 —— `Finalize` 按 link 去重,
// 重复的 link 会被当成同一条(那会让「截断到 N」的断言看不出效果)。
func capResultsBody(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, ",") + `]}`
}
// capCount 用「上游恒返回 upstream 条」的假 searxng 跑一次 web-search,返回线格式的
// `count`。args 为 nil 或没给 `count` 键都表示「模型没给 count」。
func capCount(t *testing.T, upstream int, opts []Option, args map[string]any) int {
t.Helper()
srv := capUpstream(t, upstream)
ws := webSearchToolForTest(t, append([]Option{
WithSearchEndpoint(srv.URL),
WithHTTPClient(srv.Client()),
}, opts...)...)
call := map[string]any{"query": "昔涟"}
for k, v := range args {
call[k] = v
}
got, err := ws.Execute(context.Background(), call)
if err != nil {
t.Fatalf("Execute: %v", err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("工具返回值类型 = %T", got)
}
return m["count"].(int)
}
// TestWebSearchCountIsCappedByConfiguredLimit 是本次裁定的主判据(①~④):
//
// ① 默认上限 = 10;
// ② 模型要 50 ⇒ 只给上限 10(**上限是真上限**);
// ③ 模型要 3 ⇒ 给 3(「少一点」是合理需求);
// ④ `count` 缺省 / 非法(0 / 负数 / 非数值)⇒ 用上限 10。
func TestWebSearchCountIsCappedByConfiguredLimit(t *testing.T) {
t.Parallel()
const cap10 = 10
capped := []Option{WithSearchMaxResults(cap10)}
for _, tt := range []struct {
name string
upstream int
opts []Option
args map[string]any
want int
}{
// ① 默认上限:不传任何条数选项 ⇒ io 包的默认(= 10),模型也没给 count。
{"① 默认上限 10", 30, nil, nil, cap10},
{"① 默认上限 10(模型要 50 也一样)", 30, nil, map[string]any{"count": 50}, cap10},
// ② 模型要 50 ⇒ 只给上限。
{"② 模型要 50 ⇒ 上限 10", 30, capped, map[string]any{"count": 50}, cap10},
{"② 模型要 11 ⇒ 上限 10(边界)", 30, capped, map[string]any{"count": 11}, cap10},
{"② 模型要 10 ⇒ 上限 10(正好等于上限)", 30, capped, map[string]any{"count": 10}, cap10},
// ③ 模型要得更少 ⇒ 尊重模型。
{"③ 模型要 3 ⇒ 给 3", 30, capped, map[string]any{"count": 3}, 3},
{"③ 模型要 1 ⇒ 给 1", 30, capped, map[string]any{"count": 1}, 1},
// ④ 缺省 / 非法 ⇒ 上限。
{"④ count 缺省 ⇒ 上限 10", 30, capped, map[string]any{}, cap10},
{"④ count=0(非法)⇒ 上限 10", 30, capped, map[string]any{"count": 0}, cap10},
{"④ count=-5(非法)⇒ 上限 10", 30, capped, map[string]any{"count": -5}, cap10},
{"④ count 非数值(非法)⇒ 上限 10", 30, capped, map[string]any{"count": "abc"}, cap10},
// 上游本来就不够:截断不是「凑数」,如实返回上游给的条数。
{"上游只有 2 条 ⇒ 如实返回 2", 2, capped, map[string]any{"count": 3}, 2},
} {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := capCount(t, tt.upstream, tt.opts, tt.args); got != tt.want {
t.Fatalf("count = %d, want %d", got, tt.want)
}
})
}
}
// TestResultLimit_Table 直接钉住收敛函数的四条分支(含上限缺失时的兜底),
// 不必每次都起一个 HTTP 上游。
func TestResultLimit_Table(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
limit int
args map[string]any
want int
}{
{"缺省用上限", 10, map[string]any{}, 10},
{"缺省用上限(args 为 nil)", 10, nil, 10},
{"要得多只用上限", 10, map[string]any{"count": 50}, 10},
{"要得少尊重模型", 10, map[string]any{"count": 3}, 3},
{"非数值用上限", 10, map[string]any{"count": "many"}, 10},
{"零用上限", 10, map[string]any{"count": 0}, 10},
{"负数用上限", 10, map[string]any{"count": -1}, 10},
{"上限为 0 时兜底到 1(不退化成不截断)", 0, map[string]any{}, 1},
{"上限为负时兜底到 1", -3, map[string]any{"count": 5}, 1},
{"面板把上限设成 20 ⇒ 生效值 20", 20, map[string]any{"count": 50}, 20},
} {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := resultLimit(tt.limit, tt.args); got != tt.want {
t.Fatalf("resultLimit(%d, %v) = %d, want %d", tt.limit, tt.args, got, tt.want)
}
})
}
}