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:
eaiadmin
2026-09-26 22:21:39 +08:00
co-authored by Claude Code
parent e73169df50
commit c1af86c934
192 changed files with 18046 additions and 392 deletions
@@ -0,0 +1,253 @@
package routetest
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"eai_agentplatform/backend/internal/config"
)
// 这一组用例钉的是语音路由的通路测试。
//
// 背景:routetest 原先按 Category 分发,但只认 chat/embed/image/video,
// audio 落到 default,于是「测试语音路由」会把一个 /chat/completions 请求
// 打到 ASR 服务上。报回来的错(模型不存在、参数不合法)与真实原因
// (走错接口)毫无关系,而这个红叉会让人去改一条本来没问题的路由。
//
// 所以第一条断言就是「打到了转写终点」——不是「没报错」。
// audioStub 起一个桩,记录收到的请求,能同时应答转写与 chat 两种终点。
type audioStub struct {
srv *httptest.Server
audioCalls int
chatCalls int
lastPath string
lastAuth string
lastCt string
lastFormFile string
audioStatus int
}
func newAudioStub(t *testing.T) *audioStub {
t.Helper()
s := &audioStub{audioStatus: http.StatusOK}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
// 按后缀认转写终点,这样用例可以换一个 Endpoint 验证它确实来自配置。
case strings.HasSuffix(r.URL.Path, "/transcriptions"):
s.audioCalls++
s.lastPath = r.URL.Path
s.lastAuth = r.Header.Get("Authorization")
s.lastCt = r.Header.Get("Content-Type")
s.lastFormFile = formFileName(r)
if s.audioStatus != http.StatusOK {
w.WriteHeader(s.audioStatus)
_, _ = w.Write([]byte(`{"error":{"message":"no speech detected"}}`))
return
}
_, _ = w.Write([]byte(`{"text":"","segments":[],"duration":1.0}`))
case strings.HasSuffix(r.URL.Path, "/chat/completions"):
s.chatCalls++
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"pong"}}]}`))
default:
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"unexpected path"}`))
}
}))
t.Cleanup(s.srv.Close)
return s
}
// formFileName 从 multipart 请求里取出文件部件的文件名。
func formFileName(r *http.Request) string {
mr, err := r.MultipartReader()
if err != nil {
return ""
}
for {
part, err := mr.NextPart()
if err != nil {
return ""
}
if part.FormName() == "file" {
return part.FileName()
}
}
}
// audioRoute 造一条指向桩的语音路由(回环地址 → IsLocalRoute 判为本地)。
func audioRoute(s *audioStub) *config.RouteConfig {
return &config.RouteConfig{
RouteID: "audio_route_stub",
Provider: "local_asr",
Category: "audio",
BaseURL: s.srv.URL,
Endpoint: "/audio/transcriptions",
FullURL: s.srv.URL + "/audio/transcriptions",
Model: "large-v3",
TimeoutSeconds: 30,
}
}
// TestAudioRouteTestHitsTranscriptionsEndpoint 是最重要的一条:
// audio 必须打到转写终点,绝不能落到 chat 的 default 分支。
func TestAudioRouteTestHitsTranscriptionsEndpoint(t *testing.T) {
stub := newAudioStub(t)
res := Do(audioRoute(stub), &TestOptions{})
if stub.chatCalls != 0 {
t.Errorf("语音路由被打了一个 chat 请求(%d 次)—— audio 落到了 default 分支", stub.chatCalls)
}
if stub.audioCalls != 1 {
t.Fatalf("转写终点被打了 %d 次,期望 1 次", stub.audioCalls)
}
if !res.Succeeded || len(res.Items) != 1 || !res.Items[0].Ok {
t.Fatalf("期望测试成功,实际 %+v", res.Items)
}
if !strings.HasPrefix(stub.lastCt, "multipart/form-data") {
t.Errorf("Content-Type = %q,期望 multipart/form-data", stub.lastCt)
}
if stub.lastFormFile != "silence-1s.wav" {
t.Errorf("文件部件名 = %q,期望 silence-1s.wav", stub.lastFormFile)
}
if !strings.Contains(res.Items[0].Summary, "静音") {
t.Errorf("摘要没说明样本是静音,会被读成「转写质量已验证」:%q", res.Items[0].Summary)
}
}
// TestAudioRouteTestUsesConfiguredEndpoint 终点取配置里的 Endpoint。
// 写死 /audio/transcriptions 会让这个可配置字段失效(云端中转路径未必一致)。
func TestAudioRouteTestUsesConfiguredEndpoint(t *testing.T) {
stub := newAudioStub(t)
route := audioRoute(stub)
route.Endpoint = "/v1/custom/transcriptions"
res := Do(route, &TestOptions{})
if !res.Items[0].Ok {
t.Fatalf("按 Endpoint 拼接的请求应当成功:%s", res.Items[0].Error)
}
if stub.lastPath != "/v1/custom/transcriptions" {
t.Errorf("打到了 %q —— 终点没有取自配置的 Endpoint", stub.lastPath)
}
}
// TestAudioRouteTestSendsBearerForLocalRoute 本地服务没有密钥也要带鉴权头。
//
// serve.py 不校验密钥的值,但收不到 Authorization 就回 401。通路测试若跳过这个头,
// 本地 ASR 会稳定测红 —— 而真实转写是通的(transcribe.go 里就写死 "local")。
func TestAudioRouteTestSendsBearerForLocalRoute(t *testing.T) {
stub := newAudioStub(t)
route := audioRoute(stub)
if route.APIKey != "" {
t.Fatal("用例前提:这条路由没有密钥")
}
res := Do(route, &TestOptions{})
if !res.Items[0].Ok {
t.Fatalf("本地无密钥路由应当能测通,实际:%s", res.Items[0].Error)
}
if stub.lastAuth != "Bearer local" {
t.Errorf("Authorization = %q,期望 \"Bearer local\"", stub.lastAuth)
}
}
// TestAudioRouteTestLocalWinsOverProviderName 是「回环地址免密钥」这条规则
// 在通路测试里也生效的证据 —— 也正是三份 requiresAPIKey 副本会走偏的那一格。
//
// 场景:内网有人用 OpenAI 兼容的壳把本地 ASR 包了一层,provider 于是写成 openai,
// base_url 仍是 127.0.0.1。判据若看 provider 名(旧副本就是这么写的),
// 就会报「API Key 未配置」;而真实调用根本不需要密钥。
// 判据应当是「流量去哪」——回环地址就是不需要。
func TestAudioRouteTestLocalWinsOverProviderName(t *testing.T) {
stub := newAudioStub(t)
route := audioRoute(stub)
route.Provider = "openai" // 名字像云端,地址是回环
res := Do(route, &TestOptions{})
if !res.Items[0].Ok {
t.Fatalf("回环地址的路由不该因为 provider 名像云端就被要求密钥:%s", res.Items[0].Error)
}
if stub.audioCalls != 1 {
t.Errorf("转写终点被打了 %d 次,期望 1 次", stub.audioCalls)
}
}
// TestAudioRouteTestTreatsEmptyTextAsSuccess 静音样本转出空文本是**预期**,不是失败。
//
// 判成功只看「200 + 合法 JSON」。把空文本当失败,一条完全正常的本地 whisper
// 路由(静音就返回 text="")会被测红,然后有人去改配置。
func TestAudioRouteTestTreatsEmptyTextAsSuccess(t *testing.T) {
stub := newAudioStub(t)
res := Do(audioRoute(stub), &TestOptions{})
item := res.Items[0]
if !item.Ok {
t.Fatalf("空文本被当成了失败:%s", item.Error)
}
if !strings.Contains(item.OutputPreview, "静音样本") {
t.Errorf("产物预览应说明空文本是静音所致,实际:%q", item.OutputPreview)
}
}
// TestAudioRouteTestExplainsClientRejection 4xx 的报错要说清「可能是静音被拒」。
// 否则读的人只会看到「返回 400」,然后把一条好路由当成坏的。
func TestAudioRouteTestExplainsClientRejection(t *testing.T) {
stub := newAudioStub(t)
stub.audioStatus = http.StatusBadRequest
res := Do(audioRoute(stub), &TestOptions{})
item := res.Items[0]
if item.Ok {
t.Fatal("400 不该算通过")
}
if !strings.Contains(item.Error, "静音") || !strings.Contains(item.Error, "不代表路由不可用") {
t.Errorf("4xx 的报错没解释静音这一层:%q", item.Error)
}
if item.StatusCode != http.StatusBadRequest {
t.Errorf("StatusCode = %d,期望 400", item.StatusCode)
}
}
// TestSilentWAVIsWellFormed WAV 头写错的话服务端多半直接 400,
// 上面那些用例会以「4xx」的形式红,而不会告诉你「样本本身不合法」。
func TestSilentWAVIsWellFormed(t *testing.T) {
const dataBytes = 16000 * 2 // 1 秒 × 16kHz × 16bit
data := silentWAV(1000)
if len(data) != 44+dataBytes {
t.Errorf("长度 = %d,期望 %d(44 字节头 + 1 秒采样)", len(data), 44+dataBytes)
}
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" || string(data[36:40]) != "data" {
t.Fatalf("RIFF/WAVE/data 标识不对:%q %q %q", data[0:4], data[8:12], data[36:40])
}
le := func(off int, n int) uint32 {
var v uint32
for i := 0; i < n; i++ {
v |= uint32(data[off+i]) << (8 * i)
}
return v
}
if got := le(24, 4); got != 16000 {
t.Errorf("采样率 = %d,期望 16000", got)
}
if got := le(22, 2); got != 1 {
t.Errorf("声道数 = %d,期望 1(单声道)", got)
}
if got := le(34, 2); got != 16 {
t.Errorf("位深 = %d,期望 16", got)
}
if got := le(40, 4); got != dataBytes {
t.Errorf("data 块长度 = %d,期望 %d", got, dataBytes)
}
if got := le(4, 4); got != uint32(36+dataBytes) {
t.Errorf("RIFF 长度 = %d,期望 %d", got, 36+dataBytes)
}
// 采样必须全零,否则「静音样本」这个名字就是假的。
for i, b := range data[44:] {
if b != 0 {
t.Fatalf("第 %d 个采样字节非零 —— 这不是静音样本", i)
}
}
}
@@ -11,9 +11,11 @@ package routetest
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"sort"
"strings"
@@ -114,7 +116,10 @@ func testOne(route *config.RouteConfig, model string, prompt, size string) Resul
item.Error = "base_url 未配置"
return item
}
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
// 判据与健康探测、真实调用共用 config 的那一份:本包原先自己抄了一遍。
// 两份在今天的配置上恰好同结论,但只有 config 那份知道「回环地址免密钥」;
// 抄出来的第二份一旦漏跟,症状是「通路测试说缺密钥,真实调用却是通的」。
if config.RequiresRouteAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
item.Error = "API Key 未配置"
return item
}
@@ -127,6 +132,11 @@ func testOne(route *config.RouteConfig, model string, prompt, size string) Resul
testVideo(route, model, prompt, &item)
case "embed":
testEmbed(route, model, prompt, &item)
case "audio":
// 必须单独一支:audio 的终点是 /audio/transcriptions、请求体是 multipart,
// 落到 default 会把一个 chat 请求打到 ASR 服务上,报回来的错
// (模型不存在 / 参数不对)与真实原因(走错接口)毫无关系。
testAudio(route, model, prompt, &item)
default:
testChat(route, model, prompt, &item)
}
@@ -374,6 +384,169 @@ func testVideo(route *config.RouteConfig, model, prompt string, item *ResultItem
item.Error = "响应缺少 url / b64_json"
}
// testAudio 语音转写类单模型测试。
//
// 拿一段**程序生成的静音 WAV** 去打真实的 /audio/transcriptions,验的是
// 「这条路走不走得通」:终点对不对、密钥收不收、multipart 字段名对不对、
// 返回的是不是转写那套结构。**不验转写质量** —— 静音样本本来就没什么可转的,
// 把「没转出字」当失败会让一条好路由被测红。
//
// 所以判成功的标准只有一条:HTTP 200 且响应是合法 JSON。摘要里会写明
// 「静音样本」,避免被读成「转写质量已验证」。
//
// 静音是现算的,不内嵌音频文件:P06.11 不允许引入第三方受限素材,
// 而原型机的真实录音属于用户数据,更不能编进二进制。
func testAudio(route *config.RouteConfig, model, prompt string, item *ResultItem) {
// audio 的终点在配置里(route.Endpoint),不像 chat/embed/image 是固定路径。
endpoint := strings.TrimSpace(route.Endpoint)
if endpoint == "" {
endpoint = "/audio/transcriptions"
}
sample := silentWAV(1000)
item.RawRequest = fmt.Sprintf(
`{"endpoint":%q,"model":%q,"file":"silence-1s.wav","bytes":%d,"note":"程序生成的静音样本,仅验证接口连通性"}`,
endpoint, model, len(sample))
var out struct {
Text string `json:"text"`
Segments []struct {
Speaker string `json:"speaker"`
Text string `json:"text"`
} `json:"segments"`
Duration float64 `json:"duration"`
Model string `json:"model"`
Error any `json:"error"`
}
code, rawRes, err := httpPostFile(route, endpoint, "silence-1s.wav", sample, map[string]string{
"model": model,
}, &out)
item.StatusCode = code
item.RawResponse = truncate(rawRes, 4000)
if err != nil {
// 4xx 要单独说清楚:静音被拒是这一类接口的常见反应,不能让人以为
// 路由坏了。真正的转写能力得用一份真实录音走一次语音转写技能才算数。
if code >= 400 && code < 500 {
item.Error = fmt.Sprintf("%v(样本是 1 秒静音,若服务方拒收无语音音频,此结果不代表路由不可用;"+
"请用一份真实录音跑一次「语音转写」技能确认)", err)
return
}
item.Error = err.Error()
return
}
item.ModelEcho = firstNonEmpty(out.Model, model)
item.Summary = fmt.Sprintf("转写接口应答正常(1 秒静音样本,返回 %d 段,未检验转写质量)", len(out.Segments))
if strings.TrimSpace(out.Text) != "" {
item.OutputPreview = truncate(out.Text, 2000)
} else {
item.OutputPreview = "(静音样本,文本为空属预期)"
}
}
// httpPostFile 发一次 multipart/form-data 的 POST。
//
// 鉴权头与真实转写**逐字对齐**:没有密钥时也要发 `Bearer local`。
// 本地 serve.py 不校验密钥的值,但收不到这个头就回 401 —— 这里若图省事跳过,
// 本地 ASR 在通路测试里会稳定报 401,而真实转写明明是通的。
func httpPostFile(route *config.RouteConfig, path, filename string, content []byte, fields map[string]string, out any) (int, string, error) {
buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf)
for _, key := range sortedKeys(fields) {
fw, err := w.CreateFormField(key)
if err != nil {
return 0, "", fmt.Errorf("构建请求失败: %w", err)
}
if _, err := fw.Write([]byte(fields[key])); err != nil {
return 0, "", fmt.Errorf("构建请求失败: %w", err)
}
}
fw, err := w.CreateFormFile("file", filename)
if err != nil {
return 0, "", fmt.Errorf("构建请求失败: %w", err)
}
if _, err := fw.Write(content); err != nil {
return 0, "", fmt.Errorf("写入样本失败: %w", err)
}
if err := w.Close(); err != nil {
return 0, "", fmt.Errorf("构建请求失败: %w", err)
}
fullURL := strings.TrimRight(route.BaseURL, "/") + path
req, err := http.NewRequest(http.MethodPost, fullURL, buf)
if err != nil {
return 0, "", fmt.Errorf("构造请求失败: %w", err)
}
req.Header.Set("Content-Type", w.FormDataContentType())
authKey := route.APIKey
if strings.TrimSpace(authKey) == "" {
authKey = "local"
}
req.Header.Set("Authorization", "Bearer "+authKey)
client := &http.Client{Timeout: defaultTestTimeout}
resp, err := client.Do(req)
if err != nil {
return 0, "", fmt.Errorf("服务不可达: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, "", fmt.Errorf("读取响应失败: %w", err)
}
rawStr := string(data)
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, rawStr, fmt.Errorf("返回 %d: %s", resp.StatusCode, truncate(rawStr, 200))
}
if len(data) > 0 && out != nil {
if err := json.Unmarshal(data, out); err != nil {
return resp.StatusCode, rawStr, fmt.Errorf("响应解析失败: %w", err)
}
}
return resp.StatusCode, rawStr, nil
}
// silentWAV 生成一段 ms 毫秒的 16kHz 单声道 16bit 静音 WAV。
// 标准 44 字节 RIFF 头 + 全零采样,任何 ASR 服务都认这个容器格式。
func silentWAV(ms int) []byte {
const sampleRate = 16000
samples := sampleRate * ms / 1000
dataSize := samples * 2
var buf bytes.Buffer
write := func(v any) { _ = binary.Write(&buf, binary.LittleEndian, v) }
buf.WriteString("RIFF")
write(uint32(36 + dataSize))
buf.WriteString("WAVEfmt ")
write(uint32(16)) // fmt chunk 长度
write(uint16(1)) // PCM
write(uint16(1)) // 单声道
write(uint32(sampleRate)) // 采样率
write(uint32(sampleRate * 2)) // 字节率 = 采样率 × 声道 × 位深/8
write(uint16(2)) // 块对齐 = 声道 × 位深/8
write(uint16(16)) // 位深
buf.WriteString("data")
write(uint32(dataSize))
buf.Write(make([]byte, dataSize))
return buf.Bytes()
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// DiscoverModels 拉取 provider 端 /models 获取可用模型列表(自动发现)。
// 返回按字母排序的模型 ID 列表;不可达或失败时返回 nil + error。
func DiscoverModels(route *config.RouteConfig) ([]string, error) {
@@ -404,20 +577,6 @@ func MergeCandidates(known, discovered []string) []string {
return out
}
// requiresAPIKey 判断该路由是否必须携带 API Key(只有云端 provider 才强制)。
// 本地服务(ollama / llamacpp 等)无需 key 也应能完成测试。
func requiresAPIKey(route *config.RouteConfig) bool {
if route == nil {
return false
}
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true
}
provider := strings.ToLower(strings.TrimSpace(route.Provider))
return provider == "openrouter" || provider == "openai"
}
func truncate(s string, n int) string {
s = strings.TrimSpace(s)
if n <= 0 || len(s) <= n {