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
@@ -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]