主人原话:「返回五条结果会不会太少了?」。裁定:**上限抬高到 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),留给主会话。
147 lines
5.6 KiB
Go
147 lines
5.6 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|