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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user