Files
pj0235-eai_agentplatform/eai_agentplatform/backend-go/internal/search/dtsai.go
T
eaiadminandClaude Code c1af86c934 feat(asr): 本地语音转写接入为一级路由 + 并行工作流合并提交
按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:

  · 本地 ASR 接管:audio 成为与 chat/embed/image/video 同等的路由类别
    (IsLocalRoute 单一判据、audio 健康探测、default_audio_route、
    auto 占位、GET /api/ai/routes/audio、回退云端时界面明示「音频已出网」)
  · LLM 调用层:ctx 贯穿、ToolCall/ToolSchema、EmptyCompletionError /
    TransientUpstreamError(按错误类型而非文案判重试)
  · 编排 Agent:general_assistant orchestrate/persistence/spec_driver
  · 联网搜索:internal/search(playwright)
  · 网盘:backend + 前端
  · 前端 UI:导航/路由/工作台若干页
  · 交付文档:DELIVERY.md / AR04 / 部署文档的「无 Python」表述据实改写,
    新增 eai_agentplatform-asr.service、asr.env、clonezilla-cleanup 清 ~/asr-poc

不分拆的原因:dev 早期,粒度不该打断工作节奏。且实测过——这些改动
**在编译上是同一个单元**(llm.go 的 ctx 签名变更牵动 12 个调用点,
chat_message.go 的 ctx 改动又与编排重写同处一个 hunk),拆出来的中间态编不过。
详见 TOP_CODING_RULES.md G14.5 与 bugs_and_errors.md E09。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 22:21:39 +08:00

186 lines
5.7 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 (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// DtsAIClient 调用阿里云 DTS-AI 服务(DtsAI, Version 2026-04-01)的 WebSearch 获取搜索结果。
//
// 背景:SerpApi 是境外付费源,个别企业网络或跨境场景下连通性/合规受限。阿里云 DTS-AI
// 是部署在国内(cn-beijing)的官方搜索服务,服务器端完成检索,返回结构化 JSON、无验证码,
// 公测期间免费。可作为「通用可搜」闭环在国内的稳定补充源。
//
// 认证:DTS-AI 支持两种凭据,本实现采用官方推荐的 API Key 方式——通过请求头
// `X-Acs-ApiKey` 透传,无需计算 OpenAPI 签名,便于轻量集成。key 从环境变量
// `DTS_AI_API_KEY` 读取(可在 search_config.json 的 api_key_env 覆盖)。未配置 key 时
// Search 返回 (nil, skipDtsAIError) 让上层回退到下一引擎,保证无 key 也能跑。
//
// 调用方式(curl 实测对等):
//
// POST https://dtsai.cn-beijing.aliyuncs.com?Action=WebSearch&Version=2026-04-01&SignatureNonce=<uuid>
// Header: X-Acs-ApiKey: <key> Content-Type: application/json
// Body: {"RegionId":"cn-beijing","Query":"...","MaxResults":N}
type DtsAIClient struct {
apiKey string
region string
endpoint string
maxResults int
timeout time.Duration
hc *http.Client
}
// skipDtsAIError 表示未配置 DTS-AI API key,调用方应跳过本源继续回退。
type skipDtsAIError struct{}
func (skipDtsAIError) Error() string { return "DTS-AI 未配置 API key,跳过该源" }
// NewDtsAIClient 创建 DTS-AI 客户端。
// - apiKeyEnv: 读取 API key 的环境变量名(空串时默认 DTS_AI_API_KEY)
// - region: 地域 ID(默认 cn-beijing,官方另支持 ap-southeast-1)
// - endpoint: 服务入口;推荐 dtsai.<region>.aliyuncs.com,空串时按 region 推导
// - maxResults: 单次最多返回条数(官方默认 10,最大 50)
// - timeoutSec: 单次请求超时秒数
func NewDtsAIClient(apiKeyEnv, region, endpoint string, maxResults, timeoutSec int) *DtsAIClient {
envName := "DTS_AI_API_KEY"
if apiKeyEnv != "" {
envName = apiKeyEnv
}
if region == "" {
region = "cn-beijing"
}
if endpoint == "" {
endpoint = fmt.Sprintf("https://dtsai.%s.aliyuncs.com", region)
}
if maxResults <= 0 {
maxResults = 10
}
if timeoutSec <= 0 {
timeoutSec = 15
}
c := &DtsAIClient{
apiKey: strings.TrimSpace(os.Getenv(envName)),
region: region,
endpoint: strings.TrimRight(endpoint, "/"),
maxResults: maxResults,
}
c.hc = &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
return c
}
// randNonce 生成请求签名随机数(SignatureNonce),保持请求唯一性。
func randNonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// dtsAIRequest DTS-AI WebSearch 请求体(对齐 cargo 各字段部分可选)。
type dtsAIRequest struct {
RegionId string `json:"RegionId"`
Query string `json:"Query"`
MaxResults int `json:"MaxResults"`
}
// dtsAIItem 单个搜索结果。
type dtsAIItem struct {
Title string `json:"Title"`
Url string `json:"Url"`
Snippet string `json:"Snippet"`
}
// dtsAIResponse DTS-AI WebSearch 响应体。
type dtsAIResponse struct {
RequestId string `json:"RequestId"`
Query string `json:"Query"`
HttpStatusCode int `json:"HttpStatusCode"`
TotalResults int `json:"TotalResults"`
Success bool `json:"Success"`
ErrorMessage string `json:"ErrorMessage"`
Code string `json:"Code"`
SearchResult []dtsAIItem `json:"SearchResult"`
}
// Search 调用 DTS-AI WebSearch 搜索。未配置 key 时返回 skipDtsAIError。
func (c *DtsAIClient) Search(query string) ([]Result, error) {
if c.apiKey == "" {
return nil, skipDtsAIError{}
}
payload, err := json.Marshal(dtsAIRequest{
RegionId: c.region,
Query: query,
MaxResults: c.maxResults,
})
if err != nil {
return nil, fmt.Errorf("构建 DTS-AI 请求体失败: %w", err)
}
endpoint := fmt.Sprintf("%s?Action=WebSearch&Version=2026-04-01&SignatureNonce=%s",
c.endpoint, randNonce())
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("构建 DTS-AI 请求失败: %w", err)
}
req.Header.Set("X-Acs-ApiKey", c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("DTS-AI 不可达: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 DTS-AI 响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DTS-AI 返回 %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var out dtsAIResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("DTS-AI 响应解析失败: %w", err)
}
if !out.Success {
if out.ErrorMessage != "" {
return nil, fmt.Errorf("DTS-AI 业务错误: %s", out.ErrorMessage)
}
if out.Code != "" {
return nil, fmt.Errorf("DTS-AI 业务错误 code=%s", out.Code)
}
return nil, fmt.Errorf("DTS-AI 业务错误: Success=false")
}
var res []Result
for _, r := range out.SearchResult {
title := strings.TrimSpace(r.Title)
link := strings.TrimSpace(r.Url)
if title == "" || link == "" {
continue
}
res = append(res, Result{
Title: title,
URL: link,
Content: strings.TrimSpace(r.Snippet),
Engine: "dtsai",
})
if len(res) >= c.maxResults {
break
}
}
return res, nil
}