Version: 0.9.15.dev.260412
后端: 1. 排程工具从 tools/ 根目录拆分为 tools/schedule 独立子包 - 12 个排程工具文件等价迁入 tools/schedule/,tools/ 根目录仅保留 registry.go 作为统一注册入口 - 所有依赖方(conv / model / node / prompt / service)import 统一切到 schedule 子包 2. Web 搜索工具链落地(tools/web 子包) - 新增 web_search(结构化检索)与 web_fetch(正文抓取)两个读工具,支持博查 API / mock 降级 - 启动流程按配置选择 provider,未识别类型自动降级为 mock,不阻断主流程 - 执行提示补齐 web 工具使用约束与返回值示例 - config.example.yaml 补齐 websearch 配置段 前端:无 仓库:无
This commit is contained in:
175
backend/newAgent/tools/web/fetcher.go
Normal file
175
backend/newAgent/tools/web/fetcher.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fetcher 抓取指定 URL 正文并做最小 HTML 清洗。
|
||||
//
|
||||
// 职责:
|
||||
// 1. 发起 HTTP GET 请求并读取响应体;
|
||||
// 2. 剥离 HTML 标签,保留纯文本内容;
|
||||
// 3. 按 MaxChars 截断,避免超长正文占用模型上下文。
|
||||
//
|
||||
// 不负责:
|
||||
// 1. 不负责 JS 渲染(无法处理 SPA 页面);
|
||||
// 2. 不负责反爬绕过(遇到 403 直接返回错误);
|
||||
// 3. 不负责正文提取算法优化(仅做粗粒度标签剥离)。
|
||||
type Fetcher struct {
|
||||
// Client 带超时的 HTTP 客户端,由调用方注入。
|
||||
Client *http.Client
|
||||
|
||||
// MaxChars 正文最大字符数。超出时截断并标记 truncated=true。0 使用默认值 4000。
|
||||
MaxChars int
|
||||
}
|
||||
|
||||
// NewFetcher 创建默认 Fetcher。
|
||||
//
|
||||
// 1. 超时默认 10 秒,足够覆盖大多数静态页面;
|
||||
// 2. MaxChars 默认 4000 字符,约占 1000~2000 token,不会挤占过多上下文。
|
||||
func NewFetcher() *Fetcher {
|
||||
return &Fetcher{
|
||||
Client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
MaxChars: 4000,
|
||||
}
|
||||
}
|
||||
|
||||
// FetchResult 抓取结果。
|
||||
type FetchResult struct {
|
||||
// Title 页面标题(从 <title> 标签提取)。
|
||||
Title string
|
||||
|
||||
// Content 清洗后的纯文本正文。
|
||||
Content string
|
||||
|
||||
// Truncated 正文是否被截断。
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// Fetch 抓取指定 URL 并返回清洗后的正文。
|
||||
//
|
||||
// 流程:
|
||||
// 1. 构建带超时的 HTTP GET 请求;
|
||||
// 2. 检查状态码,非 2xx 直接返回可读错误;
|
||||
// 3. 读取响应体,提取 <title>;
|
||||
// 4. 剥离 HTML 标签,按 MaxChars 截断;
|
||||
// 5. 所有失败场景返回 error,由工具层兜底组装 observation。
|
||||
func (f *Fetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||
// 1. 构建请求,注入 ctx 用于超时与取消。
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构建请求失败:%w", err)
|
||||
}
|
||||
|
||||
// 2. 模拟浏览器 User-Agent,避免部分站点直接拒绝。
|
||||
req.Header.Set("User-Agent", "SmartFlow-Agent/1.0 (compatible; web_fetch)")
|
||||
|
||||
resp, err := f.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 3. 非 2xx 返回明确状态码,方便工具层区分 4xx/5xx。
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP %d:%s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// 4. 限制读取量(最多 1MB),防止恶意超长响应撑爆内存。
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应体失败:%w", err)
|
||||
}
|
||||
|
||||
htmlStr := string(body)
|
||||
|
||||
// 5. 提取 <title> 内容。
|
||||
title := extractHTMLTitle(htmlStr)
|
||||
|
||||
// 6. 剥离 HTML 标签,得到纯文本。
|
||||
text := stripHTMLTags(htmlStr)
|
||||
|
||||
// 7. 清理多余空白(连续换行、行首行尾空格)。
|
||||
text = cleanWhitespace(text)
|
||||
|
||||
// 8. 按 MaxChars 截断。
|
||||
maxChars := f.MaxChars
|
||||
if maxChars <= 0 {
|
||||
maxChars = 4000
|
||||
}
|
||||
truncated := false
|
||||
runes := []rune(text)
|
||||
if len(runes) > maxChars {
|
||||
truncated = true
|
||||
runes = runes[:maxChars]
|
||||
}
|
||||
|
||||
return &FetchResult{
|
||||
Title: title,
|
||||
Content: string(runes),
|
||||
Truncated: truncated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractHTMLTitle 从 HTML 中提取 <title> 标签内容。
|
||||
//
|
||||
// 1. 使用正则匹配,不做 DOM 解析(兼顾性能与简单性);
|
||||
// 2. 找不到时返回空字符串,不报错。
|
||||
func extractHTMLTitle(htmlStr string) string {
|
||||
re := regexp.MustCompile("(?i)<title[^>]*>(.*?)</title>")
|
||||
matches := re.FindStringSubmatch(htmlStr)
|
||||
if len(matches) >= 2 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stripHTMLTags 剥离所有 HTML 标签,保留纯文本。
|
||||
//
|
||||
// 1. 先移除 <script> / <style> 块(避免 JS/CSS 内容污染正文);
|
||||
// 2. 再移除所有 HTML 标签;
|
||||
// 3. 解码常见 HTML 实体(& < > ")。
|
||||
func stripHTMLTags(htmlStr string) string {
|
||||
// 1. 移除 script/style 块
|
||||
re := regexp.MustCompile("(?is)<(script|style)[^>]*>.*?</\\1>")
|
||||
text := re.ReplaceAllString(htmlStr, " ")
|
||||
|
||||
// 2. 移除所有 HTML 标签
|
||||
reTag := regexp.MustCompile("<[^>]+>")
|
||||
text = reTag.ReplaceAllString(text, " ")
|
||||
|
||||
// 3. 解码常见 HTML 实体
|
||||
text = strings.ReplaceAll(text, "&", "&")
|
||||
text = strings.ReplaceAll(text, "<", "<")
|
||||
text = strings.ReplaceAll(text, ">", ">")
|
||||
text = strings.ReplaceAll(text, """, "\"")
|
||||
text = strings.ReplaceAll(text, "'", "'")
|
||||
text = strings.ReplaceAll(text, " ", " ")
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// cleanWhitespace 清理多余空白:连续空行合并为单个换行,去除行首行尾空格。
|
||||
func cleanWhitespace(text string) string {
|
||||
// 1. 连续换行压缩为最多两个换行(保留段落分隔感)。
|
||||
re := regexp.MustCompile("\\n{3,}")
|
||||
text = re.ReplaceAllString(text, "\n\n")
|
||||
|
||||
// 2. 按行去除首尾空白后重新拼装。
|
||||
lines := strings.Split(text, "\n")
|
||||
cleaned := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
cleaned = append(cleaned, trimmed)
|
||||
}
|
||||
|
||||
return strings.Join(cleaned, "\n")
|
||||
}
|
||||
82
backend/newAgent/tools/web/provider.go
Normal file
82
backend/newAgent/tools/web/provider.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SearchProvider 搜索供应商抽象接口。
|
||||
//
|
||||
// 职责:
|
||||
// 1. 接收检索查询与选项,返回结构化搜索结果;
|
||||
// 2. 实现方负责 HTTP 调用、错误重试、限流兜底;
|
||||
// 3. 调用方不感知底层是 Bocha / Mock 还是其他供应商。
|
||||
//
|
||||
// 不负责:
|
||||
// 1. 不负责 URL 正文抓取(由 Fetcher 承担);
|
||||
// 2. 不负责结果缓存(由上层工具决定)。
|
||||
type SearchProvider interface {
|
||||
// Name 返回供应商名称(如 "mock"、"bocha"),用于日志与降级标识。
|
||||
Name() string
|
||||
|
||||
// Search 执行一次检索。
|
||||
//
|
||||
// 1. ctx 用于超时控制与取消;
|
||||
// 2. opts.TopK 默认 5,上限 20,超出自动截断;
|
||||
// 3. 失败时返回 error,调用方负责兜底 observation 组装。
|
||||
Search(ctx context.Context, query string, opts SearchOptions) (*SearchResponse, error)
|
||||
}
|
||||
|
||||
// SearchOptions 搜索可选参数。
|
||||
type SearchOptions struct {
|
||||
// TopK 返回结果数上限。0 表示使用供应商默认值(通常为 5)。
|
||||
TopK int
|
||||
|
||||
// DomainAllow 仅返回指定域名下的结果。空表示不限。
|
||||
DomainAllow []string
|
||||
|
||||
// RecencyDays 仅返回最近 N 天内的结果。0 表示不限时间。
|
||||
RecencyDays int
|
||||
}
|
||||
|
||||
// SearchResponse 搜索结果集合。
|
||||
type SearchResponse struct {
|
||||
// Query 原始查询文本,用于日志追踪。
|
||||
Query string
|
||||
|
||||
// Items 搜索结果条目,按相关性降序排列。
|
||||
Items []SearchItem
|
||||
}
|
||||
|
||||
// SearchItem 单条搜索结果。
|
||||
type SearchItem struct {
|
||||
// Title 页面标题。
|
||||
Title string
|
||||
|
||||
// URL 页面链接。
|
||||
URL string
|
||||
|
||||
// Snippet 搜索引擎返回的摘要片段。
|
||||
Snippet string
|
||||
|
||||
// Domain 来源域名(如 "example.com"),由实现方从 URL 提取。
|
||||
Domain string
|
||||
|
||||
// PublishedAt 页面发布时间(若供应商可提供)。零值表示未知。
|
||||
PublishedAt time.Time
|
||||
|
||||
// Raw 供应商原始响应字段,供调试用,不传给模型。
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
// normalizeTopK 将用户传入的 topK 归一化到 [1, max] 区间。
|
||||
// 默认值 5,上限 20,防止模型传入异常值导致 API 爆炸。
|
||||
func normalizeTopK(topK, defaultVal, maxVal int) int {
|
||||
if topK <= 0 {
|
||||
return defaultVal
|
||||
}
|
||||
if topK > maxVal {
|
||||
return maxVal
|
||||
}
|
||||
return topK
|
||||
}
|
||||
217
backend/newAgent/tools/web/provider_bocha.go
Normal file
217
backend/newAgent/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"`
|
||||
}
|
||||
58
backend/newAgent/tools/web/provider_mock.go
Normal file
58
backend/newAgent/tools/web/provider_mock.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockProvider 空实现搜索供应商,返回硬编码结果。
|
||||
//
|
||||
// 用途:
|
||||
// 1. 在真实 API Key 到手前,先跑通工具注册→调用→observation 写回的完整链路;
|
||||
// 2. 后续替换为 Tavily/Brave 实现后,Mock 保留用于单元测试。
|
||||
//
|
||||
// 不负责:
|
||||
// 1. 不负责真实 HTTP 调用;
|
||||
// 2. 不负责网络错误模拟(如需测试超时可另行实现 TimeoutMockProvider)。
|
||||
type MockProvider struct{}
|
||||
|
||||
// Name 返回供应商标识。
|
||||
func (m *MockProvider) Name() string { return "mock" }
|
||||
|
||||
// Search 返回 2 条硬编码搜索结果,模拟正常检索链路。
|
||||
//
|
||||
// 1. 无论 query 内容如何,始终返回相同结果;
|
||||
// 2. ctx 仅做形式兼容,不检查超时;
|
||||
// 3. 永远不返回 error(Mock 不模拟失败场景)。
|
||||
func (m *MockProvider) Search(_ context.Context, query string, opts SearchOptions) (*SearchResponse, error) {
|
||||
topK := normalizeTopK(opts.TopK, 5, 20)
|
||||
|
||||
// 1. 准备 2 条模拟数据,覆盖核心字段(title/url/snippet/domain/published_at);
|
||||
// 2. 若调用方 topK=1 则只返回第一条。
|
||||
mockItems := []SearchItem{
|
||||
{
|
||||
Title: fmt.Sprintf("搜索结果示例 - %s", query),
|
||||
URL: "https://example.com/search-result-1",
|
||||
Snippet: "这是 MockProvider 返回的模拟搜索摘要,用于验证工具链路是否通畅。",
|
||||
Domain: "example.com",
|
||||
PublishedAt: time.Now().Add(-24 * time.Hour),
|
||||
},
|
||||
{
|
||||
Title: fmt.Sprintf("相关资料 - %s", query),
|
||||
URL: "https://example.com/related-resource-2",
|
||||
Snippet: "这是第二条 Mock 结果,模拟同主题下的补充信息来源。",
|
||||
Domain: "example.com",
|
||||
PublishedAt: time.Now().Add(-48 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
if topK < len(mockItems) {
|
||||
mockItems = mockItems[:topK]
|
||||
}
|
||||
|
||||
return &SearchResponse{
|
||||
Query: query,
|
||||
Items: mockItems,
|
||||
}, nil
|
||||
}
|
||||
227
backend/newAgent/tools/web/tools.go
Normal file
227
backend/newAgent/tools/web/tools.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SearchToolHandler web_search 工具 handler。
|
||||
//
|
||||
// 职责:
|
||||
// 1. 解析 args(query / top_k / domain_allow / recency_days);
|
||||
// 2. 调用 SearchProvider 执行检索;
|
||||
// 3. 组装结构化 JSON observation 返回给模型。
|
||||
//
|
||||
// 不负责:
|
||||
// 1. 不负责 provider 生命周期管理(由注册层注入);
|
||||
// 2. 不负责重试(provider 内部处理)。
|
||||
type SearchToolHandler struct {
|
||||
provider SearchProvider
|
||||
}
|
||||
|
||||
// NewSearchToolHandler 创建 web_search 工具 handler。
|
||||
//
|
||||
// 1. provider 为 nil 时,Handle 返回"搜索暂未启用"的 observation;
|
||||
// 2. 这样做的好处是:即使未配置 provider,也不会阻断主流程。
|
||||
func NewSearchToolHandler(provider SearchProvider) *SearchToolHandler {
|
||||
return &SearchToolHandler{provider: provider}
|
||||
}
|
||||
|
||||
// searchToolArgs web_search 工具的参数定义。
|
||||
type searchToolArgs struct {
|
||||
Query string `json:"query"`
|
||||
TopK int `json:"top_k"`
|
||||
DomainAllow []string `json:"domain_allow"`
|
||||
RecencyDays int `json:"recency_days"`
|
||||
}
|
||||
|
||||
// searchToolResult web_search 工具的输出结构。
|
||||
type searchToolResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Query string `json:"query"`
|
||||
Count int `json:"count"`
|
||||
Items []searchItem `json:"items"`
|
||||
}
|
||||
|
||||
// searchItem 输出给模型的单条搜索结果。
|
||||
type searchItem struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
Domain string `json:"domain"`
|
||||
PublishedAt string `json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
// Handle 执行 web_search 工具。
|
||||
//
|
||||
// 1. 解析参数,query 为必填,缺失时直接返回错误 observation;
|
||||
// 2. 调用 provider.Search,超时上限 10 秒;
|
||||
// 3. 失败时返回可恢复 observation(包含错误原因),不 panic、不阻断主流程。
|
||||
func (h *SearchToolHandler) Handle(args map[string]any) string {
|
||||
// 1. provider 为 nil 说明未启用,直接返回提示。
|
||||
if h.provider == nil {
|
||||
return `{"tool":"web_search","error":"搜索暂未启用,请跳过 web_search 继续执行其他操作。"}`
|
||||
}
|
||||
|
||||
// 2. 提取必填参数 query。
|
||||
query, _ := args["query"].(string)
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return `{"tool":"web_search","error":"参数错误:缺少必填参数 query。"}`
|
||||
}
|
||||
|
||||
// 3. 提取可选参数。
|
||||
topK, _ := args["top_k"].(float64)
|
||||
var domainAllow []string
|
||||
if raw, ok := args["domain_allow"].([]any); ok {
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok {
|
||||
domainAllow = append(domainAllow, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
recencyDays, _ := args["recency_days"].(float64)
|
||||
|
||||
// 4. 构建带超时的 context,防止搜索请求卡死。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 5. 调用 provider。
|
||||
start := time.Now()
|
||||
resp, err := h.provider.Search(ctx, query, SearchOptions{
|
||||
TopK: int(topK),
|
||||
DomainAllow: domainAllow,
|
||||
RecencyDays: int(recencyDays),
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 6. 记录日志,方便排查搜索耗时、结果数、失败原因。
|
||||
log.Printf("[web_search] provider=%s query=%q topK=%d elapsed=%s results=%d err=%v",
|
||||
h.provider.Name(), query, int(topK), elapsed, len(resp.Items), err)
|
||||
|
||||
if err != nil {
|
||||
// 7. 失败时返回可恢复 observation:模型看到后可选择换 query 或跳过。
|
||||
return fmt.Sprintf(`{"tool":"web_search","error":"搜索失败:%s","query":%q}`, err.Error(), query)
|
||||
}
|
||||
|
||||
// 8. 组装输出 JSON。
|
||||
items := make([]searchItem, 0, len(resp.Items))
|
||||
for _, item := range resp.Items {
|
||||
si := searchItem{
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Snippet: item.Snippet,
|
||||
Domain: item.Domain,
|
||||
}
|
||||
if !item.PublishedAt.IsZero() {
|
||||
si.PublishedAt = item.PublishedAt.Format("2006-01-02")
|
||||
}
|
||||
items = append(items, si)
|
||||
}
|
||||
|
||||
result := searchToolResult{
|
||||
Tool: "web_search",
|
||||
Query: query,
|
||||
Count: len(items),
|
||||
Items: items,
|
||||
}
|
||||
|
||||
out, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"web_search","error":"序列化结果失败:%s"}`, err.Error())
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// FetchToolHandler web_fetch 工具 handler。
|
||||
//
|
||||
// 职责:
|
||||
// 1. 解析 args(url / max_chars);
|
||||
// 2. 调用 Fetcher 抓取并清洗正文;
|
||||
// 3. 组装 JSON observation 返回给模型。
|
||||
type FetchToolHandler struct {
|
||||
fetcher *Fetcher
|
||||
}
|
||||
|
||||
// NewFetchToolHandler 创建 web_fetch 工具 handler。
|
||||
func NewFetchToolHandler(fetcher *Fetcher) *FetchToolHandler {
|
||||
return &FetchToolHandler{fetcher: fetcher}
|
||||
}
|
||||
|
||||
// fetchToolResult web_fetch 工具的输出结构。
|
||||
type fetchToolResult struct {
|
||||
Tool string `json:"tool"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// Handle 执行 web_fetch 工具。
|
||||
//
|
||||
// 1. 解析参数,url 为必填;
|
||||
// 2. max_chars 可选,为 0 时使用 Fetcher 默认值(4000);
|
||||
// 3. 所有失败场景返回结构化错误 observation,不 panic。
|
||||
func (h *FetchToolHandler) Handle(args map[string]any) string {
|
||||
// 1. fetcher 为 nil 说明未初始化。
|
||||
if h.fetcher == nil {
|
||||
return `{"tool":"web_fetch","error":"抓取服务暂未初始化,请跳过 web_fetch 继续执行。"}`
|
||||
}
|
||||
|
||||
// 2. 提取必填参数 url。
|
||||
url, _ := args["url"].(string)
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
return `{"tool":"web_fetch","error":"参数错误:缺少必填参数 url。"}`
|
||||
}
|
||||
|
||||
// 3. 提取可选参数 max_chars,覆盖 Fetcher 默认值。
|
||||
maxChars := 0
|
||||
if v, ok := args["max_chars"].(float64); ok {
|
||||
maxChars = int(v)
|
||||
}
|
||||
|
||||
// 4. 若调用方指定 max_chars,临时覆盖 Fetcher 配置。
|
||||
savedMaxChars := h.fetcher.MaxChars
|
||||
if maxChars > 0 {
|
||||
h.fetcher.MaxChars = maxChars
|
||||
}
|
||||
defer func() {
|
||||
h.fetcher.MaxChars = savedMaxChars
|
||||
}()
|
||||
|
||||
// 5. 构建带超时的 context。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 6. 调用 Fetcher。
|
||||
start := time.Now()
|
||||
result, err := h.fetcher.Fetch(ctx, url)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
log.Printf("[web_fetch] url=%q elapsed=%s truncated=%v err=%v", url, elapsed, result != nil && result.Truncated, err)
|
||||
|
||||
if err != nil {
|
||||
// 7. 失败时返回可恢复 observation。
|
||||
return fmt.Sprintf(`{"tool":"web_fetch","error":"抓取失败:%s","url":%q}`, err.Error(), url)
|
||||
}
|
||||
|
||||
// 8. 组装输出 JSON。
|
||||
out := fetchToolResult{
|
||||
Tool: "web_fetch",
|
||||
URL: url,
|
||||
Title: result.Title,
|
||||
Content: result.Content,
|
||||
Truncated: result.Truncated,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"web_fetch","error":"序列化结果失败:%s"}`, err.Error())
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
Reference in New Issue
Block a user