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:
@@ -248,17 +248,27 @@ function latestRunOfActionKey(actionKey = '') {
|
||||
return latest
|
||||
}
|
||||
|
||||
// 这条音频路由输不输出说话人标签。
|
||||
// 这一次转写会不会产出说话人标签。
|
||||
//
|
||||
// 读第 1 步「转写要求」产物里的 speakers 布尔值(后端写的是
|
||||
// route.SupportsSpeakers)。读不到就当**不知道**,照常显示那一步 ——
|
||||
// 猜错方向会让一步真的该做的事从界面上消失,比多显示一格糟得多。
|
||||
// 先读第 2 步「逐字转写稿」产物里的 has_speakers(**实际**结果),
|
||||
// 再退回第 1 步「转写要求」里的 speakers(后端写的是 route.SupportsSpeakers,
|
||||
// 即**声明**的路由能力)。
|
||||
//
|
||||
// 顺序是刻意的:回退链允许降级 —— 能分离说话人的路由全挂时会退到一条不分离的
|
||||
// (后端 config.GetFallbackAudioRoutes),于是声明和实际可以不一致,
|
||||
// 只有转写产物是这一次的事实。反过来读的话,降级那一次会照旧显示第 3 步,
|
||||
// 点下去只会撞上后端那句「本次没有说话人分离,后续步骤不可用」。
|
||||
//
|
||||
// 两处都读不到就当**不知道**,照常显示那一步 —— 猜错方向会让一步真的该做的事
|
||||
// 从界面上消失,比多显示一格糟得多。
|
||||
const speakersUnsupported = computed(() => {
|
||||
let declared = null
|
||||
for (const item of artifacts.value) {
|
||||
const payload = parseArtifactPayload(item)
|
||||
if (typeof payload?.speakers === 'boolean') return payload.speakers === false
|
||||
if (typeof payload?.has_speakers === 'boolean') return payload.has_speakers === false
|
||||
if (declared === null && typeof payload?.speakers === 'boolean') declared = payload.speakers
|
||||
}
|
||||
return false
|
||||
return declared === false
|
||||
})
|
||||
|
||||
const skillBlueprint = computed(() => {
|
||||
|
||||
@@ -107,16 +107,18 @@ export async function executeAudioSkill(context = {}) {
|
||||
for (let index = 0; index < STEP_ORDER.length; index += 1) {
|
||||
const step = STEP_ORDER[index]
|
||||
|
||||
// 第 1 步的产物里带着「这条音频路由输不输出说话人标签」。不带说话人分离时
|
||||
// (如 Qwen3-ASR),第 3 步没有任何人可识别,后端也会硬失败 —— 那种情况下
|
||||
// 这个技能就是「转写 + 整理」四步,跳过它即可,而不是报一个看不懂的错。
|
||||
// 第 1 步的产物里带着「这条音频路由输不输出说话人标签」,第 2 步跑完后
|
||||
// 还有更准的一份:这一次**实际**有没有分离出来。不带说话人分离时
|
||||
// (默认路由就无分离,或本次降级退到了一条无分离的云端路由),
|
||||
// 第 3 步没有任何人可识别,后端也会硬失败 —— 那种情况下这个技能就是
|
||||
// 「转写 + 整理」四步,跳过它即可,而不是报一个看不懂的错。
|
||||
//
|
||||
// 只在**明确是 false** 时跳过:读不到这个字段(老后端、字段改名)时照跑,
|
||||
// 让后端的硬失败把话说清楚,好过默默少做一步。
|
||||
//
|
||||
// 走到这一步时 results 里已经有 scope 了(它是第 0 个),所以读得到;
|
||||
// 不去改循环要遍历的那个数组,跳过的判断就只影响这一次迭代。
|
||||
if (step === 'speakers' && scopeSupportsSpeakers(results) === false) {
|
||||
if (step === 'speakers' && transcriptSupportsSpeakers(results) === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -191,16 +193,25 @@ export async function resumeAudioSkill(context = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 第 1 步的产物有没有说「这条音频路由不输出说话人标签」。
|
||||
* 这一次转写会不会产出说话人标签。
|
||||
*
|
||||
* 返回值是三态,调用方按三态处理:true 支持、false 明确不支持、null 不知道
|
||||
* 判据按可靠性排序:**先看第 2 步的实际结果 `has_speakers`,再看第 1 步声明的
|
||||
* 路由能力 `speakers`**。顺序不能反 —— 回退链允许降级(能分离说话人的路由全挂时
|
||||
* 会退到一条不分离的,见后端 config.GetFallbackAudioRoutes),声明和实际可以不一致,
|
||||
* 只有转写产物是这一次的事实。反过来读的话,降级那一次会照旧去跑第 3 步,
|
||||
* 然后撞上后端那句「本次没有说话人分离,后续步骤不可用」。
|
||||
*
|
||||
* 返回值是三态,调用方按三态处理:true 会、false 明确不会、null 不知道
|
||||
* (字段缺失 —— 老后端或字段改名)。把 null 当成 false 会让一步该做的事
|
||||
* 从流程里静默消失,所以这里不兜底成布尔值。
|
||||
*/
|
||||
function scopeSupportsSpeakers(results) {
|
||||
function transcriptSupportsSpeakers(results) {
|
||||
const transcribe = results.find((item) => item.step === 'transcribe')
|
||||
const actual = transcribe?.result?.has_speakers
|
||||
if (typeof actual === 'boolean') return actual
|
||||
const scope = results.find((item) => item.step === 'scope')
|
||||
const value = scope?.result?.speakers
|
||||
return typeof value === 'boolean' ? value : null
|
||||
const declared = scope?.result?.speakers
|
||||
return typeof declared === 'boolean' ? declared : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,6 +264,12 @@ function buildAudioSkillResult(definition, results, { pendingReview = false, spe
|
||||
const warnings = [
|
||||
transcribeStep.is_local === false ? stripIcon(transcribeStep.is_local_zh) : '',
|
||||
transcribeStep.fell_back ? stripIcon(transcribeStep.fell_back_zh) : '',
|
||||
// 降级到一条不分离说话人的路由:这一次拿不到说话人身份,后面的步骤都跑不了。
|
||||
//
|
||||
// 排在最后是刻意的:前两条说的是录音去了哪,这条说的是**产物少了一部分** ——
|
||||
// 只有它改变「用户最终拿到什么」。而少掉的那部分不会以别的形式出现在界面上,
|
||||
// 第 3 步和第 5/6 步的卡片会直接消失,看着就像这个技能本来只有四步。
|
||||
transcribeStep.capability_degraded ? stripIcon(transcribeStep.capability_zh) : '',
|
||||
].filter(Boolean)
|
||||
|
||||
const artifacts = [
|
||||
|
||||
Reference in New Issue
Block a user