Files
Cyrene_v2/plugins/tools/io/search/zhipu.go
T
AskaEth 33ea7a830f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
style(search): 修 zhipu.go 的 gofmt 对齐(改动前即存在的格式债)
spec 字面量的三行对齐空格多余,gofmt -l 一直报它。零语义变更:
只删多余空格,gofmt -l 现已全干净。
2026-10-07 13:42:58 +08:00

146 lines
7.6 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 (
"context"
"encoding/json"
"fmt"
"net/http"
"unicode/utf8"
)
// zhipuProfile 是智谱 BigModel 联网搜索(design/27 §2.1 国内首选;分册 11)。
//
// 认证:`Authorization: Bearer <api_key>`;纯 REST(POST + JSON)⇒ 零新依赖。
// 官方**定价已确证**(分册 11 的官方定价页逐字):Search-Std 0.01 元/次、
// Search-Pro 0.03、Search-Pro-Sogou 0.05、Search-Pro-Quark 0.05。
//
// ✅ 官方 schema 已确证(2026-10-07,分册 11「官方 schema 确证」一节,逐条对应官方原文):
// - 请求 9 字段:`search_query`(≤ **70** 字)/ `search_engine`(四档 enum,必填)/
// `search_intent`(boolean,页面标 required)/ `count`(**1–50**,默认 10)/
// `search_domain_filter` / `search_recency_filter` / `content_size` / `request_id` / `user_id`;
// - 响应:顶层 `id`/`created`/`request_id`/`search_intent`(object[])/`search_result`(object[]),
// `search_result[]` 每条 7 键 = `title`/`content`/`link`/`media`/`icon`/`refer`/`publish_date`;
// - 🔴 同名字段语义反转:请求侧 `search_intent` 是 **boolean**,响应侧是 **object[]**。
//
// ⚠️ 诚实边界:**没有 API key ⇒ 真机未实测**(分册 11「本轮未能确证」逐条)。因此:
//
// ① 请求只发**确证过的字段**;② 响应用共享提取器按**多组候选键**容错 —— 官方 schema
// 已排除的候选键**暂不删**(见 zhipuSpec),待真机实测通过后再收窄 —— 拿未实测的推断
// 去砍掉容错路径,风险大于收益;③ `content_size` 的取值属**半确证**(见常量注释)。
type zhipu struct{ cfg Config }
// zhipuPath 是联网搜索端点路径(分册 11 官方原文:POST https://open.bigmodel.cn/api/paas/v4/web_search)。
const zhipuPath = "/api/paas/v4/web_search"
// zhipuEngineStd 是默认搜索型号。取 `search_std`(自研基础搜索,官方定价页确证
// 0.01 元/次,四档里最便宜)。官方 API Reference 把 `search_engine` 列为**必填 enum**
// (`search_std`/`search_pro`/`search_pro_sogou`/`search_pro_quark`),这里显式写死一档
// 以固定成本;要换档请改这里,或后续开放为配置项。
const zhipuEngineStd = "search_std"
// zhipuMaxQueryRunes 是 `search_query` 的**官方硬上限**(API Reference:Maximum string
// length 70)。超限就**如实报错、一个请求都不发** —— 静默截断会让她以为搜的是别的东西。
//
// 按**字符(rune)**计:官方只写 "string length",未说明是字符还是字节;中文查询词按字符
// 计最宽(一个中文 = 3 字节),拿不准时宁可少发几个字,也不误把合法查询词拒掉。
const zhipuMaxQueryRunes = 70
// zhipuCount 的官方合法区间(API Reference:1–50,默认 10)。
const (
zhipuMinCount = 1
zhipuMaxCount = 50
)
// zhipuContentSize 压短上游返回的正文(`medium` = 摘要),降低撞本仓 1 MiB 读取上限
// (plugins/tools/io 的 http 响应上限)的风险。
//
// ⚠️ **半确证**:API Reference 的字段表**没标 default**;「默认 medium」只来自 guide 页
// 示例旁的注释(分册 11「本轮未能确证」第 5 条)。这里按 `medium` 发,属保守取值。
const zhipuContentSize = "medium"
// newZhipu 造一家智谱 provider。
func newZhipu(cfg Config) Provider { return &zhipu{cfg: cfg} }
// Name 实现 Provider。
func (p *zhipu) Name() string { return NameZhipu }
// Search 实现 Provider。
func (p *zhipu) Search(ctx context.Context, q Query) ([]Result, error) {
if n := utf8.RuneCountInString(q.Text); n > zhipuMaxQueryRunes {
// 官方硬上限:超限**如实报错、不发请求**(截断 = 悄悄把她的查询词换成另一个)。
return nil, fmt.Errorf("search: zhipu: query is %d characters, upstream limit is %d",
n, zhipuMaxQueryRunes)
}
payload := map[string]any{
"search_engine": zhipuEngineStd,
"search_query": q.Text,
// 官方把 search_intent 标为 required(默认 false):缺发可能触发 1213(未正常接收到
// 该参数)。发 false = 直接搜、不做意图识别。
// 🔴 响应侧同名字段是 object[](意图结果),两侧语义相反 —— 别混用。
"search_intent": false,
// content_size 是**半确证**取值(见常量注释):压短正文,降低撞读取上限的风险。
"content_size": zhipuContentSize,
}
// count 落在官方区间才透传;越界就**不发这个字段**,让上游用默认 10 ——
// 把非法值发上去换一个 1210 参数错误没有意义。
if c, ok := zhipuCount(q.Count); ok {
payload["count"] = c
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: zhipu: encode request: %w", err)
}
data, err := do(ctx, p.cfg, request{
Method: http.MethodPost,
URL: baseURLJoin(p.cfg.BaseURL, zhipuPath),
Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey},
Body: body,
})
if err != nil {
return nil, err
}
return extractResults(data, zhipuSpec())
}
// zhipuCount 把工具侧的 count 收敛到官方区间 [1,50];越界返回 ok=false(不发该字段)。
func zhipuCount(count int) (int, bool) {
if count < zhipuMinCount || count > zhipuMaxCount {
return 0, false
}
return count, true
}
// zhipuSpec 是智谱响应的候选形状。
//
// ✅ 2026-10-07 官方 schema 已确证(分册 11):数组键是**顶层 `search_result`**,每条 7 键 =
// title/content/link/media/icon/refer/publish_date;官方**没有** snippet/summary/abstract/
// url/score/rank(「摘要」就在 `content` 里)。据此:
// - `publish_date` 是确证的发布时间 ⇒ 已映射到 Published;
// - 🔴 **`media` / `icon` / `refer` 当前一律不投影**(主人 2026-10-07 裁定,撤销先前把
// `media` 当 Snippet 候选的写法)。`media` 是**网站名称**(如「搜狐」),不是正文摘录:
// 官方响应**根本没有 `snippet`** ⇒ 若把它列为候选,`Snippet` 会被**恒由 `media` 填满**
// (不是偶尔兜底),等于每条结果的「摘要位」都变成站点名 —— 往语义位塞了另一类事实;
// 更危险的是共享提取器有一条**单向**回落(`extractResults`:`Content` 空 ⇒ 回落
// `Snippet`)⇒ 站点名有可能被当成正文递出去。
// `Result` 没有「来源站点」位(本片不许改 `extract.go` / `search.Result`)⇒ **将来要投影
// `media`,正确做法是给 `Result` 加一个字段并同步契约**(不是塞进 `Snippet`)。
//
// ⚠️ **候选键暂不删**:主人还没给 key,容错路径**未经真机验证**;官方 schema 只覆盖
// `search_std` 的文档口径(分册 11「各档引擎返回字段是否真同构」未确证)。**待真机实测
// 通过后**再把这些已排除的候选键收窄成单键。
func zhipuSpec() spec {
return spec{
Arrays: []string{"search_result", "results", "data", "data.results", "web_search_result"},
Title: []string{"title", "name"},
Link: []string{"link", "url"},
// 🔴 **不含 `media`**:它是网站名称、不是正文摘录,且官方响应没有 `snippet`
// ⇒ 列进来会让 Snippet **恒**被站点名填满,并被共享提取器的单向回落当成正文
// 递出去(理由见上方注释;`media`/`icon`/`refer` 当前一律不投影)。
Snippet: []string{"snippet", "summary", "abstract"},
Content: []string{"content", "text"},
// 官方确证:publish_date(如 "2025-05-23")。
Published: []string{"publish_date", "published", "date"},
Score: []string{"score", "rank"},
}
}