真机现象:她自主思考的 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);修复是通用的。
267 lines
8.8 KiB
Go
267 lines
8.8 KiB
Go
package search
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/url"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// spec 描述「怎么把一家 provider 的 JSON 归一到 []Result」:每个字段给一串**候选键名**
|
||
// (按序取第一个非空值),结果数组给一串**候选路径**(点分,按序取第一个是数组的)。
|
||
//
|
||
// 六家 provider **共用这一个提取器** —— 不许各写一套 JSON 解析样板(AGENTS §10)。
|
||
// 候选键的意义:分册里「完整响应结构未确证」的家(智谱/火山/百度)用多组候选键容错,
|
||
// 而不是把某个猜测的字段当事实写死(design/27 §7 的诚实边界)。
|
||
type spec struct {
|
||
// Arrays 是结果数组的候选路径(点分)。例:{"webPages.value", "results"}。
|
||
Arrays []string
|
||
// 以下每个字段是一组候选键名(同一层对象内),按序取第一个非空值。
|
||
Title []string
|
||
Link []string
|
||
Snippet []string
|
||
Content []string
|
||
Published []string
|
||
Score []string
|
||
}
|
||
|
||
// extractResults 把上游 JSON 归一化成 []Result。
|
||
//
|
||
// 「空」与「解析不了」必须分开(design/27 §1.4):候选路径命中了数组(哪怕是空数组)
|
||
// ⇒ 返回(可能为空的)切片、无错误;一条候选路径都不是数组 ⇒ ErrBadResponse。
|
||
func extractResults(data []byte, s spec) ([]Result, error) {
|
||
var root any
|
||
if err := json.Unmarshal(data, &root); err != nil {
|
||
return nil, fmt.Errorf("%w: %w", ErrBadResponse, err)
|
||
}
|
||
items, ok := findArray(root, s.Arrays)
|
||
if !ok {
|
||
return nil, fmt.Errorf("%w: no results array", ErrBadResponse)
|
||
}
|
||
out := make([]Result, 0, len(items))
|
||
for _, item := range items {
|
||
obj, ok := item.(map[string]any)
|
||
if !ok {
|
||
// 非对象条目跳过:容错,不让一条脏数据毁掉整次检索。
|
||
continue
|
||
}
|
||
out = append(out, Result{
|
||
Link: pickString(obj, s.Link),
|
||
Title: pickString(obj, s.Title),
|
||
Snippet: pickString(obj, s.Snippet),
|
||
Content: pickString(obj, s.Content),
|
||
Published: pickString(obj, s.Published),
|
||
Score: pickFloat(obj, s.Score),
|
||
})
|
||
}
|
||
for i := range out {
|
||
// 既有 SearXNG 口径(WS1-A 之前就在):正文缺了就回落摘要,别让 content 空着。
|
||
if out[i].Content == "" {
|
||
out[i].Content = out[i].Snippet
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// resolvePath 按点分路径取嵌套值;任一段不是对象或键不存在时返回 ok=false。
|
||
func resolvePath(root any, path string) (any, bool) {
|
||
cur := root
|
||
for _, seg := range strings.Split(path, ".") {
|
||
obj, ok := cur.(map[string]any)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
cur, ok = obj[seg]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
}
|
||
return cur, true
|
||
}
|
||
|
||
// findArray 依次尝试候选路径,返回第一个确实是数组的值。
|
||
func findArray(root any, paths []string) ([]any, bool) {
|
||
for _, p := range paths {
|
||
v, ok := resolvePath(root, p)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if arr, ok := v.([]any); ok {
|
||
return arr, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// pickString 按候选键顺序取第一个**非空**标量值(空串不算命中,好让后面的候选有机会)。
|
||
func pickString(obj map[string]any, keys []string) string {
|
||
for _, k := range keys {
|
||
v, ok := obj[k]
|
||
if !ok || v == nil {
|
||
continue
|
||
}
|
||
if s := scalarString(v); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// pickFloat 按候选键顺序取第一个能当数字用的值(数字或数字字符串)。
|
||
func pickFloat(obj map[string]any, keys []string) float64 {
|
||
for _, k := range keys {
|
||
v, ok := obj[k]
|
||
if !ok || v == nil {
|
||
continue
|
||
}
|
||
switch t := v.(type) {
|
||
case float64:
|
||
return t
|
||
case string:
|
||
if f, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil {
|
||
return f
|
||
}
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// scalarString 把 JSON 标量转成字符串(非标量返回空串)。时间戳(毫秒数字)按整数值原样输出。
|
||
func scalarString(v any) string {
|
||
switch t := v.(type) {
|
||
case string:
|
||
return strings.TrimSpace(t)
|
||
case float64:
|
||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||
case bool:
|
||
return strconv.FormatBool(t)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// Options 是**归一化之后统一做一次**的后处理开关
|
||
// (design/27 §4:域名过滤/去重/排序集中在这里,各 provider 一律不许自己过滤)。
|
||
type Options struct {
|
||
// Limit 是返回条数上限(≤0 = 不截断)。
|
||
Limit int
|
||
// AllowDomains 非空时只保留这些域名(含子域)。
|
||
AllowDomains []string
|
||
// BlockDomains 是排除的域名(含子域);优先于 AllowDomains。
|
||
BlockDomains []string
|
||
}
|
||
|
||
// Finalize 对归一化后的结果做一次统一后处理:
|
||
// 丢弃**四个可读字段全空**的行 → 域名过滤 → 去重(按规范化 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 emptyResult(r) {
|
||
// 四个可读字段全空的空壳行:没有任何东西可以给模型看,不算一条结果。
|
||
continue
|
||
}
|
||
if !domainAllowed(r.Link, opt.AllowDomains, opt.BlockDomains) {
|
||
continue
|
||
}
|
||
if r.Link != "" {
|
||
key := canonicalLink(r.Link)
|
||
if _, dup := seen[key]; dup {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
||
if opt.Limit > 0 && len(out) > opt.Limit {
|
||
out = out[:opt.Limit]
|
||
}
|
||
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 {
|
||
u, err := url.Parse(link)
|
||
if err != nil {
|
||
return true
|
||
}
|
||
host := strings.ToLower(u.Hostname())
|
||
if host == "" {
|
||
return true
|
||
}
|
||
if matchesDomain(host, block) {
|
||
return false
|
||
}
|
||
if len(allow) == 0 {
|
||
return true
|
||
}
|
||
return matchesDomain(host, allow)
|
||
}
|
||
|
||
// matchesDomain 报告 host 是否命中名单里的某一项(同名或为其子域)。
|
||
// 名单项容忍大小写、首尾空白与前导点(如 ".example.com")。
|
||
func matchesDomain(host string, list []string) bool {
|
||
h := strings.ToLower(strings.TrimSpace(host))
|
||
if h == "" {
|
||
return false
|
||
}
|
||
for _, raw := range list {
|
||
entry := strings.ToLower(strings.Trim(strings.TrimSpace(raw), "."))
|
||
if entry == "" {
|
||
continue
|
||
}
|
||
if h == entry || strings.HasSuffix(h, "."+entry) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// canonicalLink 是去重键:去 fragment、scheme/host 小写、path 去尾斜杠。
|
||
// 刻意**保留 query** —— 查询串不同的地址通常是不同页面,过度归并会吞掉真结果。
|
||
func canonicalLink(link string) string {
|
||
u, err := url.Parse(strings.TrimSpace(link))
|
||
if err != nil {
|
||
return strings.ToLower(strings.TrimSpace(link))
|
||
}
|
||
u.Fragment = ""
|
||
u.Scheme = strings.ToLower(u.Scheme)
|
||
u.Host = strings.ToLower(u.Host)
|
||
u.Path = strings.TrimSuffix(u.Path, "/")
|
||
return u.String()
|
||
}
|