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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
package skillapi
import (
"os"
"path/filepath"
"testing"
"eai_agentplatform/backend/internal/config"
)
// TestMain 定位 backend-go 并切换工作目录,让 config/ai_config.json 在测试进程内
// 可被找到(LoadAIConfig 的相对路径回退依赖 CWD)。
func TestMain(m *testing.M) {
if base := locateBackendGoForTest(); base != "" {
_ = os.Chdir(base)
}
os.Exit(m.Run())
}
// locateBackendGoForTest 从当前工作目录向上找含 config/ai_config.json 的目录。
func locateBackendGoForTest() string {
dir, err := os.Getwd()
if err != nil {
return ""
}
for {
if _, err := os.Stat(filepath.Join(dir, "config", "ai_config.json")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
// TestAudioLLMRouteIsConfiguredWithEnoughBudget 钉住「第 3、4 步默认那条路由的
// 预算是够的」。
//
// 这是整套改动的落点。转写功能原先跑不通,根因既不是提示词也不是模型名,而是这类
// 「要产出一整篇文档」的步骤被挂在了给聊天用的 4096 预算上:这些是推理模型,思考与
// 正文共用 max_tokens,而**思考的长度跟输入几乎不成比例**(实测 1200 字的输入,
// 思考照样写到 7169 字)。4096 下正文一个字都写不出来(六次实测只成一次),
// 8192 下四次全成。
//
// 这一行配置很容易被当成冗余删掉:agent_routes 里少一行、或把那条 8192 的路由调回
// 4096,功能都会**悄悄**退回「写不出来」——GetRoute 对认不出的名字会静默回退
// default_route,Go 侧一个错都不报,只有拿到空产物的人才知道出了事。
func TestAudioLLMRouteIsConfiguredWithEnoughBudget(t *testing.T) {
if err := ensureAudioLLMAgentRouteConfigured(); err != nil {
t.Fatalf("第 3、4 步的默认路由没配好:%v", err)
}
aiCfg, err := config.LoadAIConfig()
if err != nil {
t.Fatalf("加载 ai_config.json 失败:%v", err)
}
target := aiCfg.AgentRoutes[audioLLMAgentRoute]
info := aiCfg.ChatRoutes[target]
// 16384 不是个好看的整数,是实测出来的下限,而且是由**归并**那一步定的:
// 逐块加工 1200 字在 8192 下实测 11/11 正常,但纪要步骤最后要把各段结果归并成
// 一份,那是最长的一次输入(实测 8628 字)——8192 下 finish_reason=length、
// 正文 0 字(思考写了 14678 字),16384 才出稿,32768 又快又稳。
// 逐块那一步其实用不到这么高,但上限是天花板不是配额:实测 8192 与 32768 两档下
// 同一批 11 段的用时与 completion token 基本一致,抬高的余量是白拿的。
const wantMaxTokens = 16384
if info.MaxTokens < wantMaxTokens {
t.Errorf("路由 %s 的 max_tokens=%d,低于实测下限 %d:整理逐字稿会写出空稿或半截稿,"+
"长稿更会卡在归并那一步上(归并要产出一整篇纪要,预算需求比逐块加工更高)。"+
"这个值由实测倒推(见 audio_transcribe/prompts.go 的注释),调小之前请先复测",
target, info.MaxTokens, wantMaxTokens)
}
t.Logf("第 3、4 步默认路由:%s(max_tokens=%d,model=%s)", target, info.MaxTokens, info.Model)
}
// TestResolveAudioChatRouteUsesSkillRouteWhenNotChosen 钉住「用户在界面上没选模型时,
// 第 3、4 步用的是技能自带那条大预算路由」。
//
// 上面那条只验了**配置写对**,写对了不等于**用上了**:resolveAudioChatRoute 里少一步、
// 或把 override 的处理写反,都会静默退回 default_route(4096)—— 而 Go 侧一个错都不报,
// 症状要到某一段随机写不出正文时才显形,正是这次要根除的那种「悄悄坏掉」。
func TestResolveAudioChatRouteUsesSkillRouteWhenNotChosen(t *testing.T) {
// 界面没选模型时传下来的就是空串(见 audioStepReq.AiRouteID);带空白的串一并覆盖,
// 因为「空」是靠 TrimSpace 判的,只测纯空串会漏掉另一种写法。
for _, override := range []string{"", " "} {
route, err := resolveAudioChatRoute(override)
if err != nil {
t.Fatalf("override=%q 时应解析出技能自带路由,实际报错:%v", override, err)
}
if route.Category != "chat" {
t.Errorf("override=%q 解析到 %s,分类是 %q,期望 chat", override, route.RouteID, route.Category)
}
if route.MaxTokens < 16384 {
t.Errorf("override=%q 解析到 %s(max_tokens=%d),低于实测下限 16384:"+
"这个预算写不完整理稿,长稿还会卡在归并那一步上",
override, route.RouteID, route.MaxTokens)
}
t.Logf("override=%q → %s(max_tokens=%d)", override, route.RouteID, route.MaxTokens)
}
}
@@ -0,0 +1,278 @@
package skillapi
import (
"fmt"
"path/filepath"
"strings"
"testing"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/model"
audiotranscribe "eai_agentplatform/backend/internal/skills/packages/audio_transcribe"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
"eai_agentplatform/backend/internal/store"
)
// 这一组用例钉的是「说话人身份没被用户确认过,下游两步不许跑」这道闸门。
//
// 为什么值得单独写:这道闸门的失效**不会报任何错**。它一旦不生效,第 5、6 步照跑,
// 纪要里就会出现「张局说……李处说……」这种看着完全正常的句子 —— 而稿子里其实
// 只有「说话人 0」和「说话人 1」,那些身份是模型猜的。用户不会在一份通顺的纪要里
// 发现自己被安了个假名字,这正是它比缺一个名字有害得多的地方。
// setupSpeakerGateDB 起一个进程内的临时库给本文件用。
//
// 只换 store.DB:这几个函数读的产物全部经 latestArtifact 走 store.DB,
// 不碰 dal 的零值仓库(与 internal/api 那套 fixture 需要的 dal.SetDB 不是一回事)。
func setupSpeakerGateDB(t *testing.T) {
t.Helper()
prev := store.DB
db, err := store.Init(filepath.Join(t.TempDir(), "speaker_gate.db"))
if err != nil {
t.Fatalf("初始化测试库失败:%v", err)
}
store.DB = db
t.Cleanup(func() { store.DB = prev })
}
// newSpeakerGateTask 建一个任务并返回它。
func newSpeakerGateTask(t *testing.T) model.TaskRecord {
t.Helper()
task := model.TaskRecord{
SpecialistKey: "general-assistant",
Title: "说话人闸门用例",
Status: specialistruntime.TaskStatusDraft,
}
if err := store.DB.Create(&task).Error; err != nil {
t.Fatalf("创建任务失败:%v", err)
}
return task
}
// writeSpeakerArtifact 往库里塞一条「说话人名单」产物,状态由调用方指定。
func writeSpeakerArtifact(t *testing.T, taskID uint, status, contentJSON string) model.TaskArtifact {
t.Helper()
artifact := model.TaskArtifact{
TaskID: taskID,
SpecialistKey: "general-assistant",
Title: "说话人名单",
ArtifactType: audioSpeakerArtifactType,
Status: status,
ContentText: "## 说话人名单\n",
ContentJSON: contentJSON,
}
if err := store.DB.Create(&artifact).Error; err != nil {
t.Fatalf("创建产物失败:%v", err)
}
return artifact
}
// requireDiarizingAudioRoute 确认当前音频路由真的输出说话人标签。
//
// 不 Skip 而 Fatal,是因为「路由不输出了」正是这个功能整体失效的方式:
// 换一条没有说话人分离的 ASR(比如 Qwen3-ASR),第 3 步会硬失败、闸门会直接放行,
// 于是这几个用例会全部变绿而什么都没测。那种绿比红危险得多。
// 读**声明路由**(default_audio_route),与 audioRouteSupportsSpeakers 用同一个
// 来源。不能读 GetAudioRoute("audio_transcribe"):那条现在指向 auto,会按本机
// ASR 此刻起没起挑路由,于是这套用例的成败取决于跑测试的机器上 8090 有没有在监听
// —— 那是一种「绿得毫无意义、红得莫名其妙」的测试。声明值才是与机器状态无关的事实。
func requireDiarizingAudioRoute(t *testing.T) {
t.Helper()
route, err := config.GetDeclaredAudioRoute()
if err != nil {
t.Fatalf("音频路由不可用:%v", err)
}
if !route.SupportsSpeakers {
t.Fatalf("当前音频路由 %s 的 supports_speakers=false:"+
"「识别说话人身份」这一步跑不起来,本文件所有断言都会退化成空断言。"+
"若确实要换掉带说话人分离的路由,请连同这套确认流程一起重新评估", route.RouteID)
}
}
// rosterJSON 造一份「说话人名单」产物的 ContentJSON(形状与 runAudioSpeakersStep 写的一致)。
func rosterJSON(roster ...audiotranscribe.SpeakerIdentity) string {
parts := make([]string, 0, len(roster))
for _, item := range roster {
parts = append(parts, fmt.Sprintf(
`{"key":%q,"org":%q,"title":%q,"name":%q,"evidence":%q}`,
item.Key, item.Org, item.Title, item.Name, item.Evidence))
}
return fmt.Sprintf(`{"source_type":"transcript","speakers":[%s]}`, strings.Join(parts, ","))
}
func TestAudioSpeakersGateBlocksWithoutConfirmation(t *testing.T) {
setupSpeakerGateDB(t)
requireDiarizingAudioRoute(t)
task := newSpeakerGateTask(t)
// 1) 连名单都还没有 —— 这一步没跑过,下游不能跑。
// (走不到这里的正常路径:人在第 3 步就停住了;但直接把请求打到第 5 步就能绕过前端。)
if err := ensureAudioSpeakersConfirmed(task.ID); err == nil {
t.Error("名单都还没生成,闸门却放行了 —— 直接把请求打到 /structure 就能绕过确认")
} else if !strings.Contains(err.Error(), "说话人名单") {
t.Errorf("报错没点明缺的是哪份产物:%v", err)
}
// 2) 名单有了,但还是待确认 —— 这正是「AI 已经猜了、人还没看」的那一刻,
// 也是这道闸门存在的全部理由。
writeSpeakerArtifact(t, task.ID, specialistruntime.ArtifactStatusReady, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "0", Org: "某某局", Title: "处长", Name: "张三"},
))
err := ensureAudioSpeakersConfirmed(task.ID)
if err == nil {
t.Fatal("名单还是「待确认」,闸门却放行了 —— 未确认的身份会直接写进正式纪要")
}
if !strings.Contains(err.Error(), "确认") {
t.Errorf("报错没有告诉用户该做什么:%v", err)
}
// 3) 确认过 —— 放行。
if err := store.DB.Model(&model.TaskArtifact{}).
Where("task_id = ? AND artifact_type = ?", task.ID, audioSpeakerArtifactType).
Update("status", specialistruntime.ArtifactStatusApproved).Error; err != nil {
t.Fatalf("更新产物状态失败:%v", err)
}
if err := ensureAudioSpeakersConfirmed(task.ID); err != nil {
t.Fatalf("已确认的名单仍被闸门拦住:%v", err)
}
}
// TestAudioSpeakersGateReopensWhenReinferred 钉住「重新识别一次,旧的确认作废」。
//
// 不钉的话会漏掉这条路径:用户确认了 → 觉得不对 → 重跑第 3 步(新产物落 ready)
// → 闸门如果只看「历史上有没有确认过」,就会拿**上一版**的身份去写新稿子。
func TestAudioSpeakersGateReopensWhenReinferred(t *testing.T) {
setupSpeakerGateDB(t)
requireDiarizingAudioRoute(t)
task := newSpeakerGateTask(t)
writeSpeakerArtifact(t, task.ID, specialistruntime.ArtifactStatusApproved, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "0", Name: "张三"},
))
if err := ensureAudioSpeakersConfirmed(task.ID); err != nil {
t.Fatalf("已确认时不该拦:%v", err)
}
// 重跑一次第 3 步:新产物同样是「说话人名单」,落 ready。
writeSpeakerArtifact(t, task.ID, specialistruntime.ArtifactStatusReady, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "0", Name: "李四"},
))
if err := ensureAudioSpeakersConfirmed(task.ID); err == nil {
t.Error("重新识别出的一版还没确认,闸门却拿上一版的确认放行了")
}
}
// TestAudioSpeakersGatePassesThroughWhenRouteHasNoDiarization 钉住闸门的唯一放行口。
//
// 换一条不输出说话人分离的 ASR(如 Qwen3-ASR)时,「识别说话人身份」这一步
// 根本没有对象可做,闸门必须直接放行。这一支写反的后果不是少一道校验,
// 而是**所有这类任务永久卡死**:第 3 步跑不出名单 → 闸门说没有名单 → 不许跑第 5 步,
// 而用户无论如何也变不出那份名单。没有别的路可以绕过去。
//
// 断言的是「一个名单都没有的情况下也放行」:若改成先查名单,这条会红。
func TestAudioSpeakersGatePassesThroughWhenRouteHasNoDiarization(t *testing.T) {
setupSpeakerGateDB(t)
task := newSpeakerGateTask(t)
// 换上「这条路由不输出说话人标签」。测试替身必须在断言之前换好,
// 换晚了等于没换(测的还是线上那条真路由)。
original := audioRouteSupportsSpeakers
audioRouteSupportsSpeakers = func() (bool, error) { return false, nil }
t.Cleanup(func() { audioRouteSupportsSpeakers = original })
// 刻意不写任何产物:没有名单、没有确认,什么都不该拦。
if err := ensureAudioSpeakersConfirmed(task.ID); err != nil {
t.Fatalf("音频路由不输出说话人分离时,闸门仍把后续步骤拦住了(这类任务将永远卡死):%v", err)
}
}
// TestAudioSpeakersGateFailsClosedWhenRouteUnavailable 钉住「路由读不出来时不放行」。
//
// 与上一条相反的方向:读不到路由配置(ai_config.json 被改坏、audio_routes 缺项)
// 时**不能**当作「不支持说话人分离」而放行。放行等于跳过确认,正是这道闸门
// 要防的事;而报错会让用户看见问题在哪。
func TestAudioSpeakersGateFailsClosedWhenRouteUnavailable(t *testing.T) {
setupSpeakerGateDB(t)
task := newSpeakerGateTask(t)
original := audioRouteSupportsSpeakers
audioRouteSupportsSpeakers = func() (bool, error) {
return false, fmt.Errorf("音频路由 %q 未在 ai_config.json 的 audio_routes 中定义", "x")
}
t.Cleanup(func() { audioRouteSupportsSpeakers = original })
err := ensureAudioSpeakersConfirmed(task.ID)
if err == nil {
t.Fatal("路由读不出来时闸门放行了 —— 配置坏掉会静默跳过说话人确认")
}
if !strings.Contains(err.Error(), "audio_routes") {
t.Errorf("报错没有把真实原因(路由配置)带出来:%v", err)
}
}
// TestApplyAudioSpeakerNamesUsesConfirmedRoster 钉住「确认过的身份真的写进了正文」。
//
// 闸门只解决「不许跑」,这个函数解决「跑起来之后用的是不是那份确认值」。
// 少了它,闸门放行、模型照样只看见「说话人 0」——功能看着做完了,纪要里还是序号。
func TestApplyAudioSpeakerNamesUsesConfirmedRoster(t *testing.T) {
setupSpeakerGateDB(t)
task := newSpeakerGateTask(t)
source := "说话人 1:这个方案我同意。\n说话人 10:我也同意。\n说话人 2:那就这么定。"
// 没确认过:一个字都不许动。
if got := applyAudioSpeakerNames(task.ID, source); got != source {
t.Errorf("名单还没确认,正文却被改了:\n%s", got)
}
writeSpeakerArtifact(t, task.ID, specialistruntime.ArtifactStatusApproved, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "1", Org: "某某局", Title: "处长", Name: "张三"},
audiotranscribe.SpeakerIdentity{Key: "10", Name: "李四"},
// 三个字段全空:稿里没有依据。该保持原样,不许塞占位符假装认出来了。
audiotranscribe.SpeakerIdentity{Key: "2"},
))
got := applyAudioSpeakerNames(task.ID, source)
if !strings.Contains(got, "张三:这个方案我同意。") {
t.Errorf("确认过的姓名没有写进正文:\n%s", got)
}
// 「说话人 1」是「说话人 10」的前缀。按名单原顺序替换会把这一行改成「李四0」,
// 所以这里同时钉住替换顺序。
if !strings.Contains(got, "李四:我也同意。") {
t.Errorf("「说话人 10」被「说话人 1」的替换破坏了:\n%s", got)
}
if strings.Contains(got, "李四0") {
t.Errorf("出现「李四0」:替换没有按标签长度从长到短做:\n%s", got)
}
// 没有依据的那位保持原样,而不是变成空或占位符。
if !strings.Contains(got, "说话人 2:那就这么定。") {
t.Errorf("没有依据的说话人被动过了(应保持原样):\n%s", got)
}
}
// TestApplyAudioSpeakerNamesFallsBackToOrgTitle 钉住「没有姓名时用机构·头衔」。
//
// 这不是锦上添花:一次会议里真正有用的是「谁在说话」,而「某某局·处长」在纪要里
// 已经比「说话人 0」有用得多。而三者全空时必须**跳过**,不许硬塞占位符 ——
// 那会让一份没认出来的稿子看起来像认出来了。
func TestApplyAudioSpeakerNamesFallsBackToOrgTitle(t *testing.T) {
setupSpeakerGateDB(t)
task := newSpeakerGateTask(t)
writeSpeakerArtifact(t, task.ID, specialistruntime.ArtifactStatusApproved, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "0", Org: "某某局", Title: "处长"},
audiotranscribe.SpeakerIdentity{Key: "1", Title: "记录员"},
audiotranscribe.SpeakerIdentity{Key: "2"},
))
got := applyAudioSpeakerNames(task.ID, "说话人 0:开场。\n说话人 1:在。\n说话人 2:嗯。")
if !strings.Contains(got, "某某局·处长:开场。") {
t.Errorf("只有机构与头衔时没有拼成「机构·头衔」:\n%s", got)
}
if !strings.Contains(got, "记录员:在。") {
t.Errorf("只有头衔时没有用它:\n%s", got)
}
if !strings.Contains(got, "说话人 2:嗯。") {
t.Errorf("三者全空的那位被改动了(应跳过,保持原样):\n%s", got)
}
}