Files
Cyrene_v2/plugins/tools/io/search/extract_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

168 lines
6.7 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 search
import "testing"
// TestFinalize_Pipeline 是「归一化之后**统一做一次**」的判据(design/27 §4):
// 丢弃**全空壳行** → 域名过滤 → 去重 → 按分数稳定排序 → 截断。
//
// 🔴 「没有链接」**不再**是被丢的理由(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: "没有链接"}, // **保留**:上游给了标题,只是没给链接
{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) != 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" || 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])
}
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 覆盖截断与域名白名单(域名过滤只在归一化后做一次)。
func TestFinalize_LimitAndAllowDomains(t *testing.T) {
t.Parallel()
in := []Result{
{Link: "https://a.example/1", Title: "A"},
{Link: "https://sub.a.example/2", Title: "A 子域"},
{Link: "https://other.example/3", Title: "O"},
}
allow := Finalize(in, Options{AllowDomains: []string{".a.example"}})
if len(allow) != 2 {
t.Fatalf("白名单结果 = %+v, want 2 条(含子域)", allow)
}
limited := Finalize(in, Options{Limit: 1})
if len(limited) != 1 {
t.Fatalf("Limit=1 应截断成 1 条, got %d", len(limited))
}
none := Finalize(in, Options{AllowDomains: []string{"nope.example"}})
if len(none) != 0 {
t.Fatalf("白名单不命中时应为空, got %+v", none)
}
}
// TestFinalize_ToleratesUnparsableLink 解析不出主机的链接**保留**(域名过滤不该误杀)。
func TestFinalize_ToleratesUnparsableLink(t *testing.T) {
t.Parallel()
got := Finalize([]Result{{Link: "not a url", Title: "X"}}, Options{AllowDomains: []string{"a.example"}})
if len(got) != 1 {
t.Fatalf("解析不出主机的链接应被保留(不是过滤失败): %+v", got)
}
}
// TestCanonicalLink 覆盖去重键的口径:去 fragment、scheme/host 小写、path 去尾斜杠,
// **保留 query**(查询串不同通常是不同页面,过度归并会吞掉真结果)。
func TestCanonicalLink(t *testing.T) {
t.Parallel()
same := [][2]string{
{"https://A.example/p/", "https://a.example/p"},
{"https://a.example/p#sec", "https://a.example/p"},
}
for _, pair := range same {
if canonicalLink(pair[0]) != canonicalLink(pair[1]) {
t.Errorf("应视为同一条: %q vs %q", pair[0], pair[1])
}
}
if canonicalLink("https://a.example/p?x=1") == canonicalLink("https://a.example/p?x=2") {
t.Error("query 不同不该被去重合并")
}
}
// TestScalarStringAndPick 覆盖容错提取的取值口径(字符串/数字/布尔;空串不算命中)。
func TestScalarStringAndPick(t *testing.T) {
t.Parallel()
obj := map[string]any{
"empty": "",
"title": "标题",
"num": float64(1700000000000),
"flag": true,
"nested": map[string]any{"x": 1},
"score": "0.75",
"badnum": "abc",
}
if got := pickString(obj, []string{"empty", "title"}); got != "标题" {
t.Errorf("空串不该算命中, got %q", got)
}
if got := pickString(obj, []string{"num"}); got != "1700000000000" {
t.Errorf("数字应原样字符串化, got %q", got)
}
if got := pickString(obj, []string{"flag"}); got != "true" {
t.Errorf("布尔应字符串化, got %q", got)
}
if got := pickString(obj, []string{"nested"}); got != "" {
t.Errorf("非标量应返回空串, got %q", got)
}
if got := pickFloat(obj, []string{"score"}); got != 0.75 {
t.Errorf("数字字符串应能当分数用, got %v", got)
}
if got := pickFloat(obj, []string{"badnum"}); got != 0 {
t.Errorf("非数字字符串应返回 0, got %v", got)
}
if got := pickFloat(obj, []string{"missing"}); got != 0 {
t.Errorf("缺键应返回 0, got %v", got)
}
}
// TestResolvePath 覆盖点分路径解析(含中间不是对象的情况)。
func TestResolvePath(t *testing.T) {
t.Parallel()
var root any
mustUnmarshal(t, `{"a":{"b":[1,2]}}`, &root)
if v, ok := resolvePath(root, "a.b"); !ok {
t.Fatal("a.b 应能解析")
} else if arr, ok := v.([]any); !ok || len(arr) != 2 {
t.Fatalf("a.b = %v", v)
}
if _, ok := resolvePath(root, "a.c"); ok {
t.Error("不存在的键不该命中")
}
if _, ok := resolvePath(root, "a.b.c"); ok {
t.Error("路径穿过数组时不该命中")
}
if arr, ok := findArray(root, []string{"nope", "a.b"}); !ok || len(arr) != 2 {
t.Errorf("findArray 应回退到后面的候选路径: %v %v", arr, ok)
}
}