真机现象:她自主思考的 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);修复是通用的。
215 lines
8.5 KiB
Go
215 lines
8.5 KiB
Go
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)
|
||
}
|
||
}
|