Files
Cyrene_v2/plugins/tools/io/web_search_zhipu_linkless_test.go
T
AskaEth 2c56bc9704
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(search): 「上游没给链接」不再被当成「没有结果」(真机 no results 的根因)
真机现象:她自主思考的 4 次 web-search 全部报
`search: no results (provider=zhipu)`,而同一查询直连上游返回 10 条。

根因:`Finalize` 把「link 为空」当成「不是一条结果」整条丢弃。而智谱
`search_std` 的 `search_result[].link` 在真机上**经常整批是空串**(实测同一
payload 连打 6 次:10 条全空 link,title/content 齐全;换查询也见过 6/10 空)。
⇒ 10 条被整批丢光 ⇒ 工具层如实报「一家都没查到」——**那是谎报**。

为什么面板探针是 ok:探针走 `search.Probe → p.Search`,**不过 Finalize**;
工具走 `Execute → p.Search → Finalize`。同一份上游、同一份解析代码,两条路
给出相反结论 —— 探针对这一类缺陷**结构性失明**,以后不能拿探针通不通推断
工具通不通。

修法:无链接条目**照原样保留**(title/content 对模型仍有用,链接空着就空着,
**绝不编一个出来**);判据改为「link/title/content/snippet **全空**的空壳行才
不算结果」;无链接条目不参与按 link 的去重(没有链接就没有判同身份,宁可重复
也不静默吞)。「空结果 ≠ 成功」**未放宽**:真·空 search_result 仍报 ErrNoResults。

验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;io 与 search 两包绿;
我用 -overlay 把判据退回缺陷形态 ⇒ TestWebSearch_ZhipuLinklessResultsAreNotNoResults
变红,报错串与真机逐字相同。子代理另用真 key 走 in-process 工具路径实测:
三条当时失败的查询现在各回 10 条。

⚠️ 本修复要等下次 build + restart 才真机生效(现在跑的还是老产物)。
⚠️ 其他四家是否也有空 link 形态未验(无 key);修复是通用的。
2026-10-07 10:28:36 +08:00

215 lines
8.5 KiB
Go
Raw 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
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)
}
}