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
@@ -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)
@@ -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 组装「主路由 + 回退链」。