feat(asr): 降级兜底——分离路由全挂时退到无分离路由,只交付逐字稿

回退链从「一条主路由 + 一串替补」改成两级:先把同能力(有说话人分离)的路由
试完,全挂才降级到不分离的路由。降级是**本次事实**而不是配置事实,写进第 2 步
产物(capability_degraded / capability_zh),第 3 步与第 5/6 步据此判为不可用。

- transcribe.go:Result 加 CapabilityDegraded,判据 chain[0] 能分离而实际这条
  不能;与 HasSpeakers 分开记(后者可能是「配了却没输出」那种异常)
- audio_handlers.go:闸门从「读路由声明的能力」改成「读稿子里实际有没有标签」
  (audioTranscriptSpeakerKeys),与第 3 步共用同一句 SpeakerKeysOf;本次没分离
  → 哨兵错误 errAudioSpeakersUnavailableThisRun,不再放行去写一份看不出残缺的纪要
- 闸门**不看** capability_degraded:改动前落库的老产物没有这个字段,看它就 fail-open
- 第 1 步「转写要求」提前把降级的后果说清;第 2 步产物带完整措辞与「⚠」日志
- 前端两处(SpecialistPanel.vue / audioSkill.js)判断顺序改为先读实际结果
  has_speakers、再退回声明 speakers —— 顺序反了会在降级那一次照旧显示第 3 步
- ai_config.json:补 3 条云端无分离路由与各自的回退链

验证:三处变异(产物 key 拼错、标记写死 true、闸门 fail-closed)都验过会红;
新增两个用例文件走真实 gin 路由 + 真实鉴权中间件,断言拦下来的**理由**而不只是
「拦下来了」;go test ./... 全绿、gofmt 干净、前端构建通过。

同期把本地 ASR 装成 systemd 常驻服务(deploy/install_asr_local.sh 七步全过,
开机自启,实测 26.7 分钟录音 → 3.1 分钟)。装的过程挖出两个只在服务化时才暴露的坑:

- E12 转写堵住事件循环 → 探活超时 → 本地被判不健康 → auto 静默退云端、音频出网,
  全程没有任何报错。修法 run_in_threadpool(deploy/asr/serve.py)
- E13 服务账号的 ~ 不可写,pyannote 写不了 ~/.pyannote/database.yml,每次转写 500。
  修法 asr.env 加 HOME=<cache 目录>(该目录在 unit 的 ReadWritePaths 里)

顺带收口一处交付缺口:服务源码原先只有 ~/asr-poc 一份,而 DELIVERY.md 的清理计划
要 rm -rf 它 —— 那会让唯一副本变成 /opt 下 root 所有、不在任何版本库里的文件。
现在 deploy/asr/ 是唯一事实源,装机脚本与文档同步改。

已知偏离 / 未做(记在案):
- 界面那句「本次没有说话人分离,后续步骤不可用」只验到后端接口层,没有造出真实
  降级场景渲染出来看过
