diff --git a/cmd/cyrene/websearch_maxresults_test.go b/cmd/cyrene/websearch_maxresults_test.go new file mode 100644 index 0000000..fb02821 --- /dev/null +++ b/cmd/cyrene/websearch_maxresults_test.go @@ -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) + } +} diff --git a/internal/config/websearch.go b/internal/config/websearch.go index 98fa7ba..ac6f158 100644 --- a/internal/config/websearch.go +++ b/internal/config/websearch.go @@ -22,8 +22,10 @@ type WebSearchConfig struct { // 链上的家**只在「不可用」时**才被跳过(无 key / 网络失败 / 额度错误…); // 结果里会如实标注**实际应答**的那一家,绝不拿兜底冒充主力。 Fallback []string `env:"WEB_SEARCH_FALLBACK"` - // MaxResults 是单次检索返回的条数上限(design/27 §3 默认 5)。 - MaxResults int `env:"WEB_SEARCH_MAX_RESULTS" default:"5"` + // MaxResults 是单次检索返回的条数上限(主人 2026-10-07 裁定:默认由 5 提到 10; + // design/27 §3 记的仍是 5,待同步设计文档)。它是**真上限**:模型给的 count 只能要得 + // 更少,顶不掉它(`plugins/tools/io/web_search.go` 的 resultLimit)。 + MaxResults int `env:"WEB_SEARCH_MAX_RESULTS" default:"10"` // Endpoint 是 **searxng provider** 的端点(SearXNG 兼容的 `format=json` GET 接口)。 // diff --git a/internal/config/websearch_providers_test.go b/internal/config/websearch_providers_test.go index c611a8b..9b45245 100644 --- a/internal/config/websearch_providers_test.go +++ b/internal/config/websearch_providers_test.go @@ -23,7 +23,7 @@ func wsTestLoad(t *testing.T, extra ...string) (*Config, error) { } // TestWebSearchProvider_Defaults 钉住三个新键的默认值:provider=searxng、 -// maxResults=5、fallback 为空(= 只管手选那一家)。 +// maxResults=10、fallback 为空(= 只管手选那一家)。 func TestWebSearchProvider_Defaults(t *testing.T) { cfg, err := wsTestLoad(t) if err != nil { @@ -32,8 +32,8 @@ func TestWebSearchProvider_Defaults(t *testing.T) { if cfg.WebSearch.Provider != "searxng" { t.Fatalf("WEB_SEARCH_PROVIDER 默认值 = %q, want searxng", cfg.WebSearch.Provider) } - if cfg.WebSearch.MaxResults != 5 { - t.Fatalf("WEB_SEARCH_MAX_RESULTS 默认值 = %d, want 5", cfg.WebSearch.MaxResults) + 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) diff --git a/plugins/tools/io/web_search.go b/plugins/tools/io/web_search.go index e797174..11e17e9 100644 --- a/plugins/tools/io/web_search.go +++ b/plugins/tools/io/web_search.go @@ -160,10 +160,7 @@ func (s *webSearchTool) Execute(ctx context.Context, args any) (any, error) { if query == "" { return nil, fmt.Errorf("io: web-search: query is required") } - n := s.maxResults - if v, ok := argInt(m, "count"); ok && v > 0 { - n = v - } + n := resultLimit(s.maxResults, m) raw, provider, err := s.dispatch(ctx, query, n) if err != nil { @@ -189,6 +186,30 @@ func (s *webSearchTool) Execute(ctx context.Context, args any) (any, error) { }, nil } +// resultLimit 把模型的 `count` 收进配置上限(主人 2026-10-07 裁定:上限抬高到 10, +// 并且它是**真上限**): +// +// - `count` **缺省 / 非法**(≤0、非数值)⇒ 用上限; +// - `count` **小于上限** ⇒ 尊重模型 —— 「只要 3 条」是合理需求,不该被抬到 10; +// - `count` **大于上限** ⇒ **只给上限**(模型要 50、配了 10 ⇒ 10)。 +// +// 上限本身是**运维配置**给的(`WEB_SEARCH_MAX_RESULTS` / 面板文件 `maxResults`), +// 模型顶不掉它。旧口径 `n = count`(`if ok && v > 0 { n = v }`)让模型直接越过配置, +// 与 `.env.example` 写的「条数上限」语义不符 —— 那是一条已登记的口径缺陷。 +// +// 上限 ≤0 时取 1(不该发生:两条构造路径的默认值都 ≥1):它是「一条都别给」与 +// 「截断关掉」之间唯一说得通的那个,避免 `Limit ≤ 0` 退化成**不截断**。 +func resultLimit(limit int, args map[string]any) int { + if limit < 1 { + limit = 1 + } + n, ok := argInt(args, "count") + if !ok || n < 1 || n > limit { + return limit + } + return n +} + // dispatch 按 主 provider → 降级链 依次尝试,返回结果与**实际应答的 provider 名**。 // // 只有「该家不可用」才前进一格(unavailable:无 key / 鉴权失败 / 限流 / 额度 / 网络 / diff --git a/plugins/tools/io/web_search_cap_test.go b/plugins/tools/io/web_search_cap_test.go new file mode 100644 index 0000000..13cb66c --- /dev/null +++ b/plugins/tools/io/web_search_cap_test.go @@ -0,0 +1,146 @@ +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) + } + }) + } +}