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
@@ -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)
}
}