146 lines
7.6 KiB
Go
146 lines
7.6 KiB
Go
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"},
|
||
}
|
||
}
|