diff --git a/plugins/tools/io/search/extract.go b/plugins/tools/io/search/extract.go index 651bad3..44f359e 100644 --- a/plugins/tools/io/search/extract.go +++ b/plugins/tools/io/search/extract.go @@ -154,26 +154,43 @@ type Options struct { } // Finalize 对归一化后的结果做一次统一后处理: -// 丢弃空 link → 域名过滤 → 去重(按规范化 link)→ 按 Score 降序**稳定**排序 → 截断到 Limit。 +// 丢弃**四个可读字段全空**的行 → 域名过滤 → 去重(按规范化 link)→ 按 Score 降序 +// **稳定**排序 → 截断到 Limit。 // // 排序用 SliceStable:分数相同(多数上游不给分)时保持上游给出的原始顺序,不制造随机。 +// +// 🔴 「上游没给链接」**不是**「不是一条结果」(2026-10-07 真机缺陷的修复,回归用例 +// `plugins/tools/io/web_search_zhipu_linkless_test.go`): +// +// 智谱 `search_std` 的 `search_result[].link` 在真机上**经常整批是空串**(实测同一 +// 查询连打六次,10 条的 link 全为空,而 title/content 齐全)。旧口径「空 link ⇒ 丢弃」 +// 会把上游明明返回的 10 条**整批丢光**,工具层于是报 `ErrNoResults` —— 而面板探针 +// (走 Probe,不过 Finalize)报的是 `{ok:true, count:1}`:同一份上游、同一份解析代码 +// 给出两个相反的结论,且工具层那句「一家都没查到」是**谎报**。 +// +// 因此:没有链接的条目**照原样保留**(title/content 对模型仍然有用,链接空着就空着, +// 绝不编一个出来);只有 link/title/content/snippet **全空**的空壳行才不算结果。 +// 无链接的条目**不参与按 link 的去重** —— 没有链接就没有判同的身份,宁可重复展示, +// 也不静默吞掉上游给的结果。 func Finalize(results []Result, opt Options) []Result { out := make([]Result, 0, len(results)) seen := make(map[string]struct{}, len(results)) for _, r := range results { r.Link = strings.TrimSpace(r.Link) - if r.Link == "" { - // 没有链接的结果没法被模型引用,也不算一条检索结果。 + if emptyResult(r) { + // 四个可读字段全空的空壳行:没有任何东西可以给模型看,不算一条结果。 continue } if !domainAllowed(r.Link, opt.AllowDomains, opt.BlockDomains) { continue } - key := canonicalLink(r.Link) - if _, dup := seen[key]; dup { - continue + if r.Link != "" { + key := canonicalLink(r.Link) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} } - seen[key] = struct{}{} out = append(out, r) } sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score }) @@ -183,6 +200,18 @@ func Finalize(results []Result, opt Options) []Result { return out } +// emptyResult 报告一条结果是否**四个可读字段全空**(链接 / 标题 / 正文 / 摘要都没有)。 +// +// 判据只放「全空」这一种:任一项非空都算有内容(含「只有标题、没有链接」—— 智谱真机 +// 就是这种形态)。刻意**不**把 Published/Score 算进来:只有发布时间的行给不了模型任何 +// 可读内容,是上游的空壳而不是一条结果。 +func emptyResult(r Result) bool { + return r.Link == "" && + strings.TrimSpace(r.Title) == "" && + strings.TrimSpace(r.Content) == "" && + strings.TrimSpace(r.Snippet) == "" +} + // domainAllowed 按 allow/block 名单判定一条结果的域名是否保留。 // 解析不出主机时**保留**(不是「过滤失败」——域名过滤不该把正常结果误杀)。 func domainAllowed(link string, allow, block []string) bool { diff --git a/plugins/tools/io/search/extract_test.go b/plugins/tools/io/search/extract_test.go index 4780b82..d769e0f 100644 --- a/plugins/tools/io/search/extract_test.go +++ b/plugins/tools/io/search/extract_test.go @@ -3,25 +3,30 @@ package search import "testing" // TestFinalize_Pipeline 是「归一化之后**统一做一次**」的判据(design/27 §4): -// 空 link 丢弃 → 域名过滤 → 去重 → 按分数稳定排序 → 截断。 +// 丢弃**全空壳行** → 域名过滤 → 去重 → 按分数稳定排序 → 截断。 +// +// 🔴 「没有链接」**不再**是被丢的理由(2026-10-07 真机缺陷的修复):智谱真机经常整批 +// 不给 link 而 title/content 齐全,旧口径会把上游给的 10 条整批丢光、工具层谎报 +// 「一家都没查到」。判据改成「四个可读字段全空才不算结果」。 func TestFinalize_Pipeline(t *testing.T) { t.Parallel() in := []Result{ {Link: "https://a.example/1#frag", Title: "A1", Score: 0.2}, {Link: "https://a.example/1", Title: "A1 副本", Score: 0.9}, // 去重被丢(同一地址) {Link: "https://b.example/2", Title: "B2", Score: 0.9}, - {Link: " ", Title: "没有链接"}, + {Link: " ", Title: "没有链接"}, // **保留**:上游给了标题,只是没给链接 + {Title: ""}, // 全空壳行:丢弃 {Link: "https://blocked.example/3", Title: "被排除的域名"}, {Link: "https://c.example/4/", Title: "C4", Score: 1.5}, } got := Finalize(in, Options{BlockDomains: []string{"blocked.example"}}) - if len(got) != 3 { - t.Fatalf("条数 = %d, want 3: %+v", len(got), got) + if len(got) != 4 { + t.Fatalf("条数 = %d, want 4: %+v", len(got), got) } // 去重保留**先出现的那一条**(A1,分 0.2);Score 降序;同分保持原序(稳定排序)。 - if got[0].Title != "C4" || got[1].Title != "B2" || got[2].Title != "A1" { - t.Fatalf("排序/去重结果 = %q,%q,%q; want C4,B2,A1(分数降序 + 同分稳定 + 重复地址保留首条)", - got[0].Title, got[1].Title, got[2].Title) + if got[0].Title != "C4" || got[1].Title != "B2" || got[2].Title != "A1" || got[3].Title != "没有链接" { + t.Fatalf("排序/去重结果 = %q,%q,%q,%q; want C4,B2,A1,没有链接(分数降序 + 同分稳定 + 重复地址保留首条)", + got[0].Title, got[1].Title, got[2].Title, got[3].Title) } if got[2].Link != "https://a.example/1#frag" { t.Fatalf("去重应保留先出现的那一条: %+v", got[2]) @@ -29,6 +34,31 @@ func TestFinalize_Pipeline(t *testing.T) { if got[1].Link != "https://b.example/2" { t.Fatalf("B2 的链接被改动: %q", got[1].Link) } + if got[3].Link != "" { + t.Fatalf("没有链接的条目不许被编出一个链接: %q", got[3].Link) + } +} + +// TestFinalize_LinklessResultsSurvive 是**真机缺陷的单元级判据**:一整批「有标题正文、 +// 没链接」的结果(智谱真机形态)必须原样留下——它们**不是**空结果。 +// +// 同时钉住「不按 link 去重无链接条目」:没有链接就没有判同的身份,宁可重复展示, +// 也不静默吞掉上游给的结果(吞掉会让上层把「查到了 10 条」谎报成「一家都没查到」)。 +func TestFinalize_LinklessResultsSurvive(t *testing.T) { + t.Parallel() + in := make([]Result, 0, 10) + for i := 0; i < 10; i++ { + in = append(in, Result{Title: "热搜条目", Content: "正文摘要", Link: ""}) + } + got := Finalize(in, Options{Limit: 5}) + if len(got) != 5 { + t.Fatalf("条数 = %d, want 5(Limit 截断,而不是被空 link 判据丢光): %+v", len(got), got) + } + for i, r := range got { + if r.Link != "" || r.Title == "" || r.Content == "" { + t.Fatalf("got[%d] = %+v, want 原样保留(链接空、标题正文在)", i, r) + } + } } // TestFinalize_LimitAndAllowDomains 覆盖截断与域名白名单(域名过滤只在归一化后做一次)。 diff --git a/plugins/tools/io/search/providers_test.go b/plugins/tools/io/search/providers_test.go index 80baa6d..bb3b87d 100644 --- a/plugins/tools/io/search/providers_test.go +++ b/plugins/tools/io/search/providers_test.go @@ -289,7 +289,9 @@ func TestProviders_AllNormalizeToSameResult(t *testing.T) { } for i, r := range got { if r.Link == "" { - t.Errorf("[%d] Link 为空 —— 没链接的结果不算结果: %+v", i, r) + // 六家的夹具**都给得出**链接(真机智谱有时给不出,但那是上游的形态、 + // 不是本判据的对象):夹具里链接丢了就说明归一化把 link 认错了。 + t.Errorf("[%d] Link 为空(夹具里给了链接): %+v", i, r) } if r.Title == "" { t.Errorf("[%d] Title 为空(夹具里给了标题): %+v", i, r) diff --git a/plugins/tools/io/web_search.go b/plugins/tools/io/web_search.go index 8e5d9c3..e797174 100644 --- a/plugins/tools/io/web_search.go +++ b/plugins/tools/io/web_search.go @@ -170,6 +170,8 @@ func (s *webSearchTool) Execute(ctx context.Context, args any) (any, error) { return nil, err } // 归一化之后**统一做一次**后处理:域名过滤/去重/排序/截断(design/27 §4)。 + // ⚠️ 这里判的是「**归一化之后**确实一条都没有」,不是「上游没给链接」—— 后者曾被 + // Finalize 当成没有结果(2026-10-07 智谱真机缺陷),现在无链接的条目照原样保留。 results := search.Finalize(raw, search.Options{Limit: n}) if len(results) == 0 { // 空结果 ≠ 成功(design/27 §1.4):如实报「一家都没查到」,绝不冒充成功。 @@ -271,7 +273,10 @@ func searchUnavailable(err error) bool { // searchResult 是一条结果的**线格式**(既有字段一字不改;published/score 是本期追加的 // 可选字段 —— design/27 §4:只许追加,不许改名)。 type searchResult struct { - Title string `json:"title"` + Title string `json:"title"` + // URL 是结果链接。⚠️ **可能是空串**:上游(如智谱 `search_std`)真机上经常不给 link, + // 此时如实回空串(`title`/`content` 仍可用),**绝不**编一个 URL 出来。 + // 客户端按「空 url = 这条没有可点链接」处理,不要把它当成失败或省略字段。 URL string `json:"url"` Content string `json:"content"` Snippet string `json:"snippet"` diff --git a/plugins/tools/io/web_search_zhipu_linkless_test.go b/plugins/tools/io/web_search_zhipu_linkless_test.go new file mode 100644 index 0000000..645d75d --- /dev/null +++ b/plugins/tools/io/web_search_zhipu_linkless_test.go @@ -0,0 +1,214 @@ +package io + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// 本文件是 **2026-10-07 智谱真机缺陷**的回归:上游明明返回 10 条,工具路径却报 +// `no results (provider=zhipu)`。 +// +// 真机证据(同日实测,逐条): +// - 直连 `open.bigmodel.cn/api/paas/v4/web_search`:200,顶层五键 +// `created/id/request_id/search_intent/search_result`,`search_result` **10 条**, +// 每条七键 `title/content/link/media/icon/refer/publish_date`; +// - 🔴 真机上 `link` **经常整批是空串**:同一查询连打六次,10 条的 `link` 全为空串, +// 而 `title`/`content` 齐全(换查询有时有 1–6 条带链接); +// - 面板探针(`search.Probe` → `Provider.Search`)⇒ `{ok:true, count:1}`; +// 工具路径(`Execute` → `search.Finalize` → 空结果判定)⇒ `ErrNoResults`。 +// +// 分叉点就是 `search.Finalize`:「上游没给 link」被当成「不是一条结果」丢掉, +// 于是上游给的 10 条**整批丢光**,工具层如实报「一家都没查到」—— 而它其实查到了。 +// 探针不过 `Finalize`,所以同一份上游、同一份解析代码给出两个相反的结论。 +// +// 假上游按真机应答**逐字同形**造(顶层五键 + 十条七键,`link` 全为空串)。 + +// zhipuRealShapeBody 造一份智谱 `search_std` 真机形状的应答。 +// +// linkless=true 时把每条 `link` 写成空串(真机常见形态,也是本次缺陷的触发条件); +// false 时给出可解析的链接(用于验证「有链接」的那条路径没被改坏)。 +func zhipuRealShapeBody(n int, linkless bool) string { + items := make([]map[string]any, 0, n) + for i := 0; i < n; i++ { + link := fmt.Sprintf("https://news%d.example.com/p/%d", i+1, i+1) + if linkless { + link = "" + } + // 顺序无关(JSON 对象),但键名与真机逐字一致:七键,不多不少。 + items = append(items, map[string]any{ + "title": fmt.Sprintf("热搜条目 %d", i+1), + "content": fmt.Sprintf("热搜条目 %d 的正文摘要", i+1), + "link": link, + "media": "", + "icon": "", + "refer": fmt.Sprintf("ref_%d", i+1), + "publish_date": "2026-10-07", + }) + } + raw, err := json.Marshal(map[string]any{ + "created": int64(1759800000), + "id": "zhipu-request-id", + "request_id": "zhipu-request-id", + // 真机上 search_intent 是 object[](与请求侧的同名 boolean 语义相反)—— + // 一并照抄,免得假上游比真上游更"干净"而掩盖解析分支。 + "search_intent": []any{map[string]any{"query": "今日热搜榜", "intent": "SEARCH_NEWS"}}, + "search_result": items, + }) + if err != nil { + panic(err) + } + return string(raw) +} + +// zhipuLikeServer 造一台智谱形状的假上游:只认 POST + 官方路径,记录请求体与认证头。 +func zhipuLikeServer(t *testing.T, body string) (*httptest.Server, *[]byte, *string) { + t.Helper() + var gotBody []byte + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("方法 = %s, want POST", r.Method) + } + if r.URL.Path != "/api/paas/v4/web_search" { + t.Errorf("路径 = %s, want /api/paas/v4/web_search", r.URL.Path) + } + // 请求体与认证头留证(密钥只在头里,本用例只断言「带没带 Bearer」,绝不打印值)。 + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("读请求体: %v", err) + } + gotBody = raw + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv, &gotBody, &gotAuth +} + +// invokeViaDispatcher 走**真分派器**打一次 web-search —— 模型真正走的那条路 +// (schema 校验 + 参数 coercion + Execute),而不是直接调 Execute。 +func invokeViaDispatcher(t *testing.T, ws *webSearchTool, args map[string]any) (any, error) { + t.Helper() + caps := registry.New() + disp, err := tool.New(caps) + if err != nil { + t.Fatalf("tool.New: %v", err) + } + if err := disp.Register(ws); err != nil { + t.Fatalf("register %s: %v", ws.Name(), err) + } + return disp.Invoke(context.Background(), "web-search", args) +} + +// TestWebSearch_ZhipuLinklessResultsAreNotNoResults 钉住真机缺陷的修复: +// 上游返回 10 条、`link` 全为空串时,工具路径必须把那 10 条递给模型, +// **不许**报 ErrNoResults(那等于谎报「一家都没查到」)。 +func TestWebSearch_ZhipuLinklessResultsAreNotNoResults(t *testing.T) { + t.Parallel() + const query = "今日热搜榜 实时热点" + srv, body, auth := zhipuLikeServer(t, zhipuRealShapeBody(10, true)) + + ws := searchTool(t, + []SearchProviderSpec{cloudSpec(search.NameZhipu, leakKey, srv.URL)}, + search.NameZhipu, nil, + WithHTTPClient(srv.Client()), + ) + // 模型真机就发了字符串 "10"(trace 里 args_summary={"count":"10",...})—— + // 照抄,好让分派器的参数 coercion 也在链路上。 + got, err := invokeViaDispatcher(t, ws, map[string]any{"query": query, "count": "10"}) + if err != nil { + t.Fatalf("上游返回 10 条却报错:%v\n(若为 ErrNoResults,就是「空 link 被当成没有结果」这条分叉)", err) + } + m, ok := got.(map[string]any) + if !ok { + t.Fatalf("工具返回值类型 = %T", got) + } + if m["count"].(int) != 10 { + t.Fatalf("count = %v, want 10(上游给了 10 条,就得如实回 10 条)", m["count"]) + } + if m["provider"] != search.NameZhipu { + t.Fatalf("provider = %v, want %q", m["provider"], search.NameZhipu) + } + results, ok := m["results"].([]searchResult) + if !ok || len(results) != 10 { + t.Fatalf("results = %#v, want 10 条", m["results"]) + } + for i, r := range results { + if r.Title == "" { + t.Errorf("results[%d].title 为空 —— 真机那批 title 是齐的: %+v", i, r) + } + if r.Content == "" { + t.Errorf("results[%d].content 为空 —— 真机那批 content 是齐的: %+v", i, r) + } + if r.URL != "" { + t.Errorf("results[%d].url = %q, want \"\"(上游没给链接,不许编一个)", i, r.URL) + } + } + // 请求侧也要对:查询词原样发出、带了 Bearer。 + if !strings.Contains(string(*body), query) { + t.Errorf("请求体没带上查询词: %s", string(*body)) + } + if !strings.HasPrefix(*auth, "Bearer ") { + t.Errorf("认证头 = %q, want Bearer …", *auth) + } +} + +// TestWebSearch_ZhipuResultsWithLinksStillDeduped 钉住修复**没有**顺手放宽去重: +// 有链接的结果照旧按规范化 link 去重(同一地址出现两次只留一条)。 +func TestWebSearch_ZhipuResultsWithLinksStillDeduped(t *testing.T) { + t.Parallel() + srv, _, _ := zhipuLikeServer(t, `{"search_result":[ + {"title":"A","content":"x","link":"https://a.example/p/1#frag"}, + {"title":"A 副本","content":"x","link":"https://a.example/p/1"} + ]}`) + + ws := searchTool(t, + []SearchProviderSpec{cloudSpec(search.NameZhipu, leakKey, srv.URL)}, + search.NameZhipu, nil, + WithHTTPClient(srv.Client()), + ) + got, err := invokeViaDispatcher(t, ws, map[string]any{"query": "q", "count": 10}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + results := got.(map[string]any)["results"].([]searchResult) + if len(results) != 1 || results[0].Title != "A" { + t.Fatalf("同一地址应去重成 1 条并保留先出现的: %+v", results) + } +} + +// TestWebSearch_ZhipuEmptyResultStillNoResults 是**诚实口径的护栏**:上游 200 且 +// `search_result` 真的是空数组 ⇒ 仍然必须报 ErrNoResults。 +// (修复「空 link 被误杀」不许顺手把「空结果 ≠ 成功」这条口径放宽。) +func TestWebSearch_ZhipuEmptyResultStillNoResults(t *testing.T) { + t.Parallel() + srv, _, _ := zhipuLikeServer(t, `{"created":1,"id":"a","request_id":"b","search_intent":[],"search_result":[]}`) + + ws := searchTool(t, + []SearchProviderSpec{cloudSpec(search.NameZhipu, leakKey, srv.URL)}, + search.NameZhipu, nil, + WithHTTPClient(srv.Client()), + ) + _, err := invokeViaDispatcher(t, ws, map[string]any{"query": "查不到的词", "count": 10}) + if err == nil { + t.Fatal("空结果绝不是成功:必须报错,不许拿空列表冒充成功") + } + if !errors.Is(err, search.ErrNoResults) { + t.Fatalf("err = %v, want errors.Is(search.ErrNoResults)", err) + } + if !strings.Contains(err.Error(), search.NameZhipu) { + t.Fatalf("空结果要如实说清是谁答的: %v", err) + } +}