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>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SogouBaseURL 搜狗网页搜索地址(墙内可直连、按关键词真实返回结果,
|
||||
// 用于在 SearXNG 上游失效时作为第三方搜索源兜底)。
|
||||
const SogouBaseURL = "https://www.sogou.com/web"
|
||||
|
||||
const (
|
||||
sogouTimeout = 12 * time.Second
|
||||
sogouMaxResult = 8
|
||||
)
|
||||
|
||||
// SogouUserAgent 避免触发反爬降级的真实浏览器 UA。
|
||||
const SogouUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36"
|
||||
|
||||
var (
|
||||
// sogouItemRe 匹配搜狗结果条目。实际结构(实测):
|
||||
// <a href="真实URL" class="special-title" ...>
|
||||
// <h3 class="vr-title"><span>标题</span>
|
||||
// <p class="title-summary">摘要</p>
|
||||
// </h3>
|
||||
// </a>
|
||||
// 取 <a> 的 href 作 URL,h3 内文本作标题。
|
||||
sogouItemRe = regexp.MustCompile(`(?s)<a[^>]*href="([^"]+)"[^>]*>.*?<h3[^>]*class="[^"]*vr-title[^"]*"[^>]*>.*?<span[^>]*>(.*?)</span>`)
|
||||
// sogouDescRe 匹配结果摘要(title-summary / str_info / space-txt 等文本容器)。
|
||||
sogouDescRe = regexp.MustCompile(`(?s)<(?:p|span|div)[^>]*class="[^"]*(?:title-summary|str_info|space-txt|fb fz-mid|text-info)[^"]*"[^>]*>(.*?)</(?:p|span|div)>`)
|
||||
anyTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||
)
|
||||
|
||||
// SogouClient 搜狗网页搜索客户端。随机化部分请求头,降低连续查询触发
|
||||
// 人机校验的概率(已验证源响应按关键词真实匹配,而非缓存页)。
|
||||
type SogouClient struct {
|
||||
timeout time.Duration
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// NewSogouClient 创建搜狗搜索客户端。
|
||||
func NewSogouClient() *SogouClient {
|
||||
return &SogouClient{timeout: sogouTimeout, hc: &http.Client{Timeout: sogouTimeout}}
|
||||
}
|
||||
|
||||
// Search 抓取并解析搜狗网页搜索结果,映射为 []Result。
|
||||
// 搜狗对数据中心 IP 的连续快速查询偶发人机校验(返回验证码页),
|
||||
// 此处做一次短暂退避重试,降低瞬时校验导致的空结果概率。
|
||||
func (s *SogouClient) Search(query string) ([]Result, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
res, err := s.searchOnce(query)
|
||||
if (err == nil && len(res) > 0) || attempt >= 1 {
|
||||
return res, err
|
||||
}
|
||||
// 空结果或错误:等一小段再试一次
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SogouClient) searchOnce(query string) ([]Result, error) {
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
searchURL := SogouBaseURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, searchURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构建搜狗请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", SogouUserAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
req.Header.Set("Referer", "https://www.sogou.com/")
|
||||
req.Header.Set("X-Forwarded-For", randomIP())
|
||||
req.Header.Set("X-Real-IP", randomIP())
|
||||
|
||||
resp, err := s.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("搜狗不可达: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("搜狗返回 %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取搜狗响应失败: %w", err)
|
||||
}
|
||||
return parseSogou(body), nil
|
||||
}
|
||||
|
||||
// parseSogou 从搜狗结果页提取标题/链接/摘要。
|
||||
func parseSogou(page []byte) []Result {
|
||||
raw := string(page)
|
||||
// 命中人机校验页则明确返回空(触发上层回退),不产出伪结果。
|
||||
if strings.Contains(raw, "请输入验证码") || strings.Contains(raw, "antispider") || strings.Contains(raw, "访问过于频繁") {
|
||||
return nil
|
||||
}
|
||||
|
||||
matches := sogouItemRe.FindAllStringSubmatch(raw, -1)
|
||||
results := make([]Result, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
href := m[1]
|
||||
title := cleanText(m[2])
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
// 跳过非可访问协议(如 javascript:void(0) 的分享桩),只保留 http(s) 结果链接。
|
||||
if !strings.HasPrefix(href, "http") && !strings.HasPrefix(href, "/link?") {
|
||||
continue
|
||||
}
|
||||
// 结果链接多为搜狗跳转封装 (/link?url=...),保留源标题/摘要,
|
||||
// 跳转链接可由调用方按需跟进,不影响 LLM 引用来源。
|
||||
urlText := href
|
||||
if strings.HasPrefix(href, "/link?") || strings.HasPrefix(href, "/weixin") {
|
||||
urlText = "https://www.sogou.com" + href
|
||||
}
|
||||
results = append(results, Result{
|
||||
Title: title,
|
||||
URL: urlText,
|
||||
Content: extractSogouDesc(raw),
|
||||
Engine: "sogou",
|
||||
})
|
||||
if len(results) >= sogouMaxResult {
|
||||
break
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// extractSogouDesc 提取搜狗摘要段;无法精确定位时返回空。
|
||||
func extractSogouDesc(raw string) string {
|
||||
m := sogouDescRe.FindStringSubmatch(raw)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return cleanText(m[1])
|
||||
}
|
||||
|
||||
func cleanText(s string) string {
|
||||
s = anyTagRe.ReplaceAllString(s, "")
|
||||
s = html.UnescapeString(s)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func randomIP() string {
|
||||
b := make([]byte, 4)
|
||||
_, _ = rand.Read(b)
|
||||
return fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3])
|
||||
}
|
||||
Reference in New Issue
Block a user