Version: 0.9.75.dev.260505
后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
This commit is contained in:
217
backend/services/agent/tools/web/provider_bocha.go
Normal file
217
backend/services/agent/tools/web/provider_bocha.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BochaProvider 博查(Bocha)搜索供应商实现。
|
||||
//
|
||||
// 职责:
|
||||
// 1. 将 SearchOptions 映射为博查 API 请求参数;
|
||||
// 2. 发起 HTTP POST 调用博查 web-search 端点;
|
||||
// 3. 将博查响应转换为统一的 SearchResponse 结构。
|
||||
//
|
||||
// 不负责:
|
||||
// 1. 不负责 API Key 管理(由调用方注入);
|
||||
// 2. 不负责重试(单次调用失败直接返回 error);
|
||||
// 3. 不负责 URL 正文抓取(由 Fetcher 承担)。
|
||||
//
|
||||
// 博查 API 文档:https://open.bochaai.com/
|
||||
type BochaProvider struct {
|
||||
// apiKey 博查 API Key,从配置注入。
|
||||
apiKey string
|
||||
|
||||
// httpClient 带超时的 HTTP 客户端。
|
||||
httpClient *http.Client
|
||||
|
||||
// baseURL 博查 API 基础地址,默认 https://api.bochaai.com/v1。
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewBochaProvider 创建博查搜索供应商。
|
||||
//
|
||||
// 1. apiKey 必填,为空时 Search 会返回明确错误;
|
||||
// 2. 超时默认 10 秒,与工具层 ctx 超时对齐;
|
||||
// 3. baseURL 留空则使用默认地址。
|
||||
func NewBochaProvider(apiKey, baseURL string) *BochaProvider {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.bochaai.com/v1"
|
||||
}
|
||||
return &BochaProvider{
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// Name 返回供应商标识。
|
||||
func (b *BochaProvider) Name() string { return "bocha" }
|
||||
|
||||
// Search 调用博查 web-search API 执行检索。
|
||||
//
|
||||
// 流程:
|
||||
// 1. 参数校验(apiKey 非空、query 非空);
|
||||
// 2. 将 SearchOptions 映射为博查请求体(count / freshness / summary);
|
||||
// 3. 发起 HTTP POST,读取响应;
|
||||
// 4. 解析博查 JSON 响应,提取 webPages.value 数组;
|
||||
// 5. 转换为统一 SearchItem 结构返回。
|
||||
//
|
||||
// 错误处理:
|
||||
// - apiKey 为空 → 返回明确错误;
|
||||
// - HTTP 非 2xx → 返回带状态码的错误;
|
||||
// - 响应解析失败 → 返回原始响应片段供排查。
|
||||
func (b *BochaProvider) Search(ctx context.Context, query string, opts SearchOptions) (*SearchResponse, error) {
|
||||
// 1. 参数校验。
|
||||
if b.apiKey == "" {
|
||||
return nil, fmt.Errorf("博查 API Key 未配置")
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("查询关键词为空")
|
||||
}
|
||||
|
||||
// 2. 组装请求体。
|
||||
// 2.1 count:博查支持 1~50,默认 10。
|
||||
count := normalizeTopK(opts.TopK, 10, 50)
|
||||
|
||||
// 2.2 freshness:将 RecencyDays 映射为博查的时间过滤枚举。
|
||||
freshness := mapRecencyDaysToFreshness(opts.RecencyDays)
|
||||
|
||||
reqBody := bochaSearchRequest{
|
||||
Query: query,
|
||||
Count: count,
|
||||
Freshness: freshness,
|
||||
Summary: true, // 开启 AI 摘要,提升结果信息密度
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求体失败:%w", err)
|
||||
}
|
||||
|
||||
// 3. 发起 HTTP POST。
|
||||
url := b.baseURL + "/web-search"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构建请求失败:%w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+b.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := b.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求博查 API 失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 4. 读取响应体(限制 2MB,防止异常响应撑爆内存)。
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取博查响应失败:%w", err)
|
||||
}
|
||||
|
||||
// 5. 检查 HTTP 状态码。
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// 截取前 200 字符作为错误上下文,避免日志过长。
|
||||
snippet := string(respBody)
|
||||
if len(snippet) > 200 {
|
||||
snippet = snippet[:200]
|
||||
}
|
||||
return nil, fmt.Errorf("博查 API 返回 HTTP %d:%s", resp.StatusCode, snippet)
|
||||
}
|
||||
|
||||
// 6. 解析响应 JSON。
|
||||
var bochaResp bochaSearchAPIResponse
|
||||
if err := json.Unmarshal(respBody, &bochaResp); err != nil {
|
||||
return nil, fmt.Errorf("解析博查响应失败:%w", err)
|
||||
}
|
||||
|
||||
// 7. 提取搜索结果。
|
||||
items := make([]SearchItem, 0, len(bochaResp.Data.WebPages.Value))
|
||||
for _, v := range bochaResp.Data.WebPages.Value {
|
||||
item := SearchItem{
|
||||
Title: v.Name,
|
||||
URL: v.URL,
|
||||
Snippet: v.Summary, // 优先使用 AI 摘要;若为空则回退到 snippet
|
||||
Domain: v.SiteName,
|
||||
}
|
||||
if item.Snippet == "" {
|
||||
item.Snippet = v.Snippet
|
||||
}
|
||||
// 解析发布时间(博查格式:2024-07-22T00:00:00+08:00)。
|
||||
if v.DatePublished != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v.DatePublished); err == nil {
|
||||
item.PublishedAt = t
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return &SearchResponse{
|
||||
Query: query,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mapRecencyDaysToFreshness 将 RecencyDays 映射为博查 freshness 枚举值。
|
||||
//
|
||||
// 映射规则:
|
||||
// - 0 → noLimit(不限时间)
|
||||
// - 1 → oneDay
|
||||
// - 2~7 → oneWeek
|
||||
// - 8~30 → oneMonth
|
||||
// - 31~365 → oneYear
|
||||
// - >365 → noLimit
|
||||
func mapRecencyDaysToFreshness(days int) string {
|
||||
switch {
|
||||
case days <= 0:
|
||||
return "noLimit"
|
||||
case days <= 1:
|
||||
return "oneDay"
|
||||
case days <= 7:
|
||||
return "oneWeek"
|
||||
case days <= 30:
|
||||
return "oneMonth"
|
||||
case days <= 365:
|
||||
return "oneYear"
|
||||
default:
|
||||
return "noLimit"
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 博查 API 请求/响应结构体 ====================
|
||||
|
||||
// bochaSearchRequest 博查 web-search 请求体。
|
||||
type bochaSearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
Count int `json:"count"`
|
||||
Freshness string `json:"freshness"`
|
||||
Summary bool `json:"summary"`
|
||||
}
|
||||
|
||||
// bochaSearchAPIResponse 博查 web-search 响应体(只提取需要的字段)。
|
||||
type bochaSearchAPIResponse struct {
|
||||
Data struct {
|
||||
WebPages struct {
|
||||
Value []bochaWebPageItem `json:"value"`
|
||||
} `json:"webPages"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// bochaWebPageItem 博查单条搜索结果。
|
||||
type bochaWebPageItem struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
Summary string `json:"summary"`
|
||||
SiteName string `json:"siteName"`
|
||||
DatePublished string `json:"datePublished"`
|
||||
}
|
||||
Reference in New Issue
Block a user