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:
@@ -0,0 +1,200 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
)
|
||||
|
||||
// 这一组用例走**真实路由 + 真实鉴权中间件**,钉住「降级之后」这件事在接口上的行为:
|
||||
// 第 2 步只交出逐字稿,第 3 步与第 5/6 步必须真的跑不了。
|
||||
//
|
||||
// 为什么函数级用例不够:闸门在 skills/api 里,端点却是 gin 注册的,中间隔着绑定、
|
||||
// 鉴权与 handler 分发。少注册一行路由、或 handler 里漏调一次闸门,函数级用例全绿,
|
||||
// 而线上那份没有说话人的稿子照样能生成正式纪要 —— 仓库里已有的教训
|
||||
// (go test 全绿测不到 handler)。
|
||||
|
||||
// seedTranscript 在任务下铺一份第 2 步产物。
|
||||
//
|
||||
// degraded 决定稿子里那个 capability_degraded 标记;两种情形的共同点是
|
||||
// **分段里都没有 speaker 标签** —— 而降级与否正是靠这个字段区分的(措辞不同,
|
||||
// 拦不拦一样,见下面第二个用例)。
|
||||
//
|
||||
// 字段名照抄 audio_handlers.go 的 transcribeArtifactOutput,改错了会被
|
||||
// skills/api 的 audio_capability_roundtrip_test.go 接住。
|
||||
func seedTranscript(t *testing.T, taskID uint, degraded bool) {
|
||||
t.Helper()
|
||||
flag := "false"
|
||||
if degraded {
|
||||
flag = "true"
|
||||
}
|
||||
transcript := model.TaskArtifact{
|
||||
TaskID: taskID,
|
||||
SpecialistKey: "general-assistant",
|
||||
Title: "逐字转写稿",
|
||||
ArtifactType: "transcript",
|
||||
Status: specialistruntime.ArtifactStatusDraft,
|
||||
ContentText: "这个方案我同意。",
|
||||
ContentJSON: `{"has_speakers":false,"capability_degraded":` + flag + `,` +
|
||||
`"segments":[{"speaker":"","text":"这个方案我同意。"}]}`,
|
||||
}
|
||||
if err := store.DB.Create(&transcript).Error; err != nil {
|
||||
t.Fatalf("铺逐字稿失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// countRuns 数这个任务落了几条 task_run。
|
||||
func countRuns(t *testing.T, taskID uint) int64 {
|
||||
t.Helper()
|
||||
var runs int64
|
||||
store.DB.Model(&model.TaskRun{}).Where("task_id = ?", taskID).Count(&runs)
|
||||
return runs
|
||||
}
|
||||
|
||||
// TestAudioDegradedTranscriptBlocksStructureThroughRealRoute 是这一组的核心:
|
||||
// 降级之后,第 5 步的请求打进来必须被拦下,并说清楚「后续步骤不可用」。
|
||||
//
|
||||
// 用户拍板的就是这个后果:宁可只交付逐字稿,也不要一份看不出残缺的纪要 ——
|
||||
// 稿子里那些「说话人 0」会被当成一个称谓写进正式正文,用户拿到手看不出来少了东西。
|
||||
func TestAudioDegradedTranscriptBlocksStructureThroughRealRoute(t *testing.T) {
|
||||
fx := newAudioRouteFixture(t)
|
||||
seedTranscript(t, fx.taskID, true)
|
||||
|
||||
code, body := fx.post(t, fx.token, "/api/skills/audio/structure", gin.H{
|
||||
"task_id": fx.taskID,
|
||||
})
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("降级稿子上 structure 返回 %d,期望 400:%v", code, body)
|
||||
}
|
||||
msg := errorMessageOf(t, body)
|
||||
if !strings.Contains(msg, "后续步骤不可用") {
|
||||
t.Errorf("报错没有说明后果(界面要显示的就是这句话):%q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "逐字稿") {
|
||||
t.Errorf("报错没有告诉用户逐字稿还在(那是这次唯一拿得到的东西):%q", msg)
|
||||
}
|
||||
|
||||
// 被拦下的那一步不许留痕:落了 run 会让右栏「工作流 x/6」凭空前进一格,
|
||||
// 显示成「已经整理过了」,而实际上一个字都没生成。
|
||||
if n := countRuns(t, fx.taskID); n != 0 {
|
||||
t.Errorf("被闸门拦下的那一步落了 %d 条 task_run,期望 0 —— 会出现假进度", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioSpeakersStepBlocksDegradedThroughRealRoute 第 3 步同样跑不了,
|
||||
// 且给的是**降级**那句措辞,而不是「配了分离却没输出」那句。
|
||||
//
|
||||
// 两句话的补救办法不同:降级是回退链的预期结果,要用户换一条能分离的路由重新转写;
|
||||
// 而没降级却没标签是异常,要用户去查第 2 步。混成一句会让排障指错方向。
|
||||
func TestAudioSpeakersStepBlocksDegradedThroughRealRoute(t *testing.T) {
|
||||
fx := newAudioRouteFixture(t)
|
||||
seedTranscript(t, fx.taskID, true)
|
||||
|
||||
code, body := fx.post(t, fx.token, "/api/skills/audio/speakers", gin.H{
|
||||
"task_id": fx.taskID,
|
||||
})
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("降级稿子上 speakers 返回 %d,期望 400:%v", code, body)
|
||||
}
|
||||
msg := errorMessageOf(t, body)
|
||||
if !strings.Contains(msg, "后续步骤不可用") {
|
||||
t.Errorf("降级该走共用哨兵错误,实际:%q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "请检查第 2 步的转写结果") {
|
||||
t.Errorf("降级被报成了「配了分离却没输出」那种异常:%q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioSpeakersStepReportsAnomalyWhenNotDegraded 没降级却没标签,是另一回事。
|
||||
//
|
||||
// 这条是上面那条的对照:没有它,「两种情形报同一句话」也能全绿,
|
||||
// 而那意味着分岔逻辑压根没生效。
|
||||
func TestAudioSpeakersStepReportsAnomalyWhenNotDegraded(t *testing.T) {
|
||||
fx := newAudioRouteFixture(t)
|
||||
seedTranscript(t, fx.taskID, false)
|
||||
|
||||
code, body := fx.post(t, fx.token, "/api/skills/audio/speakers", gin.H{
|
||||
"task_id": fx.taskID,
|
||||
})
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("没有标签的稿子上 speakers 返回 %d,期望 400:%v", code, body)
|
||||
}
|
||||
msg := errorMessageOf(t, body)
|
||||
if !strings.Contains(msg, "请检查第 2 步的转写结果") {
|
||||
t.Errorf("没降级却没标签该提示去查第 2 步,实际:%q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioGateBlocksRegardlessOfDegradedFlag 闸门**拦不拦**不看那个标记。
|
||||
//
|
||||
// 这是刻意的:capability_degraded 是本改动新加的字段,本改动之前落库的产物里
|
||||
// 没有它 —— 若闸门以「标记为真」作为拦截条件,那些老任务的稿子(同样没有说话人)
|
||||
// 会一路放行到纪要,fail-open 得毫无声息。
|
||||
//
|
||||
// 标记只决定**用哪句话解释**,不决定放不放行。这条用例把两者分开钉住。
|
||||
func TestAudioGateBlocksRegardlessOfDegradedFlag(t *testing.T) {
|
||||
for _, degraded := range []bool{true, false} {
|
||||
name := "未标记降级"
|
||||
if degraded {
|
||||
name = "标记了降级"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fx := newAudioRouteFixture(t)
|
||||
seedTranscript(t, fx.taskID, degraded)
|
||||
|
||||
code, body := fx.post(t, fx.token, "/api/skills/audio/structure", gin.H{
|
||||
"task_id": fx.taskID,
|
||||
})
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("返回 %d,期望 400 —— 没有说话人标签就不能往下走:%v", code, body)
|
||||
}
|
||||
if !strings.Contains(errorMessageOf(t, body), "后续步骤不可用") {
|
||||
t.Errorf("没有拦住的理由说明:%q", errorMessageOf(t, body))
|
||||
}
|
||||
if n := countRuns(t, fx.taskID); n != 0 {
|
||||
t.Errorf("落了 %d 条 task_run,期望 0", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioStructureStillRunsWithConfirmedSpeakers 是这一组的**阳性对照**:
|
||||
// 稿子里有标签、名单也确认过了,第 5 步就必须放行。
|
||||
//
|
||||
// 没有它,上面几条「一律 400」的断言即使闸门被改成永远报错也照样全绿 ——
|
||||
// 那样用户拿到的是「音频技能彻底不能用」,而不是「降级时只交付逐字稿」。
|
||||
//
|
||||
// 这里只走到闸门之后:路由解析失败(测试环境没有可用的对话路由)也算放行,
|
||||
// 因为那已经证明请求越过了闸门。要区分两种失败,看错误措辞。
|
||||
func TestAudioStructurePassesGateWithConfirmedSpeakers(t *testing.T) {
|
||||
fx := newAudioRouteFixture(t)
|
||||
seedTranscriptAndSpeakers(t, fx.taskID, twoSpeakersJSON)
|
||||
|
||||
// 先把名单从 ready 推到 approved(第 4 步在真实流程里做的事)。
|
||||
code, body := fx.post(t, fx.token, "/api/skills/audio/speakers/confirm", gin.H{
|
||||
"task_id": fx.taskID,
|
||||
"speakers": []gin.H{
|
||||
{"key": "0", "org": "某某局", "title": "处长", "name": "张三"},
|
||||
{"key": "1", "title": "记录员", "name": ""},
|
||||
},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("确认说话人身份返回 %d:%v", code, errorMessageOf(t, body))
|
||||
}
|
||||
|
||||
_, body = fx.post(t, fx.token, "/api/skills/audio/structure", gin.H{
|
||||
"task_id": fx.taskID,
|
||||
})
|
||||
// 越过闸门的标志:报错不再是闸门那两句。测试环境没有配可用的对话路由,
|
||||
// 所以这里大概率是路由类的失败 —— 那正是「已经放行」的证据。
|
||||
if msg := errorMessageOf(t, body); strings.Contains(msg, "后续步骤不可用") ||
|
||||
strings.Contains(msg, "尚未确认") || strings.Contains(msg, "没有说话人标签") {
|
||||
t.Errorf("名单已确认,却仍被闸门拦下:%q", msg)
|
||||
}
|
||||
}
|
||||
@@ -108,31 +108,82 @@ func TestAudioRouteCapabilityIsDeclared(t *testing.T) {
|
||||
declared.RouteID, declared.SupportsSpeakers, resolved.RouteID)
|
||||
}
|
||||
|
||||
// TestAudioFallbackKeepsSpeakerCapability 钉住回退链不会换掉说话人能力。
|
||||
// TestDeclaredAudioRouteStillDiarizes 钉住产品前提:默认音频路由仍然输出说话人分离。
|
||||
//
|
||||
// 回退是为了「这条路此刻不行」,不是为了「悄悄换个能力」。链上一条
|
||||
// supports_speakers 不同的路由,等于让一次网络抖动改掉整个任务的下游行为。
|
||||
func TestAudioFallbackKeepsSpeakerCapability(t *testing.T) {
|
||||
primary, err := GetDeclaredAudioRoute()
|
||||
// 「说话人名单 + 用户确认」这整套流程(第 3、4 步与闸门)只在默认路由能分离时才有意义。
|
||||
// 这条断言原本在 skills/api 的闸门用例里(requireDiarizingAudioRoute);闸门改成读产物
|
||||
// 之后它已经不在那条链上,但前提还在,所以搬到这里 —— 它是**配置**的约束。
|
||||
//
|
||||
// 失效方式很隐蔽:把默认路由换成一条无分离的(比如图快换成 Qwen3-ASR),
|
||||
// 第 3 步从此跑不起来,而那套闸门用例会全部变绿而什么都没测。
|
||||
func TestDeclaredAudioRouteStillDiarizes(t *testing.T) {
|
||||
route, err := GetDeclaredAudioRoute()
|
||||
if err != nil {
|
||||
t.Fatalf("声明路由解析失败:%v", err)
|
||||
t.Fatalf("声明路由(default_audio_route)解析失败:%v", err)
|
||||
}
|
||||
chain := GetFallbackAudioRoutes(primary.RouteID)
|
||||
if len(chain) == 0 {
|
||||
// 没有配回退不是错误(云端可能本就没开通),但要说清「本地挂了没有退路」。
|
||||
t.Logf("路由 %s 没有配置回退链:本地不可用时这次转写会直接失败", primary.RouteID)
|
||||
return
|
||||
if !route.SupportsSpeakers {
|
||||
t.Fatalf("默认音频路由 %s 的 supports_speakers=false:说话人确认流程整体失效,"+
|
||||
"若确实要换掉带说话人分离的默认路由,请连同第 3/4 步与闸门一起重新评估",
|
||||
route.RouteID)
|
||||
}
|
||||
for _, r := range chain {
|
||||
if r.Category != "audio" {
|
||||
t.Errorf("回退链里的 %s 分类是 %q —— 会把音频发到非 ASR 终点", r.RouteID, r.Category)
|
||||
}
|
||||
|
||||
// TestAudioFallbackKeepsSpeakerCapability 钉住回退链的**两级**语义。
|
||||
//
|
||||
// 老版本钉的是「能力不同就整条丢掉」。那条保护有个过头的代价:能分离的路由
|
||||
// 全挂时整条链是空的,用户连逐字稿都拿不到,而逐字稿是后面每一步的输入。
|
||||
// 现在改成:同能力的排前面,降级的排最后 —— 降级仍是最后手段,但不再「宁可不回退」。
|
||||
//
|
||||
// 对每条音频路由断言三件事,缺一条这个语义就不成立:
|
||||
// 1. 链上每条都是 audio 分类(不会把音频发到非 ASR 终点);
|
||||
// 2. 支持分离的主路由,其链上同能力路由一条都不许排在降级路由之后 ——
|
||||
// 那等于有保得住能力的路由没试就降级了;
|
||||
// 3. 支持分离的主路由,链上必须真有降级路由 —— 否则「允许降级兜底」是句空话,
|
||||
// 而这条恰恰是本改动要保证的事,不能只靠「配置里碰巧写了」。
|
||||
func TestAudioFallbackKeepsSpeakerCapability(t *testing.T) {
|
||||
routes, err := GetRoutesByCategory("audio")
|
||||
if err != nil {
|
||||
t.Fatalf("枚举音频路由失败:%v", err)
|
||||
}
|
||||
for _, primary := range routes {
|
||||
chain := GetFallbackAudioRoutes(primary.RouteID)
|
||||
if len(chain) == 0 {
|
||||
// 没有配回退不是错误(云端可能本就没开通),但要说清「这条挂了没有退路」。
|
||||
t.Logf("路由 %s 没有配置回退链:它不可用时这次转写会直接失败", primary.RouteID)
|
||||
continue
|
||||
}
|
||||
if r.SupportsSpeakers != primary.SupportsSpeakers {
|
||||
t.Errorf("回退链里的 %s 的 supports_speakers=%v,与主路由 %s 的 %v 不同:"+
|
||||
"回退会静默换掉说话人能力", r.RouteID, r.SupportsSpeakers,
|
||||
primary.RouteID, primary.SupportsSpeakers)
|
||||
|
||||
degradedSeen := ""
|
||||
sameCount := 0
|
||||
for _, r := range chain {
|
||||
if r.Category != "audio" {
|
||||
t.Errorf("%s 的回退链里有 %s,分类是 %q —— 会把音频发到非 ASR 终点",
|
||||
primary.RouteID, r.RouteID, r.Category)
|
||||
}
|
||||
if r.SupportsSpeakers != primary.SupportsSpeakers {
|
||||
degradedSeen = r.RouteID
|
||||
continue
|
||||
}
|
||||
if degradedSeen != "" {
|
||||
t.Errorf("%s 的回退链里同能力的 %s 排在降级路由 %s 之后:"+
|
||||
"还有保得住说话人能力的路由没试就先降级了",
|
||||
primary.RouteID, r.RouteID, degradedSeen)
|
||||
}
|
||||
sameCount++
|
||||
}
|
||||
t.Logf("回退: %s (%s)", r.RouteID, r.Model)
|
||||
|
||||
if primary.SupportsSpeakers && degradedSeen == "" {
|
||||
t.Errorf("%s 支持说话人分离,但回退链(%d 条)里没有一条不分离的路由:"+
|
||||
"分离路由全挂时整个任务会失败,逐字稿也拿不到", primary.RouteID, len(chain))
|
||||
}
|
||||
// 第 1 步的文案靠它决定要不要提醒用户「这次可能没有说话人分离」,
|
||||
// 与链的实际内容必须是同一个答案。
|
||||
if got, want := AudioFallbackDegradesSpeakers(primary.RouteID), degradedSeen != ""; got != want {
|
||||
t.Errorf("%s: AudioFallbackDegradesSpeakers=%v,但链上降级路由存在=%v",
|
||||
primary.RouteID, got, want)
|
||||
}
|
||||
t.Logf("%s(speakers=%v)链:%d 条同能力 + 降级 %q",
|
||||
primary.RouteID, primary.SupportsSpeakers, sameCount, degradedSeen)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -497,9 +497,18 @@ func GetDeclaredAudioRoute() (*RouteConfig, error) {
|
||||
// 直到 TranscribeBytes 的分类检查才报错,报错文案指向「分类不对」而不是
|
||||
// 「配置写错了」。这与 GetAudioRoute 当初存在的理由完全同构。
|
||||
//
|
||||
// 额外过滤掉**说话人能力不同**的候选:回退是为了「这条路由此刻不行」,
|
||||
// 不是为了「悄悄换掉能力」。默认路由带说话人分离时退到一条不带的,
|
||||
// 下游第 3 步会硬失败,而闸门还会因为读到 false 而放行。宁可不回退。
|
||||
// 回退链按**说话人能力**分两级:同能力的排前面,降级的排最后。
|
||||
//
|
||||
// 原先是「能力不同就整条丢掉」,理由是「回退是为了这条路由此刻不行,不是为了
|
||||
// 悄悄换掉能力」。那条保护是对的但过头了:分离路由全挂时整条链是空的,用户连
|
||||
// 逐字稿都拿不到,而逐字稿恰恰是后面每一步的输入。
|
||||
//
|
||||
// 现在降级仍然是**最后手段** —— 只要还有一条同能力路由没试过,就不会碰降级路由;
|
||||
// 真降级了,TranscribeBytes 会把它记进 Result.CapabilityDegraded,
|
||||
// 下游据此把依赖说话人身份的步骤判为不可用(见 audio_handlers.go 的
|
||||
// audioTranscriptSpeakerKeys 与 ensureAudioSpeakersConfirmed)。
|
||||
//
|
||||
// 主路由本来就不分离时没有能力可保,直接照配置顺序返回。
|
||||
func GetFallbackAudioRoutes(primaryRouteID string) []*RouteConfig {
|
||||
aiCfg, err := LoadAIConfig()
|
||||
if err != nil {
|
||||
@@ -509,18 +518,41 @@ func GetFallbackAudioRoutes(primaryRouteID string) []*RouteConfig {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var result []*RouteConfig
|
||||
var same, degraded []*RouteConfig
|
||||
for _, fid := range aiCfg.FallbackRoutes[primaryRouteID] {
|
||||
r, err := GetAudioRoute(fid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if r.SupportsSpeakers != primary.SupportsSpeakers {
|
||||
if !primary.SupportsSpeakers || r.SupportsSpeakers == primary.SupportsSpeakers {
|
||||
same = append(same, r)
|
||||
continue
|
||||
}
|
||||
result = append(result, r)
|
||||
degraded = append(degraded, r)
|
||||
}
|
||||
return result
|
||||
return append(same, degraded...)
|
||||
}
|
||||
|
||||
// AudioFallbackDegradesSpeakers 沿回退链走一趟会不会**丢掉**说话人分离能力。
|
||||
//
|
||||
// 给第 1 步(确认音频范围)的文案用。用户需要在转写**开始之前**就知道
|
||||
// 「这一次有可能拿不到说话人分离」,而不是跑完第 2 步才发现 —— 到那时
|
||||
// 录音可能已经出了本机,而后续步骤已经注定跑不了。
|
||||
//
|
||||
// 判据是「链上有一条能力更低的路由」,不是「链上有一条不分离的路由」:
|
||||
// 主路由本来就不分离时(管理员特意选了无分离的云端路由),整条链都不分离,
|
||||
// 但那不是降级 —— 第 1 步已经写着「不区分」,再补一句只会平白吓唬用户。
|
||||
func AudioFallbackDegradesSpeakers(primaryRouteID string) bool {
|
||||
primary, err := GetAudioRoute(primaryRouteID)
|
||||
if err != nil || primary == nil || !primary.SupportsSpeakers {
|
||||
return false
|
||||
}
|
||||
for _, r := range GetFallbackAudioRoutes(primaryRouteID) {
|
||||
if !r.SupportsSpeakers {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildRouteConfig 把 RouteInfo 组装成 RouteConfig,并注入 base_url / api_key。
|
||||
|
||||
@@ -92,13 +92,17 @@ func resolveAutoRoute(category string) (*RouteConfig, error) {
|
||||
}
|
||||
|
||||
defaultRouteID := getDefaultRouteIDForCategory(category)
|
||||
// audio 的能力约束:只在**说话人能力与声明路由相同**的候选里挑。
|
||||
// audio 的能力约束:**挑首选时**只在说话人能力与声明路由相同的候选里挑。
|
||||
//
|
||||
// 默认路由(本地 whisper+pyannote)带说话人分离,而它一旦不健康,
|
||||
// 现有排序会把候选里唯一还活着的 audio_route_siliconflow_qwen3 选出来 ——
|
||||
// 那条不支持说话人分离。这不是「降级可用」:下游第 3 步会硬失败,
|
||||
// 而闸门读到 supports_speakers=false 后直接放行,模型猜的身份就进了正式纪要。
|
||||
// 宁可这次转写失败(用户看得见,可以去修本地服务),也不要静默换掉能力。
|
||||
// 不设约束时的排序会把候选里唯一还活着的 audio_route_siliconflow_qwen3 选出来 ——
|
||||
// 那条不支持说话人分离,于是「首选」直接就是降级态。
|
||||
//
|
||||
// 这只是**偏好**,不是兜底:同能力路由全挂时这里退回声明路由,
|
||||
// 由 GetFallbackAudioRoutes 的两级链在试完所有同能力候选之后才允许降级。
|
||||
// 分工是刻意的 —— 这里答「最想用哪条」,那里答「都不行了依次退到哪」。
|
||||
// 降级与否最终以转写产物的实际说话人标签为准(audio_handlers.go),
|
||||
// 不再依赖「声明路由能力 == 实际能力」这个前提。
|
||||
if category == "audio" && defaultRouteID != "" {
|
||||
if declared, err := GetAudioRoute(defaultRouteID); err == nil && declared != nil {
|
||||
kept := routes[:0:0]
|
||||
|
||||
@@ -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("逐字稿解析不出来时闸门放行了 —— 身份不明的稿子会直接进纪要")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+81
@@ -89,6 +89,87 @@ func deadRoute(t *testing.T, id string) *config.RouteConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// audioNoSpeakerBody 是「这条路由不做说话人分离」时我们收到的形状:
|
||||
// 有 segments,但 speaker 全是空串(has_speakers 就是据此算出来的)。
|
||||
// 降级兜底拿到的正是它。
|
||||
const audioNoSpeakerBody = `{"duration":12.5,"text":"测试转写内容",
|
||||
"segments":[{"speaker":"","start":0,"end":12.5,"text":"测试转写内容"}],
|
||||
"usage":{"type":"duration","seconds":12.5}}`
|
||||
|
||||
// newStubBody 起一个固定返回 body 的桩。
|
||||
func newStubBody(t *testing.T, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestTranscribeMarksCapabilityDegraded 钉住「降级兜底」这件事被如实记下来。
|
||||
//
|
||||
// 回退链分两级(config.GetFallbackAudioRoutes):同能力的排前面,降级的排最后。
|
||||
// 真降级时产物里必须留下 capability_degraded=true —— 界面靠它告诉用户
|
||||
// 「本次没有说话人分离,后续步骤不可用」,闸门(audioTranscriptSpeakerKeys)
|
||||
// 也据它拦下第 3 步与第 5/6 步。
|
||||
//
|
||||
// 漏掉这个标记的后果不是少一句提示:用户会拿到一份看不出残缺的产物,
|
||||
// 而依赖说话人身份的那几步已经在后台被判为不可用,两边说的不是一回事。
|
||||
func TestTranscribeMarksCapabilityDegraded(t *testing.T) {
|
||||
secondary := newStubBody(t, audioNoSpeakerBody)
|
||||
primary := deadRoute(t, "audio_route_diarize_dead")
|
||||
primary.SupportsSpeakers = true
|
||||
backup := stubRoute("audio_route_no_diarize", secondary)
|
||||
backup.SupportsSpeakers = false
|
||||
|
||||
// 链路本身就是「同能力优先、降级殿后」的产物,这里照它的形状喂进去。
|
||||
withChain(t, primary, backup)
|
||||
|
||||
res, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", "")
|
||||
if err != nil {
|
||||
t.Fatalf("同能力路由全挂时应降级兜底并成功,却报错:%v", err)
|
||||
}
|
||||
if !res.CapabilityDegraded {
|
||||
t.Error("CapabilityDegraded = false,但这一次确实从「能分离」降到了「不能分离」")
|
||||
}
|
||||
if res.HasSpeakers {
|
||||
t.Error("HasSpeakers = true,但兜底那条路由的输出里没有任何 speaker")
|
||||
}
|
||||
if res.RouteID != "audio_route_no_diarize" {
|
||||
t.Errorf("RouteID = %q,期望降级后的 audio_route_no_diarize", res.RouteID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeDoesNotMarkDegradedWhenCapabilityKept 同能力之间的回退不算降级。
|
||||
//
|
||||
// 判据是「两条路由的 supports_speakers 声明不同」,不是「回退过」:
|
||||
// 本地 whisper 挂掉退到云端 Diarize 仍然有说话人分离,不该报降级,
|
||||
// 更不该因此把第 3/5/6 步关掉。
|
||||
func TestTranscribeDoesNotMarkDegradedWhenCapabilityKept(t *testing.T) {
|
||||
secondary := newStub(t, http.StatusOK, nil, 0)
|
||||
primary := deadRoute(t, "audio_route_diarize_dead")
|
||||
primary.SupportsSpeakers = true
|
||||
backup := stubRoute("audio_route_diarize_backup", secondary)
|
||||
backup.SupportsSpeakers = true
|
||||
|
||||
withChain(t, primary, backup)
|
||||
|
||||
res, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", "")
|
||||
if err != nil {
|
||||
t.Fatalf("回退应成功,却报错:%v", err)
|
||||
}
|
||||
if !res.FellBack {
|
||||
t.Fatal("用例前提不成立:这一次并没有回退")
|
||||
}
|
||||
if res.CapabilityDegraded {
|
||||
t.Error("CapabilityDegraded = true,但两条路由都支持说话人分离 —— 这次没有丢能力")
|
||||
}
|
||||
if !res.HasSpeakers {
|
||||
t.Error("HasSpeakers = false,但兜底路由的输出里带 speaker 标签")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeFallsBackWhenPrimaryIsUnreachable(t *testing.T) {
|
||||
var secondaryHits int32
|
||||
secondary := newStub(t, http.StatusOK, &secondaryHits, 0)
|
||||
|
||||
+13
-2
@@ -61,7 +61,15 @@ type Result struct {
|
||||
FellBack bool `json:"fell_back"`
|
||||
PrimaryRouteID string `json:"primary_route_id,omitempty"` // 原本该用的那条
|
||||
FallbackReason string `json:"fallback_reason,omitempty"` // 主路由失败的原因
|
||||
CreatedAt string `json:"created_at"` // 时间
|
||||
|
||||
// CapabilityDegraded 本次是否**降级**了:原本该用的那条能区分说话人,
|
||||
// 实际跑通的这条不能。回退链允许降级兜底(见 config.GetFallbackAudioRoutes),
|
||||
// 但降级是有代价的 —— 没有说话人标签,依赖身份的下游步骤就不可用了。
|
||||
//
|
||||
// 与 HasSpeakers 分开记:HasSpeakers=false 也可能来自「路由支持分离但这次
|
||||
// 一个标签都没输出」,那是异常,值得和「我们主动降级了」区分开。
|
||||
CapabilityDegraded bool `json:"capability_degraded"`
|
||||
CreatedAt string `json:"created_at"` // 时间
|
||||
}
|
||||
|
||||
// AllowedExt 允许转写的音频扩展名(小写,不含点)。
|
||||
@@ -115,6 +123,9 @@ func TranscribeBytes(userID uint, fileData []byte, filename, ext, language, rout
|
||||
if lastErr != nil {
|
||||
result.FallbackReason = lastErr.Error()
|
||||
}
|
||||
// 降级 = 该用的那条能分离、实际这条不能。判据取自两条路由的配置声明,
|
||||
// 与 HasSpeakers(实际拿到什么)互补:这里答「我们放弃了什么能力」。
|
||||
result.CapabilityDegraded = chain[0].SupportsSpeakers && !route.SupportsSpeakers
|
||||
return result, nil
|
||||
}
|
||||
lastErr = err
|
||||
@@ -149,7 +160,7 @@ func TranscribeBytes(userID uint, fileData []byte, filename, ext, language, rout
|
||||
// 真实配置里的回退目标是公网 ASR,要测回退就得真把音频发出去 —— 那正是这个功能
|
||||
// 要防的事,绝不能拿测试来干。注入之后整条链都留在本机,而回退逻辑仍是真的
|
||||
// (真发 HTTP、真读状态码、真走重试判定)。同一模式见 audio_handlers.go 的
|
||||
// audioRouteSupportsSpeakers。
|
||||
// audioTranscriptSpeakerKeys。
|
||||
var transcribeChainFn = transcribeChain
|
||||
|
||||
// transcribeChain 组装「主路由 + 回退链」。
|
||||
|
||||
Reference in New Issue
Block a user