- deploy/asr/ 的引入改变了装机来源:原型目录 $SRC_DIR 从此只提供 venv 与模型,
  服务代码一律从仓库取

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-26 23:36:00 +08:00
co-authored by Claude Code
parent bc30bfce28
commit b4c8ea38d1
19 changed files with 2002 additions and 151 deletions
@@ -0,0 +1,121 @@
package skillapi
import (
"encoding/json"
"strings"
"testing"
"eai_agentplatform/backend/internal/model"
audiotranscribe "eai_agentplatform/backend/internal/skills/packages/audio_transcribe"
)
// 这一组用例钉的是「降级」这件事在**产物里的往返**:第 2 步写进去的字段,
// 必须原样被下游读出来。
//
// 为什么值得单独写:这条链上全是**字符串 key**,没有一处会被编译器检查。
// 把 capability_degraded 拼错一个字母,Go 照样编译,`go test` 照样全绿,
// 而结果是降级提示不再出现、闸门也不再判「本次没有说话人分离」——
// 用户拿到一份看不出残缺的产物,依赖说话人身份的几步却已经在后台跑不了了。
//
// 手写 JSON 的用例(audio_speakers_gate_test.go)盖不到这一段:它们自己造
// ContentJSON,验的是读的一侧。这里验的是写的一侧,以及写读之间对得上。
// degradedResult 造一个「主路由能分离、实际跑的这条不能」的转写结果。
func degradedResult() *audiotranscribe.Result {
return &audiotranscribe.Result{
Text: "测试转写内容",
Language: "zh",
Duration: 12.5,
// 无分离 ASR 的输出:segment 在,speaker 是空的。
Segments: []audiotranscribe.Segment{{Speaker: "", Start: 0, End: 12.5, Text: "测试转写内容"}},
HasSpeakers: false,
Model: "XingChenAGI/XingChenASR-V3.2-Ultra",
RouteID: "audio_route_siliconflow_asr_ultra",
IsLocal: false,
FellBack: true,
// 关键:本该用的那条能分离,实际这条不能。
PrimaryRouteID: "audio_route_local_whisper",
CapabilityDegraded: true,
}
}
// TestTranscribeArtifactCarriesCapabilityDegraded 产物里必须带着降级的两个字段。
func TestTranscribeArtifactCarriesCapabilityDegraded(t *testing.T) {
output := transcribeArtifactOutput(
degradedResult(), model.MediaFile{ID: 1, Filename: "a.mp3"}, "转写正文", "zh")
if got, ok := output["capability_degraded"].(bool); !ok || !got {
t.Errorf("产物里 capability_degraded = %#v,期望 true —— "+
"界面靠它显示「本次没有说话人分离,后续步骤不可用」", output["capability_degraded"])
}
zh, _ := output["capability_zh"].(string)
if !strings.Contains(zh, "后续步骤不可用") {
t.Errorf("capability_zh 没有说清后果:%q", zh)
}
if !strings.Contains(zh, "逐字稿") {
t.Errorf("capability_zh 没有告诉用户逐字稿还在(那是这次唯一拿得到的东西):%q", zh)
}
}
// TestTranscribeArtifactNotDegradedByDefault 没降级时不许留下这句话。
//
// 反向断言不能省:一个永远返回 true 的字段会让每一条正常转写都顶着
// 「本次没有说话人分离」的红字,闸门也会把所有任务拦在第 5 步。
func TestTranscribeArtifactNotDegradedByDefault(t *testing.T) {
result := degradedResult()
result.CapabilityDegraded = false
result.HasSpeakers = true
result.Segments[0].Speaker = "0"
output := transcribeArtifactOutput(
result, model.MediaFile{ID: 1, Filename: "a.mp3"}, "转写正文", "zh")
if got, _ := output["capability_degraded"].(bool); got {
t.Error("没降级却标了 capability_degraded —— 正常转写会被当成缺了说话人分离")
}
if zh, _ := output["capability_zh"].(string); zh != "" {
t.Errorf("没降级时 capability_zh 该是空串,实际 %q", zh)
}
}
// TestTranscribeArtifactOutputRoundTripsToGate 是这一组里最关键的一条:
// 把产物**序列化成 JSON**(persistAudioStep 就是这么落库的)再交给下游那两个
// 读它的函数,必须得出「本次没有说话人分离」。
//
// 直接调写和读的函数而不比字段名是刻意的:要比的正是「写进去的 key」
// 与「读出来的 key」是同一个 —— 两边各写一遍字面量,正是这类 bug 的温床。
func TestTranscribeArtifactOutputRoundTripsToGate(t *testing.T) {
output := transcribeArtifactOutput(
degradedResult(), model.MediaFile{ID: 1, Filename: "a.mp3"}, "转写正文", "zh")
raw, err := json.Marshal(output)
if err != nil {
t.Fatalf("产物序列化失败:%v", err)
}
contentJSON := string(raw)
if !audioTranscriptDegraded(contentJSON) {
t.Error("产物里写着降级,落库后再读却读不出降级 —— " +
"第 3 步会把「本次没有说话人分离」误报成「配了分离却没输出」")
}
if keys := audiotranscribe.SpeakerKeysOf(contentJSON); len(keys) != 0 {
t.Errorf("降级产物理应读不出说话人标签,实际读到 %v", keys)
}
// 有标签的那一次走同一个往返,确保上面不是「永远读不出标签」的假绿。
kept := degradedResult()
kept.CapabilityDegraded = false
kept.HasSpeakers = true
kept.Segments[0].Speaker = "0"
raw2, err := json.Marshal(transcribeArtifactOutput(
kept, model.MediaFile{ID: 1, Filename: "a.mp3"}, "转写正文", "zh"))
if err != nil {
t.Fatalf("产物序列化失败:%v", err)
}
if audioTranscriptDegraded(string(raw2)) {
t.Error("没降级的产物被读成了降级")
}
if keys := audiotranscribe.SpeakerKeysOf(string(raw2)); len(keys) != 1 || keys[0] != "0" {
t.Errorf("有标签的产物读出的说话人是 %v,期望 [\"0\"]", keys)
}
}
@@ -2,6 +2,7 @@ package skillapi
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
@@ -47,7 +48,7 @@ var audioStepSpecs = map[string]audioStepSpec{
Step: "transcribe",
ActionTitle: "生成逐字转写文本",
ArtifactTitle: "逐字转写稿",
ArtifactType: "transcript",
ArtifactType: audioTranscriptArtifactType,
},
"speakers": {
Step: "speakers",
@@ -254,9 +255,16 @@ func runAudioScopeStep(user *model.User, req audioStepReq) (*audioStepOutcome, e
if language != "" {
languageText = audioLanguageLabel(language)
}
// 说「区分」时要把降级的可能一并说掉:可分离的路由都不成时,回退链会退到
// 一条不分离的(config.GetFallbackAudioRoutes 的两级排序),届时依赖说话人
// 身份的后续步骤不可用。第 1 步是用户唯一能**事先**看到这个后果的地方。
speakerText := "不区分(当前音频路由的模型不输出说话人)"
if route.SupportsSpeakers {
speakerText = "区分(当前音频路由的模型输出说话人标签)"
if config.AudioFallbackDegradesSpeakers(route.RouteID) {
speakerText += ";该路由不可用时会依次回退,最后可能退到一条不区分说话人的路由," +
"那种情况下后续依赖说话人身份的步骤不可用"
}
}
lines := []string{
@@ -361,20 +369,7 @@ func runAudioTranscribeStep(user *model.User, req audioStepReq) (*audioStepOutco
"media_id": media.ID,
"language": language,
},
Output: gin.H{
"media_id": media.ID,
"file_name": media.Filename,
"text": result.Text,
"transcript": transcript,
"language": result.Language,
"duration": result.Duration,
"segments": result.Segments,
"has_speakers": result.HasSpeakers,
"model": result.Model,
"route_id": result.RouteID,
"is_local": result.IsLocal,
"fell_back": result.FellBack,
},
Output: transcribeArtifactOutput(result, media, transcript, language),
Logs: transcribeLogs(result, media, durationText),
SourceRefs: []gin.H{{"type": "upload", "title": media.Filename, "media_id": media.ID}},
Extra: gin.H{
@@ -394,6 +389,10 @@ func runAudioTranscribeStep(user *model.User, req audioStepReq) (*audioStepOutco
// fallback_reason 是原始错误串,留作排障,不往用户脸上摆。
"fell_back_zh": audioFallbackLabel(result),
"fallback_reason": result.FallbackReason,
// 降级与回退是两件事,界面上要分开说:回退是「换了条路」,
// 降级是「换的那条路少了一样能力」。可分离路由都没跑成时才降级。
"capability_degraded": result.CapabilityDegraded,
"capability_zh": audioCapabilityLabel(result),
},
}, nil
}
@@ -441,6 +440,54 @@ func audioFallbackLabel(result *audiotranscribe.Result) string {
audioRouteDisplayName(result.PrimaryRouteID), audioRouteDisplayName(result.RouteID))
}
// transcribeArtifactOutput 第 2 步产物的结构化字段(它会被 marshal 成
// task_artifact.content_json,也原样作为 result 回给前端)。
//
// 单独拎出来是为了能被测试直接调。这些 key 有两条**按字符串**读的消费路径:
// 前端的结果卡片(capability_degraded / capability_zh)与后端自己
// (audioTranscriptDegraded 读 capability_degraded、SpeakerKeysOf 读 segments)。
// key 打错一个字既不会编译报错,也不会让任何用例变红,只会让降级提示和
// 「本次没有说话人分离」的判断一起静默失效 —— 那正是这个改动最怕的失败方式。
func transcribeArtifactOutput(
result *audiotranscribe.Result, media model.MediaFile, transcript, language string,
) gin.H {
return gin.H{
"media_id": media.ID,
"file_name": media.Filename,
"text": result.Text,
"transcript": transcript,
"language": result.Language,
"duration": result.Duration,
"segments": result.Segments,
"has_speakers": result.HasSpeakers,
"model": result.Model,
"route_id": result.RouteID,
"is_local": result.IsLocal,
"fell_back": result.FellBack,
// 降级要一路带到界面:回退链允许退到不分离的路由,
// 而「这一次没有说话人分离」直接决定后面的步骤还能不能跑。
"capability_degraded": result.CapabilityDegraded,
"capability_zh": audioCapabilityLabel(result),
}
}
// audioCapabilityLabel 把「这一次有没有说话人分离」说成人话,降级时点明后果。
//
// 只在降级时返回非空。正常拿到标签时界面不需要多一句话;而「本来该有、这次没有」
// 是用户必须当场知道的事 —— 它决定了后面的步骤还能不能跑,不能等到点了第 5 步
// 才从一句报错里反推。措辞与第 3 步的失败文案共用同一个判断(见
// audioTranscriptSpeakerKeys),两处不能各说各的。
func audioCapabilityLabel(result *audiotranscribe.Result) string {
if result == nil || !result.CapabilityDegraded {
return ""
}
return fmt.Sprintf(
"本次没有说话人分离,后续步骤不可用:"+
"能区分说话人的路由这次都没跑成,已改用「%s」完成转写。"+
"逐字稿可以正常查看,但「识别说话人身份」及其后的整理与纪要都跑不了。",
audioRouteDisplayName(result.RouteID))
}
// transcribeLogs 第 2 步的日志行。
//
// 出网那一行只在本地的没走成时出现:本地转写是这个平台的常态,不该每次
@@ -456,6 +503,11 @@ func transcribeLogs(result *audiotranscribe.Result, media model.MediaFile, durat
"⚠ 主路由 %s 不可用(%s),已回退到 %s",
result.PrimaryRouteID, result.FallbackReason, result.RouteID))
}
if result.CapabilityDegraded {
logs = append(logs, fmt.Sprintf(
"⚠ 本次没有说话人分离(%s 不支持),后续依赖说话人身份的步骤不可用",
result.RouteID))
}
if !result.IsLocal {
logs = append(logs, audioEgressLabel(false))
}
@@ -472,21 +524,24 @@ func transcribeLogs(result *audiotranscribe.Result, media model.MediaFile, durat
// 任务写成「待确认」—— 两处都是既有枚举,界面上也已经有对应的展示(右栏产物区
// 显示「待确认」,左侧任务列表显示「待确认」),不需要任何新的展示代码。
func runAudioSpeakersStep(c *gin.Context, task model.TaskRecord, req audioStepReq) (*audioStepOutcome, error) {
transcript, found := latestArtifact(task.ID, "transcript")
transcript, found := latestArtifact(task.ID, audioTranscriptArtifactType)
if !found || strings.TrimSpace(transcript.ContentText) == "" {
return nil, fmt.Errorf("本任务还没有「逐字转写稿」,请先完成转写再来识别说话人")
}
keys := audiotranscribe.SpeakerKeysOf(transcript.ContentJSON)
if len(keys) == 0 {
// 硬失败,不当作「没有说话人」自动放行。
// 硬失败,不当作「没有说话人」自动放行(G02 反例 A:状态未知就默认视为通过)。
//
// 这里对应 G02 反例 A 的形状:「状态未知就默认视为通过」。当前音频路由是
// 开着说话人分离的(audio_route_siliconflow_diarize 的 supports_speakers
// 为 true),配了却没吐出说话人标签,只可能是转写出了问题 —— 那种情况下
// 悄悄跳过身份确认,会让后面几步把「说话人 0」当成一个人名写进纪要。
return nil, fmt.Errorf("逐字稿里没有说话人标签:当前音频路由配了说话人分离却没输出," +
"请检查第 2 步的转写结果,或换一条音频路由后重跑")
// 措辞不能断言「当前路由配了说话人分离」:回退链允许降级,
// 配了分离却退到一条不分离的路由是**预期内**的结果,不是故障。
// 分岔看第 2 步产物里记的 capability_degraded —— 降级走共用哨兵错误,
// 与第 5/6 步的闸门说同一句话;没降级才是「配了却没输出」那种异常。
if audioTranscriptDegraded(transcript.ContentJSON) {
return nil, errAudioSpeakersUnavailableThisRun
}
return nil, fmt.Errorf("逐字稿里没有说话人标签,识别不了说话人身份:" +
"请检查第 2 步的转写结果,或换一条支持说话人分离的音频路由后重新转写")
}
route, err := resolveAudioChatRoute(req.AiRouteID)
@@ -561,7 +616,7 @@ func runAudioTextStep(c *gin.Context, task model.TaskRecord, req audioStepReq, k
var sourceType, sourceLabel string
switch kind {
case audiotranscribe.KindStructure:
sourceType, sourceLabel = "transcript", "逐字转写稿"
sourceType, sourceLabel = audioTranscriptArtifactType, "逐字转写稿"
case audiotranscribe.KindMinutes:
sourceType, sourceLabel = "document", "结构化纪要"
default:
@@ -752,6 +807,13 @@ func latestArtifact(taskID uint, artifactType string) (model.TaskArtifact, bool)
return artifact, true
}
// audioTranscriptArtifactType 第 2 步「逐字转写稿」产物的类型。
//
// 与 audioSpeakerArtifactType 同一个理由:写它的地方一处(步骤表),读它的地方
// 三处(第 3 步、闸门、第 5/6 步)。各写一遍字面量的话,改类型名时只会漏掉一处,
// 而闸门漏掉的症状是「永远当作还没转写」—— 静默放行,比卡死更糟。
const audioTranscriptArtifactType = "transcript"
// audioSpeakerArtifactType 「说话人名单」产物的类型。
//
// 单独拎出来是因为有三处要用同一个字面量:识别那一步写它、确认那一步读它、
@@ -759,26 +821,50 @@ func latestArtifact(taskID uint, artifactType string) (model.TaskArtifact, bool)
// 「闸门永远说没确认过」—— 任务卡死,且看不出为什么。
const audioSpeakerArtifactType = "speakers"
// audioRouteSupportsSpeakers 单独拎成变量,只为让测试能覆盖「这条音频路由不输出
// 说话人分离」那一支。音频路由读的是 config/ai_config.json,没有环境变量可以改写
// 它指向的文件,测试里没法安全地换一条路由(那是用户线上正在用的配置)。
// errAudioSpeakersUnavailableThisRun 本次转写没有说话人分离,依赖身份的步骤不可用。
//
// 这一支必须被测到:它是**唯一的放行口**,写反了(比如把取反写掉)会让所有
// 用 Qwen3-ASR 这类无说话人分离路由的任务永久卡死在第 5 步,没有任何办法绕过去。
// 同一个模式见 internal/skills/core/allowed_skills.go 的 allowedSkillKeyProvider。
// 读的是**声明路由**(default_audio_route)而不是解析 audio_transcribe:
// 后者现在指向 auto,会按此刻哪台服务活着挑一条,于是这一问的答案会随着
// 本机 ASR 起没起而变化 —— 而这个答案必须是常量。第 1 步(确认范围)、
// 第 2 步(转写)、第 5/6 步(本闸门)是三次独立解析,中间隔着几分钟,
// 用 auto 就会出现「闸门以为有标签、稿子里其实没有」这种最坏的自相矛盾。
// auto 侧的配套约束见 config/route_health.go 的 resolveAutoRoute:
// 它只在说话人能力相同的候选里挑,所以声明值就是实际会跑的那一类。
var audioRouteSupportsSpeakers = func() (bool, error) {
route, err := config.GetDeclaredAudioRoute()
if err != nil {
return false, fmt.Errorf("音频路由不可用:%w", err)
// 单列成哨兵错误是为了让测试断言「拦下来的理由」而不只是「拦下来了」——
// 「没有说话人分离」和「名单还没确认」都返回 error,混在一个 error 里
// 就分不清闸门是拦对了还是拦错了。
var errAudioSpeakersUnavailableThisRun = errors.New(
"本次没有说话人分离,后续步骤不可用:逐字稿里没有说话人标签," +
"识别不了说话人身份,整理与纪要也就没法把姓名替换进正文。" +
"逐字稿已在第 2 步产物中,可以直接查看;" +
"若要跑完整流程,请换一条支持说话人分离的音频路由后重新转写")
// audioTranscriptSpeakerKeys 返回第 2 步逐字稿里**实际出现**的说话人标签,
// 以及「本任务转写过没有」。单独拎成变量,只为让测试能构造三种情形
// (有标签 / 无标签 / 还没转写),同 audioRouteSupportsSpeakers 的老做法。
//
// 判据从「路由声明的能力」改成「稿子里到底有没有标签」,是因为回退链现在允许
// 降级:能分离的路由全挂时会退到一条不分离的(见 config.GetFallbackAudioRoutes),
// 于是「配置说支持」和「这次真的拿到了」不再恒等,只有稿子说的是真的。
//
// 这也让闸门和第 3 步同源:runAudioSpeakersStep 判断「能不能识别身份」用的
// 就是同一句 SpeakerKeysOf。两处若各判各的,重新长出「闸门以为有标签、
// 稿子里其实没有」只是时间问题 —— 而那正是当初把它们绑在一起的初衷。
var audioTranscriptSpeakerKeys = func(taskID uint) (keys []string, transcribed bool) {
artifact, found := latestArtifact(taskID, audioTranscriptArtifactType)
if !found {
return nil, false
}
return route.SupportsSpeakers, nil
return audiotranscribe.SpeakerKeysOf(artifact.ContentJSON), true
}
// audioTranscriptDegraded 读第 2 步产物里记的「本次是否降级」。
//
// 读产物而不是读路由配置:降级是**这一次**的事实,配置只说明想要什么 ——
// 而这两件事现在可以不一致,正是本改动引入的自由度。
// 解析失败按 false 处理:降级字段是后加的,老任务的产物里没有它,
// 那种情况下「配了分离却没输出」的旧解释才是对的。
func audioTranscriptDegraded(contentJSON string) bool {
var payload struct {
CapabilityDegraded bool `json:"capability_degraded"`
}
if err := json.Unmarshal([]byte(contentJSON), &payload); err != nil {
return false
}
return payload.CapabilityDegraded
}
// ensureAudioSpeakersConfirmed 闸门:说话人身份没被用户确认过,下游两步不许跑。
@@ -789,21 +875,26 @@ var audioRouteSupportsSpeakers = func() (bool, error) {
// 以这个身份发出去,「张三说」和「李四说」就换了人。所以这一步不放行,
// 而不是放行后补一句「仅供参考」——后者是 AR05 §6.6 明确不接受的做法。
//
// 音频路由不支持说话人分离时直接放行:那种情况下第 3 步根本跑不起来,
// 不存在「有一个待确认的身份」这件事,卡在这里就成了死锁。
// 本次没有说话人分离时判为**不可用**(errAudioSpeakersUnavailableThisRun),
// 而不是像以前那样放行。放行的结果是正文里那些「说话人 0」被当成一个称谓
// 写进正式纪要,用户拿到手看不出来这里少了东西 —— 那就是 G02 反例 A
// 「状态未知就默认视为通过」的形状,只不过这次是「能力缺失就默认视为够用」。
// 用户已拍板:这种时候宁可只交付逐字稿,也不要一份看不出残缺的纪要。
func ensureAudioSpeakersConfirmed(taskID uint) error {
supports, err := audioRouteSupportsSpeakers()
if err != nil {
return err
}
if !supports {
keys, transcribed := audioTranscriptSpeakerKeys(taskID)
if !transcribed {
// 还没转过写:这不是本闸门该报的错,调用方各自的前置检查(
// latestArtifactText 的「请先完成上一步」)会先开口。
return nil
}
if len(keys) == 0 {
return errAudioSpeakersUnavailableThisRun
}
artifact, found := latestArtifact(taskID, audioSpeakerArtifactType)
if !found {
return fmt.Errorf(
"本任务还没有「说话人名单」:当前音频路由输出说话人标签," +
"本任务还没有「说话人名单」:逐字稿里有说话人标签," +
"请先执行「识别说话人身份」并确认后再继续")
}
if specialistruntime.NormalizeArtifactStatus(artifact.Status) != specialistruntime.ArtifactStatusApproved {
@@ -1,12 +1,12 @@
package skillapi
import (
"errors"
"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"
@@ -67,26 +67,41 @@ func writeSpeakerArtifact(t *testing.T, taskID uint, status, contentJSON string)
return artifact
}
// requireDiarizingAudioRoute 确认当前音频路由真的输出说话人标签。
// transcriptJSON 造一份第 2 步「逐字转写稿」产物的 ContentJSON。
//
// 不 Skip 而 Fatal,是因为「路由不输出了」正是这个功能整体失效的方式:
// 换一条没有说话人分离的 ASR(比如 Qwen3-ASR),第 3 步会硬失败、闸门会直接放行,
// 于是这几个用例会全部变绿而什么都没测。那种绿比红危险得多。
// 读**声明路由**(default_audio_route),与 audioRouteSupportsSpeakers 用同一个
// 来源。不能读 GetAudioRoute("audio_transcribe"):那条现在指向 auto,会按本机
// ASR 此刻起没起挑路由,于是这套用例的成败取决于跑测试的机器上 8090 有没有在监听
// —— 那是一种「绿得毫无意义、红得莫名其妙」的测试。声明值才是与机器状态无关的事实。
func requireDiarizingAudioRoute(t *testing.T) {
// 形状与 runAudioTranscribeStep 写的一致(Output 直接 marshal 进 ContentJSON)。
// capabilityDegraded 传 true 用来构造「本次降级到不分离路由」那种产物:
// 它有 segments,但每个 segment 的 speaker 都是空串 —— 无分离 ASR 的输出就长这样。
func transcriptJSON(capabilityDegraded bool, speakers ...string) string {
segs := make([]string, 0, len(speakers))
for i, sp := range speakers {
segs = append(segs, fmt.Sprintf(
`{"speaker":%q,"start":%d,"end":%d,"text":"第 %d 句"}`, sp, i*10, i*10+10, i+1))
}
return fmt.Sprintf(`{"has_speakers":%v,"capability_degraded":%v,"segments":[%s]}`,
len(speakers) > 0, capabilityDegraded, strings.Join(segs, ","))
}
// writeTranscriptArtifact 往库里塞一条第 2 步的「逐字转写稿」产物。
//
// 这组用例必须先有它。闸门判「这一次到底有没有说话人分离」读的就是这份产物
// (audioTranscriptSpeakerKeys),不再是路由声明的能力 —— 回退链允许降级之后,
// 「配置说支持」和「这次真的拿到了」可以不相等,只有稿子说的是真的。
func writeTranscriptArtifact(t *testing.T, taskID uint, contentJSON string) model.TaskArtifact {
t.Helper()
route, err := config.GetDeclaredAudioRoute()
if err != nil {
t.Fatalf("音频路由不可用:%v", err)
artifact := model.TaskArtifact{
TaskID: taskID,
SpecialistKey: "general-assistant",
Title: "逐字转写稿",
ArtifactType: audioTranscriptArtifactType,
Status: specialistruntime.ArtifactStatusReady,
ContentText: "## 逐字转写稿\n",
ContentJSON: contentJSON,
}
if !route.SupportsSpeakers {
t.Fatalf("当前音频路由 %s 的 supports_speakers=false:"+
"「识别说话人身份」这一步跑不起来,本文件所有断言都会退化成空断言。"+
"若确实要换掉带说话人分离的路由,请连同这套确认流程一起重新评估", route.RouteID)
if err := store.DB.Create(&artifact).Error; err != nil {
t.Fatalf("创建逐字转写稿产物失败:%v", err)
}
return artifact
}
// rosterJSON 造一份「说话人名单」产物的 ContentJSON(形状与 runAudioSpeakersStep 写的一致)。
@@ -102,9 +117,19 @@ func rosterJSON(roster ...audiotranscribe.SpeakerIdentity) string {
func TestAudioSpeakersGateBlocksWithoutConfirmation(t *testing.T) {
setupSpeakerGateDB(t)
requireDiarizingAudioRoute(t)
task := newSpeakerGateTask(t)
// 0) 稿子还没有 —— 这时不该由闸门开口,见
// TestAudioSpeakersGatePassesThroughWhenNotTranscribedYet。
// 写在这里是为了把「还没转写」与「转写了但没有说话人」分开,
// 后者必须拦(TestAudioSpeakersGateBlocksDegradedTranscript)。
if err := ensureAudioSpeakersConfirmed(task.ID); err != nil {
t.Errorf("还没转写时闸门不该报「没有说话人分离」,会把用户引向错误的原因:%v", err)
}
// 本次转写有说话人标签 —— 下面几条断言的前提。
writeTranscriptArtifact(t, task.ID, transcriptJSON(false, "0", "1"))
// 1) 连名单都还没有 —— 这一步没跑过,下游不能跑。
// (走不到这里的正常路径:人在第 3 步就停住了;但直接把请求打到第 5 步就能绕过前端。)
if err := ensureAudioSpeakersConfirmed(task.ID); err == nil {
@@ -143,8 +168,8 @@ func TestAudioSpeakersGateBlocksWithoutConfirmation(t *testing.T) {
// → 闸门如果只看「历史上有没有确认过」,就会拿**上一版**的身份去写新稿子。
func TestAudioSpeakersGateReopensWhenReinferred(t *testing.T) {
setupSpeakerGateDB(t)
requireDiarizingAudioRoute(t)
task := newSpeakerGateTask(t)
writeTranscriptArtifact(t, task.ID, transcriptJSON(false, "0"))
writeSpeakerArtifact(t, task.ID, specialistruntime.ArtifactStatusApproved, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "0", Name: "张三"},
@@ -162,51 +187,100 @@ func TestAudioSpeakersGateReopensWhenReinferred(t *testing.T) {
}
}
// TestAudioSpeakersGatePassesThroughWhenRouteHasNoDiarization 钉住闸门的唯一放行口。
// TestAudioSpeakersGatePassesThroughWhenNotTranscribedYet 钉住闸门不越权报错。
//
// 换一条不输出说话人分离的 ASR(如 Qwen3-ASR)时,「识别说话人身份」这一步
// 根本没有对象可做,闸门必须直接放行。这一支写反的后果不是少一道校验,
// 而是**所有这类任务永久卡死**:第 3 步跑不出名单 → 闸门说没有名单 → 不许跑第 5 步,
// 而用户无论如何也变不出那份名单。没有别的路可以绕过去。
//
// 断言的是「一个名单都没有的情况下也放行」:若改成先查名单,这条会红。
func TestAudioSpeakersGatePassesThroughWhenRouteHasNoDiarization(t *testing.T) {
// 第 5/6 步拿不到前置产物时,该开口的是 latestArtifactText 的「请先完成上一步」。
// 闸门若抢着报「本次没有说话人分离」,用户会去查一条根本不存在的路由问题,
// 而真正的原因是他还没跑第 2 步。
func TestAudioSpeakersGatePassesThroughWhenNotTranscribedYet(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)
t.Fatalf("还没转写时闸门就报错了(会把用户引向错误的原因):%v", err)
}
}
// TestAudioSpeakersGateFailsClosedWhenRouteUnavailable 钉住「路由读不出来时不放行」。
// TestAudioSpeakersGateBlocksDegradedTranscript 钉住本改动引入的核心行为。
//
// 与上一条相反的方向:读不到路由配置(ai_config.json 被改坏、audio_routes 缺项)
// 时**不能**当作「不支持说话人分离」而放行。放行等于跳过确认,正是这道闸门
// 要防的事;而报错会让用户看见问题在哪。
func TestAudioSpeakersGateFailsClosedWhenRouteUnavailable(t *testing.T) {
// 回退链现在允许降级:能分离说话人的路由全挂时会退到一条不分离的
// (config.GetFallbackAudioRoutes 的两级排序),逐字稿仍然拿得到。
// 代价是稿子里只剩「说话人 0」这样的裸标签 —— 没有身份可确认,
// 所以第 3 步与第 5/6 步要一起判为不可用。
//
// 老行为是「路由不支持分离就直接放行」,而放行的结果是把裸标签当成称谓写进
// 正式纪要,用户拿到手看不出这里少了东西(G02 反例 A 的形状)。
// 用户已拍板:这种时候宁可只交付逐字稿。
//
// 名单存在、且已确认,也照样要拦:那份名单是**上一次**转写的产物,
// 拿它去解释这一次的稿子,等于给这一版说话人安上上一版的身份。
func TestAudioSpeakersGateBlocksDegradedTranscript(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 })
writeTranscriptArtifact(t, task.ID, transcriptJSON(true))
writeSpeakerArtifact(t, task.ID, specialistruntime.ArtifactStatusApproved, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "0", Org: "某某局", Title: "处长", Name: "张三"},
))
err := ensureAudioSpeakersConfirmed(task.ID)
if err == nil {
t.Fatal("路由读不出来时闸门放行了 —— 配置坏掉会静默跳过说话人确认")
t.Fatal("本次没有说话人分离,闸门却放行了 —— 裸标签会被当成称谓写进正式纪要")
}
if !strings.Contains(err.Error(), "audio_routes") {
t.Errorf("报错没有把真实原因(路由配置)带出来:%v", err)
if !errors.Is(err, errAudioSpeakersUnavailableThisRun) {
t.Errorf("拦下来的理由不是「本次没有说话人分离」:%v", err)
}
if !strings.Contains(err.Error(), "后续步骤不可用") {
t.Errorf("报错没有说清后果:%v", err)
}
if !strings.Contains(err.Error(), "逐字稿") {
t.Errorf("报错没有告诉用户逐字稿还在(那是这次唯一拿得到的东西):%v", err)
}
}
// TestAudioSpeakersGateDistinguishesBlockReasons 钉住两种「拦」的理由能分开。
//
// 两种情形都返回 error,但用户要做的事完全相反:没有说话人分离时该换一条支持
// 分离的路由重跑;名单没确认时该去核对并确认名单。混成同一句话,
// 用户照着报错做完还是过不去。
func TestAudioSpeakersGateDistinguishesBlockReasons(t *testing.T) {
setupSpeakerGateDB(t)
degraded := newSpeakerGateTask(t)
writeTranscriptArtifact(t, degraded.ID, transcriptJSON(true))
degradedErr := ensureAudioSpeakersConfirmed(degraded.ID)
unconfirmed := newSpeakerGateTask(t)
writeTranscriptArtifact(t, unconfirmed.ID, transcriptJSON(false, "0"))
writeSpeakerArtifact(t, unconfirmed.ID, specialistruntime.ArtifactStatusReady, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "0", Name: "张三"},
))
unconfirmedErr := ensureAudioSpeakersConfirmed(unconfirmed.ID)
if degradedErr == nil || unconfirmedErr == nil {
t.Fatalf("两种情形都该被拦住,实际:降级=%v,未确认=%v", degradedErr, unconfirmedErr)
}
if errors.Is(unconfirmedErr, errAudioSpeakersUnavailableThisRun) {
t.Errorf("「名单还没确认」被报成了「没有说话人分离」:%v", unconfirmedErr)
}
if degradedErr.Error() == unconfirmedErr.Error() {
t.Errorf("两种拦截理由说的是同一句话,用户分不清该做什么:\n%s", degradedErr)
}
}
// TestAudioSpeakersGateFailsClosedOnCorruptTranscript 钉住「读不出来时不放行」。
//
// 稿子在但 ContentJSON 解析不出来(历史数据、写库出错)时,SpeakerKeysOf 返回 nil,
// 于是闸门看到的是「没有标签」。这个方向是**故意**的:分不清「真的没有分离」和
// 「解析失败」时,宁可拦下来(用户看得见、能重跑),也不要放行 ——
// 放行等于让一份身份不明的稿子直接进纪要。
func TestAudioSpeakersGateFailsClosedOnCorruptTranscript(t *testing.T) {
setupSpeakerGateDB(t)
task := newSpeakerGateTask(t)
writeTranscriptArtifact(t, task.ID, "{ 这不是 JSON")
if err := ensureAudioSpeakersConfirmed(task.ID); err == nil {
t.Fatal("逐字稿解析不出来时闸门放行了 —— 身份不明的稿子会直接进纪要")
}
}