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