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:
+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