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
+213
View File
@@ -21,6 +21,10 @@
| E07 | 2026-09-26 | 本地 ASR 加载 | pyannote 的 `from_pretrained` **只认文件不认目录**,给目录被当成 HF repo id |
| E08 | 2026-09-26 | 本地 ASR 加载 | torch≥2.6 把 `weights_only` 默认翻成 True,pyannote 旧权重直接拒载 |
| E09 | 2026-09-26 | 工作方式 | 一句「提交一下」被我做成依赖锥测量 + hunk 挑拣工具,最后反问用户提交范围 |
| E10 | 2026-09-26 | 本地 ASR 显存 | pyannote 管线挪上 CUDA 后永久缓存、从不释放,服务只能转写**第一次** |
| E11 | 2026-09-26 | 验证方法 | 靠「连跑三次都成功」下结论 —— 其实三次全是缓存命中,压根没碰 GPU |
| E12 | 2026-09-26 | 本地 ASR 服务化 | 转写堵住事件循环,探活超时 → 本地被判不健康 → **静默退云端,音频出网** |
| E13 | 2026-09-26 | 本地 ASR 服务化 | 服务账号的 `~` 不可写,pyannote 写不了 `~/.pyannote/database.yml`,每次转写都 500 |
---
@@ -412,6 +416,210 @@ internal/ai/llm.go 的 ctx 签名变更
---
## E10 pyannote 管线占着显存不放,本地转写只能成功第一次
**日期**:2026-09-26 **区域**:本地 ASR 显存(原型遗留给常驻服务的坑)
**症状**:把本地 ASR 从「跑一次看结果」接成常驻服务后,**第一次转写正常,之后每一次都失败**:
```
[22:30:32] 合并完成:18 段,1 个说话人,正文 480 字 ← 第一次,成功
[22:35:15] 加载 faster-whisper large-v3(当前空闲显存 0.25 GiB)
[22:35:18] int8_float16 加载失败:CUDA failed with error out of memory
[22:35:21] int8 加载失败:CUDA failed with error out of memory
```
`nvidia-smi` 显示 `./venv/bin/python` 稳稳占着 2646 MiB 不还。
**根因**:`asr_core.py` 里两个模型的显存抢占**只做了单向**。
- `_FW_CACHE["m"]`(whisper)有释放函数 `_free_fw()`;
- `_DIA_CACHE["p"]`(pyannote 管线)**没有**。`_pipeline()` 里 `pipe.to(torch.device("cuda"))`
之后就再也没人碰过它,`_DIA_CACHE` 是模块级字典,进程活着它就不走。
而 `_free_fw()` 的唯一调用点在 `run()` 里,条件是 `if "diarize" in want` ——
注释写着「转写和分离是先后关系,没有并存的理由,所以分离前先放掉前一个」。
这个理由是对的,但**只覆盖了一个方向**:分离前放掉 whisper。反向没人管 ——
第二次转写要加载 whisper 时,pyannote 还在卡上。
所以第一次跑完(转写 → 放 whisper → 加载 pyannote → 留在卡上),
第二次就无路可走。8G 卡被 llama-server 常态占掉约 5G,余量本来就只够一个模型。
**修法**(`asr_core.py`):
1. 新增 `_free_pipe()`,与 `_free_fw()` 对称:pop 出 `_DIA_CACHE["p"]` → `gc.collect()`
→ `torch.cuda.empty_cache()`。
2. **把互斥放进加载器自己**,而不是留给调用方记:
- `_fw_model()` 在阶梯循环**之前**调 `_free_pipe()`;
- `_pipeline()` 在加载之前调 `_free_fw()`。
3. 删掉 `run()` 里那句 `if "diarize" in want: _free_fw()` —— 现在多余,而且正是它
教坏了结构:腾显存成了「调用方要记住的规矩」,只要有一条路径绕开就 OOM。
放在阶梯循环之前是必须的:放后面的话 `int8_float16` / `int8` 两档会各白撞一次 OOM。
修完日志每次都是干净的循环,空闲显存回到 2.42 GiB 而不是塌到 229 MiB:
```
已释放 pyannote 显存,现空闲 2.42 GiB → 加载 faster-whisper large-v3
已释放 whisper 显存,现空闲 2.42 GiB → (加载 pyannote)
```
**代价(如实记)**:两个模型现在每次都互相驱逐,也就是**每次请求都要重新加载模型**,
单次多花几秒。这是 8G 卡上仅剩 2.4G 可用显存的必然结果,不是可以调优掉的。
换更大显存的交付机时,这条约束可以放宽(改成「装不下才驱逐」),但**没在那种机器上验过,
不要提前改** —— 与 `asr.env` 里不加分配器开关是同一条理由。
**怎么早点发现**:**接成服务之后,用「两个内容不同的输入」连跑两次。**
原型阶段的验证模式是「跑一遍、看结果对不对」,这个模式下「跑第二次」是不存在的动作,
所以单次能过就以为没问题。服务化会引入一类原型阶段根本不存在的失败:**状态残留**。
凡是「加载了就往缓存里一放」的模块级字典,都要问一句:谁负责把它拿出来。
---
## E11 「连跑三次都成功」其实是三次缓存命中
**日期**:2026-09-26 **区域**:验证方法(**我自己犯的错**)
**症状**:修完 E10 后我验证,连打三次本地转写,三次都返回 200,据此向用户报告
「修复成立」。实际上**三次都走的是 `out/` 里的缓存**(`whisper_raw.json` /
`diarization.json` 按音频内容哈希落盘),一秒钟的活都没干。
揭穿它的是显存:三次的 `nvidia-smi` 只动了 300 MiB(whisper 常驻),
pyannote 压根没加载过。而 E10 要验的正是 pyannote 那条路径。
**根因**:两件事同时让我误判,而且它们**都会让验证看起来通过**:
1. **`asr_core.py` 按内容哈希缓存各阶段结果**。同样的输入第二次不重算 ——
这是我要的功能,但它让「重复同一动作」变成了无效验证。
2. **我用的测试载荷本身走不到出问题的那条路径**。当然后端自带的 audio 通路测试
发的是 **1 秒静音 WAV**,它连说话人分离都不会触发 —— 拿它验显存问题,
等于用体温计测血压。
两次误判的形状一样:**我以为在验 A,实际验的是 B,而 B 从来没坏过。**
**修法**:验证要让「出问题的那条路径」真的被执行。
- 判断依据不是「返回码是 200」,而是**副作用真的发生了**:
看 `nvidia-smi` 有没有动、看日志里有没有出现该出现的行(`已释放 pyannote 显存`)。
- 输入要**两两不同**(内容不同 → 哈希不同 → 不吃缓存),且要包含**走分离**的路径。
这次是切三段不同的音频(`-ss 300 / 700 / 1100`)连跑。
- 决定性的一次是**第三个**文件:此时 pyannote 已驻留,它必须先被请走才能加载 whisper ——
这才复现了 E10 的场景。
**怎么早点发现**:
> **当一个验证「全都通过了」,先问一句:它们真的跑了吗?**
> 找那个**不该这么快**的信号 —— 2ms、1ms 的「转写」就是它。
具体到本仓库:任何带缓存的链路(`out/` 下的 `whisper_raw.json`、`diarization.json`),
连续两次同样的请求**不构成两次验证**。要验第二次,就换输入。
---
## E12 转写堵住事件循环,本地路由被误判「不健康」,音频静默出网
**日期**:2026-09-26 **区域**:本地 ASR 服务化(**装成服务才出现,原型上永远不会遇到**)
**症状**:`serve.py` 装成 systemd 服务后,后台「AI 路由 → 语音」里本地那条
`healthy=false`,`last_error` 是
```
服务不可达: Get "http://127.0.0.1:8090/v1/models": context deadline exceeded
```
而同时手工 `curl` 同一个地址是 **HTTP 200,1.7 毫秒**。于是 `audio_route_auto`
解析到 `audio_route_siliconflow_diarize` —— **下一个任务的录音会被送到公网 ASR**。
整个过程中平台不报任何错,界面只显示「本地不可用」,用户完全不知道自己失去了
「音频不出本机」。
**根因**:`serve.py` 的端点写成 `async def transcriptions(...)`,里面**直接**调
分钟级的阻塞函数 `asr_core.run(...)`。uvicorn 只有一个事件循环 —— 这一行把循环
占死,期间 `/v1/models` 一个字都回不了(连 FastAPI 丢给线程池的 `def` 端点也一样,
因为请求要先由事件循环受理)。
关键在**「不健康」是假的**:本地服务没坏,它只是**正忙**。而健康探测分不清
「忙」和「死」,两者都表现为「20 秒内没有任何响应头」,于是判死。
原型机上为什么没暴露:原型是手工起在前台,跑一次转写就占满那一次会话;
而平台侧那 30 分钟一次的探测,撞上正忙的概率被「反正没人同时用」掩盖了。
**修法**:把阻塞调用丢出事件循环(`serve.py`)
```python
from starlette.concurrency import run_in_threadpool
...
result = await run_in_threadpool(asr_core.run, src, work, language or "zh", n)
```
转写本身仍然串行(`asr_core` 里已有 `_LOCK`),这里只是把「等 GPU」从事件循环里
挪出去,好让探活和排队中的请求还能被受理。
**怎么证明修好了**(不是「探了一次是 200」):要**在忙窗口里**探。
这次的证据链是三样东西对齐时间戳:
1. 服务日志给出忙窗口:`23:26:43 开始说话人分离 … 23:28:13 合并完成`,
紧接着 `23:28:13 → 23:29:23` 是第二段的转写窗口;
2. 探测结果打时间戳:5 次采样落在 `23:28:11 / 23:28:41 / 23:29:01 / 23:29:22 / 23:29:45`
—— **全部在忙窗口内**,全部 `healthy=true`,延迟 1–2 毫秒;
3. 修之前同样的探活在忙窗口里是 `20018ms` 超时。
**怎么早点发现**:
> **「服务不可达」和「服务正忙」在探测上长得一模一样 —— 只要探测走的是同一个
> 单线程入口,忙就会被读成死。**
>
> 给一个「会把进程占满」的服务的健康端点,先问一句:**它在主活跑着的时候还能应答吗?**
> 验的时候必须在忙的**当中**探,忙完再探等于没探。
具体到本仓库:`internal/config/route_health.go` 那个 20 秒超时对 audio 是**没有余量**的
(chat 探测发的是 4 token 的小请求,audio 探测虽然只 GET `/v1/models`,但撞上忙窗口
就得靠对端把循环让出来)。所以这条不只是 ASR 一侧的事。
---
## E13 服务账号的家目录不可写,pyannote 起步就 PermissionError
**日期**:2026-09-26 **区域**:本地 ASR 服务化(同上,装了服务才出现)
**症状**:装成 systemd 服务后,每次转写都以 500 收场:
```
本地 ASR 失败:PermissionError: [Errno 13] Permission denied: '/home/eai_agentplatform/.pyannote/database.yml'
```
手工起原型时同样的代码、同样的音频,一切正常。
**根因**:两件事在服务化时同时发生,原型上一个都不成立:
1. unit 里 `ProtectHome=true`,`/home` 整个不可访问;
2. 服务账号是 `useradd -r` 建的系统账号,家目录 `/home/eai_agentplatform` **根本不存在**。
pyannote 起步时要写 `~/.pyannote/database.yml`,于是必失败。报错落在说话人分离
那一段,看起来像**模型坏了**,其实是**没地方写配置**。
**修法**:给服务一个可写的家目录 —— `deploy/asr.env` 里
```
HOME=/opt/eai_agentplatform-asr/cache
```
指到 `cache/` 是因为 unit 的 `ReadWritePaths` 只放开了这一个目录;顺带让
torch / matplotlib 之类的 dotfile 也落在同一个可写位置。
**怎么早点发现**:
> **凡是「以某个账号跑」的服务,`~` 就得当成一个真实依赖来验**,
> 不能假定它存在、也不能假定它可写 —— 尤其是 `useradd -r` 建的系统账号
> (家目录常常压根不建)和开了 `ProtectHome` 的 unit。
>
> 症状的迷惑性在于:报错点在**第三个库**(pyannote),而病灶在**运行环境**(HOME)。
具体到本仓库:`deploy/eai_agentplatform.service` 是同一个形状 —— 主服务现在
不写 `~`,但哪天有依赖要写,会以完全一样的姿势炸。
---
## 待沉淀(还没写进规则的)
- [ ] **装完必须导入自检**:带 C 扩展的包(torch / torchaudio / ctranslate2)装完立刻 import 一次(见 E01)。
@@ -421,3 +629,8 @@ internal/ai/llm.go 的 ctx 签名变更
候选升格为 P06 的一条(「旧权重在新 torch 上要先放行 unpickle 白名单」)。
- [ ] **E06 的通用形状**:GPU 是共享资源,跑之前先 `nvidia-smi` 看**别人**占了多少,
再决定自己的精度档位 —— 候选升格为 P06 的一条。
- [ ] **E12/E13 的通用形状**:**「手工跑得通」不等于「装成服务跑得通」** ——
服务化会同时改掉三件事:运行账号(家目录、权限)、资源隔离(ProtectHome /
ProtectSystem / ReadOnlyPaths)、并发模型(前台独占 vs 后台多请求)。
凡是「原型上验过」的东西,装成服务后必须**照着这三条重验一遍**。
这次的 E13 是账号那条,E12 是并发那条。
@@ -11,7 +11,7 @@
"audio_routes": {
"audio_route_local_whisper": {
"base_url": "http://127.0.0.1:8090/v1",
"description": "语音转写 · 本机 faster-whisper large-v3 + pyannote 3.1(音频不出本机)。由 deploy/eai_agentplatform-asr.service 常驻在 127.0.0.1:8090;服务不在时 audio_route_auto 会改走 fallback_routes 里的云端路由,并在转写产物里标注「音频已出本机」。timeout 定在 840s 而不是一小时:前端 api/audioSkill.js 给这一步的上限是 15 分钟,后端必须在它之前自己收手(否则用户看到的是 axios 超时,而后端还在跑),同时 840s 里还留得下云端那 600s 的回退预算",
"description": "语音转写 · 本机 faster-whisper large-v3 + pyannote 3.1(音频不出本机)。由 deploy/eai_agentplatform-asr.service 常驻在 127.0.0.1:8090;服务不在时 audio_route_auto 会改走 fallback_routes 里的云端路由,并在转写产物里标注「音频已出本机」。timeout 定在 840s 而不是一小时:前端 api/audioSkill.js 给这一步的上限是 15 分钟,后端必须在它之前自己收手(否则用户看到的是 axios 超时,而后端还在跑),同时 840s 里还留得下云端那 600s 的回退预算。回退链分两级:先把同能力(有说话人分离)的路由试完,最后才降级到不分离的路由;一旦降级,转写产物里 capability_degraded=true,第 3 步「识别说话人身份」与第 5/6 步「整理/纪要」判为不可用,只交付逐字稿",
"endpoint": "/audio/transcriptions",
"model": "large-v3",
"provider": "local_asr",
@@ -21,7 +21,7 @@
"timeout_seconds": 840
},
"audio_route_siliconflow_diarize": {
"description": "语音转写 · SiliconFlow / XingChen ASR Diarize(云端,音频会出本机;本地服务不可用时的回退,也可由管理员手动指定)",
"description": "语音转写 · SiliconFlow / XingChen ASR Diarize(云端,音频会出本机;本地服务不可用时的回退,也可由管理员手动指定)。回退链分两级:先把同能力(有说话人分离)的路由试完,最后才降级到不分离的路由;一旦降级,转写产物里 capability_degraded=true,第 3 步「识别说话人身份」与第 5/6 步「整理/纪要」判为不可用,只交付逐字稿",
"endpoint": "/audio/transcriptions",
"model": "XingChenAGI/XingChenASR-Diarize-V3.0",
"provider": "siliconflow",
@@ -31,7 +31,7 @@
"timeout_seconds": 600
},
"audio_route_siliconflow_qwen3": {
"description": "语音转写 · SiliconFlow / Qwen3-ASR(云端,音频会出公网;无说话人分离)",
"description": "语音转写 · SiliconFlow / Qwen3-ASR(云端,音频会出公网;无说话人分离)。实测最快(45 秒片段约 1.4 秒),是降级兜底的首选",
"endpoint": "/audio/transcriptions",
"model": "Qwen/Qwen3-ASR-1.7B",
"provider": "siliconflow",
@@ -39,6 +39,38 @@
"short_route_name": "SiliconFlow",
"supports_speakers": false,
"timeout_seconds": 600
},
"audio_route_openrouter_whisper": {
"description": "语音转写 · OpenRouter / OpenAI whisper-1(云端,音频会出公网;无说话人分离)。实测是几条云端路线里最慢的(45 秒片段约 9 秒),排在同能力链的末位",
"endpoint": "/audio/transcriptions",
"model": "openai/whisper-1",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"short_model_name": "whisper-1",
"short_route_name": "OpenRouter",
"supports_speakers": false,
"timeout_seconds": 600
},
"audio_route_openrouter_gpt4o_transcribe": {
"description": "语音转写 · OpenRouter / OpenAI gpt-4o-transcribe(云端,音频会出公网;无说话人分离)。实测比 whisper-1 快一倍且断句更整,无分离路由里的首选",
"endpoint": "/audio/transcriptions",
"model": "openai/gpt-4o-transcribe",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"short_model_name": "gpt-4o-transcribe",
"short_route_name": "OpenRouter",
"supports_speakers": false,
"timeout_seconds": 600
},
"audio_route_siliconflow_asr_ultra": {
"description": "语音转写 · SiliconFlow / XingChen ASR V3.2 Ultra(云端,音频会出公网;无说话人分离)。同门的 V3.2(非 Ultra)实测同样可用、约快一倍,但效果略差,没有单独建路由",
"endpoint": "/audio/transcriptions",
"model": "XingChenAGI/XingChenASR-V3.2-Ultra",
"provider": "siliconflow",
"short_model_name": "XingChen V3.2 Ultra",
"short_route_name": "SiliconFlow",
"supports_speakers": false,
"timeout_seconds": 600
}
},
"chat_routes": {
@@ -188,7 +220,11 @@
"chat_route_ollama_qwen"
],
"audio_route_local_whisper": [
"audio_route_siliconflow_diarize"
"audio_route_siliconflow_diarize",
"audio_route_siliconflow_qwen3",
"audio_route_siliconflow_asr_ultra",
"audio_route_openrouter_gpt4o_transcribe",
"audio_route_openrouter_whisper"
],
"embed_route_llamacpp_nomic": [
"embed_route_openrouter_text_v3"
@@ -198,6 +234,33 @@
],
"embed_route_openrouter_text_v3": [
"embed_route_ollama_bge_m3"
],
"audio_route_openrouter_gpt4o_transcribe": [
"audio_route_siliconflow_asr_ultra",
"audio_route_openrouter_whisper",
"audio_route_siliconflow_qwen3"
],
"audio_route_siliconflow_asr_ultra": [
"audio_route_openrouter_gpt4o_transcribe",
"audio_route_openrouter_whisper",
"audio_route_siliconflow_qwen3"
],
"audio_route_openrouter_whisper": [
"audio_route_openrouter_gpt4o_transcribe",
"audio_route_siliconflow_asr_ultra",
"audio_route_siliconflow_qwen3"
],
"audio_route_siliconflow_qwen3": [
"audio_route_openrouter_gpt4o_transcribe",
"audio_route_siliconflow_asr_ultra",
"audio_route_openrouter_whisper"
],
"audio_route_siliconflow_diarize": [
"audio_route_local_whisper",
"audio_route_siliconflow_qwen3",
"audio_route_siliconflow_asr_ultra",
"audio_route_openrouter_gpt4o_transcribe",
"audio_route_openrouter_whisper"
]
},
"image_routes": {
@@ -64,6 +64,9 @@
/opt/eai_agentplatform-asr/ # 本地语音转写(独立进程,独立单元)
├── serve.py # OpenAI 兼容 HTTP 端点(/v1/audio/transcriptions、/v1/models)
├── asr_core.py # 转写 + 说话人分离
│ # ↑ 这两个的**事实源在仓库** deploy/asr/,装机时由
│ # install_asr_local.sh 装过来;直接改 /opt 下那份
│ # 会在下次装机时被覆盖,且没人 review 得到
├── asr.env # 环境变量(精度档位、离线开关)
├── venv/ # Python 环境(约 9.4 GB)
├── models/ # 模型权重(约 4.1 GB,实体目录)
@@ -105,10 +108,14 @@ sudo systemctl enable --now eai_agentplatform
curl http://127.0.0.1:8080/api/health # {"status":"ok",...}
# 2.5 本地语音转写服务(ASR)
# 整个 2.5 已经写成脚本,它会先打印计划再执行(--dry-run 只看计划):
sudo bash deploy/install_asr_local.sh
#
# 它做的事(等价的手工命令如下,仅供理解,**别照着敲**):
# 环境本身随原型机整盘克隆过来,这一步只是把它装到交付位置 + 起服务。
# venv 与模型都很大(9.4G + 4.1G),用 mv 而不是 cp —— 两台机器上各留一份没意义。
sudo mkdir -p /opt/eai_agentplatform-asr
sudo cp -a ~/asr-poc/{serve.py,asr_core.py} /opt/eai_agentplatform-asr/
sudo install -m 0644 deploy/asr/{serve.py,asr_core.py} /opt/eai_agentplatform-asr/
sudo mv ~/asr-poc/venv /opt/eai_agentplatform-asr/venv
sudo cp -aL ~/asr-poc/models /opt/eai_agentplatform-asr/models # -L 解开软链,客户机上没有仓库可指
sudo install -m 0644 deploy/asr.env /opt/eai_agentplatform-asr/asr.env
@@ -0,0 +1,80 @@
# 本地语音转写服务(eai_agentplatform-asr)
> 这份文件是给**运维/交付工程师**看的,装完机器后遇到「转写失败」时先读它。
> 服务的 systemd 单元里 `Documentation=` 指向本文件。
---
## 它是什么
一个只监听回环地址的 Python 服务,提供 OpenAI 兼容的转写端点:
| 端点 | 用途 |
|---|---|
| `POST /v1/audio/transcriptions` | 转写(含说话人分离) |
| `GET /v1/models` | 健康探测用的探针端点 |
- **监听**:`127.0.0.1:8090` —— 只回环,不在局域网上暴露
- **模型**:faster-whisper large-v3 + pyannote 3.1
- **为什么要它**:把客户会议录音的转写留在本机。平台默认语音路由指向它,
音频不出本机;它不可用时平台才回退云端,并在界面上标注「音频已离开本机」。
---
## 怎么确认它是好的
```bash
systemctl status eai_agentplatform-asr # active (running)
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/v1/models # 期望 200
journalctl -u eai_agentplatform-asr -n 50 --no-pager
```
平台侧从**后台 → AI 路由 → 语音**看 `本地` 那条的 `healthy` 是否为真;
或直接打后端接口:
```bash
curl -s -H "Authorization: Bearer <token>" http://127.0.0.1:8080/api/ai/routes/audio
```
---
## 它挂了会怎样(重要)
**不会报错给用户,而是静默回退云端。** 这正是需要有人盯着它的原因:
1. `audio_route_auto` 探测到本地不健康 → 改选 `fallback_routes` 里的云端路由;
2. 转写照常成功,但**录音已经出了本机**;
3. 界面在转写结果上标注「音频已离开本机」——这是唯一的用户可见信号。
所以:**如果这台机器的定位是「音频不许出本机」,本服务必须一直 active。**
单元里 `Restart=always` 就是这个意思;不要改成 `on-failure`。
---
## 排障顺序
| 症状 | 先看这里 |
|---|---|
| 服务起不来 | `journalctl -u eai_agentplatform-asr -n 100` |
| CUDA OOM | 显存被别的进程占了(本机 llama-server 常态占约 5G,卡总共 8G)。见 `bugs_and_errors.md` E06 |
| 加载模型挂住不报错 | 是否有分支想回 `huggingface.co`。本机到那边不通,`HF_HUB_OFFLINE=1` / `TRANSFORMERS_OFFLINE=1` 会让它立刻失败而不是挂起 |
| 平台显示本地不健康但服务是好的 | 端口对不上。端口写在 unit 的 `ExecStart --port` 与后端 `config/ai_config.json` 的 `audio_route_local_whisper.base_url` 两处,改一处没用 |
| 平台显示本地不健康,且刚重启过服务 | 平台每 30 分钟探一次。后台点一次保存/重载会立刻重探(`POST /api/ai/reload`) |
---
## 装 / 卸
装:`sudo bash deploy/install_asr_local.sh`(见 `DELIVERY.md` 第 2.5 节)
卸(会删掉 13.5 GB 的 venv 与模型,先确认目标机不再需要本地转写):
```bash
sudo systemctl disable --now eai_agentplatform-asr
sudo rm -rf /opt/eai_agentplatform-asr
sudo rm -f /etc/systemd/system/eai_agentplatform-asr.service
sudo systemctl daemon-reload
```
卸完记得把后台的**默认语音路由**切到云端,否则 `audio_route_auto` 会一直
在一条永远不健康的本地路由上打转,每次都白等一轮探测超时。
@@ -16,6 +16,19 @@ ASR_COMPUTE_TYPE=int8_float16
# /opt/eai_agentplatform-asr/models(软链或实体目录都行,见 DELIVERY.md 第 1 节)。
# 把模型放到别处就得改 asr_core.py,不如把目录放对。
# 家目录指到 cache/,**必须有这一行**。
#
# 原型机上服务以 eaiadmin 跑,~ 可写,所以从没暴露过;装成服务后两件事同时变:
# unit 里 ProtectHome=true 挡住 /home,而系统账号 eai_agentplatform 的家目录
# (/home/eai_agentplatform)压根不存在。pyannote 起步时要写
# ~/.pyannote/database.yml,于是每次转写都以 PermissionError 收场 ——
# 而报错发生在说话人分离那一段,看起来像模型坏了,其实是没地方写配置。
#
# 指到 cache/ 是因为 unit 的 ReadWritePaths 只放开了这一个目录;
# 顺带让 torch / matplotlib 之类的 dotfile 也落在同一个可写位置。
# 换目录要连 unit 的 ReadWritePaths 一起改。
HOME=/opt/eai_agentplatform-asr/cache
# 离线开关。权重全部是本地文件(faster-whisper 直接读目录、pyannote 的
# config.yaml 已改写成本地文件路径),正常情况下一个网络请求都不发。
# 这两个开关是安全网:万一哪条分支想回 huggingface.co,本机到那边是**不通**的,
@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""本地 ASR 核心:faster-whisper large-v3 + pyannote 3.1 说话人分离。
产物刻意做成**后端那个形状**(internal/skills/packages/audio_transcribe/transcribe.go:164-173):
{"duration": 秒, "text": "全文", "segments": [{"speaker","start","end","text"}], "usage": {...}}
所以只要外面套一个 OpenAI 兼容的 HTTP 端点(serve.py),Go 侧一个字都不用改。
三个阶段的代价差很多(wav 几秒 / 转写几分钟 / 分离一两分钟),所以每阶段都往
out_dir 落缓存,重跑读缓存。调分离参数时不该让 large-v3 再跑一遍。
"""
from __future__ import annotations
import gc
import hashlib
import json
import os
import subprocess
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
MODELS = ROOT / "models"
SEG_DIR = MODELS / "pyannote/segmentation-3.0"
EMB_DIR = MODELS / "pyannote/wespeaker-voxceleb-resnet34-LM"
DIA_DIR = MODELS / "pyannote/speaker-diarization-3.1"
FW_DIR = MODELS / "Systran/faster-whisper-large-v3"
# faster-whisper / pyannote 都不是线程安全的,而且显存只有 8G,
# 并发跑两个 large-v3 必爆。整个进程串行化 —— 转写本来就是分钟级的长活,
# 并发几个请求也不会更快。
_LOCK = threading.Lock()
_FW_CACHE = {}
_DIA_CACHE = {}
def log(msg: str):
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
# ── 阶段 1:转 wav ────────────────────────────────────────────────
def ensure_wav(src: Path, out_dir: Path) -> Path:
dst = out_dir / "audio16k.wav"
if dst.exists() and dst.stat().st_size > 0:
return dst
out_dir.mkdir(parents=True, exist_ok=True)
log(f"ffmpeg 转 16k 单声道:{src.name}")
subprocess.run(
# -vn 丢掉 mp3 里那个 mjpeg 封面流。不丢的话 pyannote 读音频时会被它绊到,
# 报出来的错跟「音频格式不对」一模一样,很难看出是封面。
["ffmpeg", "-y", "-v", "error", "-i", str(src), "-vn",
"-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", str(dst)],
check=True,
)
log(f"wav 就绪:{dst.stat().st_size/1048576:.1f} MB")
return dst
# ── 阶段 2:转写 ──────────────────────────────────────────────────
# 本机显存只有 8 GiB,而 llama-server(仓库 README 里 8080/8081 那两个常驻服务)
# 常年占着约 5 GiB,留给我们的实际不到 2.6 GiB —— large-v3 的 float16 权重
# 本身就要 3 GB,一加载就 `CUDA failed with error out of memory`。
# 所以默认用 int8_float16(权重压到一半左右,实测 2.5 GiB 空闲下装得下),
# 并用 ASR_COMPUTE_TYPE 留一个旋钮:显存宽裕时可以调回 float16 换精度。
_COMPUTE_LADDER = [
os.environ.get("ASR_COMPUTE_TYPE", "int8_float16"),
"int8", # 再小一档
]
def _fw_model():
if "m" not in _FW_CACHE:
import torch
from faster_whisper import WhisperModel
# 先请走 pyannote:它上一次跑完还占着约 2.6G,不清掉这里必 OOM。
# 放在阶梯循环**之前**,否则 int8_float16 / int8 两档会白撞两次。
_free_pipe()
last = None
for ct in dict.fromkeys(_COMPUTE_LADDER): # 去重且保序
free = torch.cuda.mem_get_info()[0] / 2**30
log(f"加载 faster-whisper large-v3(本地目录,不走网络,"
f"compute_type={ct},当前空闲显存 {free:.2f} GiB)")
try:
_FW_CACHE["m"] = WhisperModel(
str(FW_DIR), device="cuda", compute_type=ct, num_workers=1,
)
if ct != _COMPUTE_LADDER[0]:
log(f"注意:{_COMPUTE_LADDER[0]} 装不下,已退到 {ct}(精度略降)")
break
except Exception as e:
last = e
log(f"{ct} 加载失败:{type(e).__name__}: {e}")
else:
# 一档都装不下就别再试了 —— 报出显存实况,比抛一句 CUDA OOM 有用。
holders = subprocess.run(
["nvidia-smi", "--query-compute-apps=pid,used_memory,process_name",
"--format=csv,noheader"],
capture_output=True, text=True).stdout.strip()
raise RuntimeError(
f"faster-whisper large-v3 在本机显存里装不下(试过 "
f"{list(dict.fromkeys(_COMPUTE_LADDER))})。\n"
f"当前占显存的进程:\n{holders}\n"
f"处置:腾出显存,或把 ASR_COMPUTE_TYPE 调得更小。"
) from last
return _FW_CACHE["m"]
def _free_fw():
"""把 whisper 请出显存。
本机只剩两三百 MB 余量,whisper 和 pyannote 同时驻留必炸 ——
转写和分离是先后关系,没有并存的理由,所以加载另一个之前先放掉它。
"""
if _FW_CACHE.pop("m", None) is None:
return
gc.collect()
import torch
torch.cuda.empty_cache()
log(f"已释放 whisper 显存,现空闲 {torch.cuda.mem_get_info()[0]/2**30:.2f} GiB")
def _free_pipe():
"""把 pyannote 管线请出显存。
这是 `_free_fw` 的对称面,缺了它服务只能转写**一次**:
显存只有 8G,llama-server 常态占掉约 5G。第一次请求转写完会加载 pyannote
并把它挪上 CUDA,然后一直缓存在 `_DIA_CACHE` 里 —— 原先没有任何地方释放它。
于是第二次请求去加载 whisper 时,空闲显存只剩零点几 G,int8_float16 和 int8
两档接连 OOM,用户看到的是「第一次能转,之后每次都失败」。
原型机是跑一次看一次结果,暴露不出来;接成常驻服务后这是必现的。
"""
if _DIA_CACHE.pop("p", None) is None:
return
gc.collect()
import torch
torch.cuda.empty_cache()
log(f"已释放 pyannote 显存,现空闲 {torch.cuda.mem_get_info()[0]/2**30:.2f} GiB")
def transcribe(wav: Path, out_dir: Path, language: str = "zh") -> dict:
cache = out_dir / "whisper_raw.json"
if cache.exists() and cache.stat().st_size > 0:
return json.loads(cache.read_text())
model = _fw_model()
t0 = time.time()
segments, info = model.transcribe(
str(wav),
language=language or None,
beam_size=5,
vad_filter=True, # 长会议里静音很多,先切掉
vad_parameters={"min_silence_duration_ms": 500},
word_timestamps=True, # 按时间把说话人贴到词上要用
condition_on_previous_text=False, # 长音频上它会放大幻觉,必须关
)
log(f"音频 {info.duration/60:.1f} 分钟,语言 {info.language}"
f"(置信度 {info.language_probability:.2f})")
out = []
for i, seg in enumerate(segments):
out.append({
"start": seg.start, "end": seg.end, "text": seg.text.strip(),
"words": [{"word": w.word, "start": w.start, "end": w.end}
for w in (seg.words or [])
if w.start is not None and w.end is not None],
})
if (i + 1) % 100 == 0:
log(f" 已转写 {i+1} 段,进度 {seg.end/60:.1f}/{info.duration/60:.1f} 分钟")
took = time.time() - t0
log(f"转写完成:{len(out)} 段,{took/60:.1f} 分钟({info.duration/took:.1f}x 实时)")
result = {"duration": info.duration, "language": info.language, "segments": out}
cache.write_text(json.dumps(result, ensure_ascii=False))
return result
# ── 阶段 3:说话人分离 ────────────────────────────────────────────
def _ckpt(repo_dir: Path) -> dict:
"""把本地模型目录说成 pyannote 认得的形式。
pyannote 的 `Model.from_pretrained(x)` 只认**文件**:`os.path.isfile(x)` 不成立
就当成 HF repo id 去联网(core/model.py:588)。传目录会在 huggingface_hub 的
`validate_repo_id` 上炸成 HFValidationError,看着像「路径写错了」,其实是
「它压根没打算读目录」。传 dict 走的是 `Model.from_pretrained(**dict)` 那一支
(pipelines/utils/getter.py:81),checkpoint 指到 .bin 就通了。
不给 hparams_file:权重里自带 PL 的 hparams,而 repo 里那份 config.yaml 是
模型结构配置、没有 `task:` 段,塞进去只会换来 `Missing key setup`。
"""
return {"checkpoint": str(repo_dir / "pytorch_model.bin")}
def _allow_torch_load():
"""放行 pyannote 权重里那几个类,否则 torch>=2.6 一律拒绝加载。
torch 2.6 起 `torch.load` 的 `weights_only` 默认从 False 翻成 True,
pickle 里没在白名单上的全局符号直接拒载。pyannote 3.x 的 .bin 里存着 4 个
数据类(TorchVersion / Specifications / Problem / Resolution),于是
`Pipeline.from_pretrained` 会抛一大段「Weights only load failed」。
这里只放行这 4 个**数据类**,不碰 `weights_only=False` —— 后者等于把
反序列化变成任意代码执行。权重是我们自己下的、有 SHA256SUMS 对过,
但没必要为此把整扇门打开。
"""
import torch
from pyannote.audio.core.task import Problem, Resolution, Specifications
torch.serialization.add_safe_globals(
[torch.torch_version.TorchVersion, Specifications, Problem, Resolution])
def _pipeline():
"""加载 pyannote 管线(带缓存)。
读的是**加工件**里的 config.yaml,它由 `build_ready.sh` 从 raw 生成,
已把两个 HF repo id 改写成 raw 里的本地路径 —— 照原样读会去连
huggingface.co,而本机不通。除那两行外其余参数(含那两个阈值)逐字未改。
路线 B 是兜底:万一 pyannote 升级后不认这份 3.1 配置,就手工组装,阈值照抄
config.yaml。两条路都失败就原样抛出 —— 绝不吞掉错误退化成「没有说话人」,
那种失败从结果上跟「音频里真的只有一个人」长得一模一样,最难查。
"""
if "p" in _DIA_CACHE:
return _DIA_CACHE["p"]
# 与 _fw_model 里的 _free_pipe 对称:两个模型在 8G 卡上不能并存。
# 有了这一步,`run()` 里那句「转写完先放掉 whisper」就多余了 ——
# 谁都不用记住「先放谁」,那是加载器自己的事。
_free_fw()
_allow_torch_load()
import pyannote.audio as pa
from pyannote.audio import Model, Pipeline
log(f"pyannote {pa.__version__}")
try:
log("路线 A:读本地 config.yaml 加载管线")
# 必须给到 config.yaml 这个**文件**:给的目录会被当成 repo id(见 _ckpt)。
pipe = Pipeline.from_pretrained(str(DIA_DIR / "config.yaml"))
log("路线 A 成功")
except Exception as e:
log(f"路线 A 失败:{type(e).__name__}: {e}")
log("路线 B:手工组装(两个子模型直接吃本地 .bin)")
from pyannote.audio.pipelines import SpeakerDiarization
pipe = SpeakerDiarization(
segmentation=Model.from_pretrained(**_ckpt(SEG_DIR)),
embedding=Model.from_pretrained(**_ckpt(EMB_DIR)),
clustering="AgglomerativeClustering",
)
# 两个阈值照抄 speaker-diarization-3.1/config.yaml,不自己调。
pipe.instantiate({
"clustering": {"method": "centroid", "min_cluster_size": 12,
"threshold": 0.7045654963945799},
"segmentation": {"min_duration_off": 0.0},
})
log("路线 B 成功")
try:
import torch
pipe.to(torch.device("cuda"))
log("管线已挪到 GPU")
except Exception as e:
log(f"挪 GPU 失败,用 CPU:{e}")
_DIA_CACHE["p"] = pipe
return pipe
def diarize(wav: Path, out_dir: Path, num_speakers: int | None = None) -> dict:
salt = f".n{num_speakers}" if num_speakers else ""
cache = out_dir / f"diarization{salt}.json"
if cache.exists() and cache.stat().st_size > 0:
return json.loads(cache.read_text())
pipe = _pipeline()
t0 = time.time()
kw = {"num_speakers": num_speakers} if num_speakers else {}
log("开始说话人分离…")
ann = pipe(str(wav), **kw)
turns = [{"start": float(t.start), "end": float(t.end), "speaker": str(lab)}
for t, _, lab in ann.itertracks(yield_label=True)]
speakers = sorted({t["speaker"] for t in turns})
log(f"分离完成:{len(turns)} 个轮次,{len(speakers)} 个说话人 {speakers},"
f"{(time.time()-t0)/60:.1f} 分钟")
result = {"turns": turns, "speakers": speakers}
cache.write_text(json.dumps(result, ensure_ascii=False))
return result
# 短于这个字数的碎片不单独成段。理由见 _absorb_stray_groups。
_MIN_GROUP_CHARS = 4
def _absorb_stray_groups(groups: list) -> list:
"""把一闪而过的「说话人碎片」并回邻居。
说话人边界跟词边界对不齐时,会出现「记」/「住这个要」/「求」这种被切碎的段 ——
实测一份 26 分钟录音里 693 段中有一大批是这么来的,读起来像乱码。
这不是分离算错了,是**切点太细**:pyannote 的边界落在词中间,
而按词投票只能整词归属,于是边界两侧各留下半截。
处置:字数不到 _MIN_GROUP_CHARS 的组不单独成段,并进相邻的组
(优先并给前一组;段首没有前一组就并给后一组)。
并进去等于承认「这一两个词归属存疑」,比切出半截字更接近事实,
也比硬判给某一方诚实。
"""
if len(groups) <= 1:
return groups
def chars(g) -> int:
return len("".join(w["word"] for _, w in g).strip())
out: list = []
for g in groups:
if out and chars(g) < _MIN_GROUP_CHARS:
out[-1].extend(g) # 并给前一组
else:
out.append(list(g))
# 首组可能自己就是碎片(上面没有前一组可并),回头并给后来变成首组的那一组。
# 注意要先 pop 再取 out[0]:pop 之后原 out[1] 才在索引 0 上。
if len(out) > 1 and chars(out[0]) < _MIN_GROUP_CHARS:
first = out.pop(0)
out[0][:0] = first
return out
# ── 阶段 4:拼装 ──────────────────────────────────────────────────
def assign_speakers(transcript: dict, diar: dict) -> dict:
"""把说话人贴到 whisper 段上。
按**词**投票而不是整段取最大重叠:中文会议里一个 whisper 段跨两个人很常见,
按整段会把少数那个人的话一起判给多数,云端 diarize 模型不会犯这个错,
我们自己拼装就得自己处理掉。
"""
turns = diar["turns"]
def spk_of(a: float, b: float) -> str:
best, best_ov = None, 0.0
for t in turns:
ov = min(b, t["end"]) - max(a, t["start"])
if ov > best_ov:
best, best_ov = t, ov
if best is not None:
return best["speaker"]
if not turns:
return ""
# 词落在两轮之间的缝里:归给最近的一轮,不留空。
# 留空会让这一段没有 speaker,前端会当成「这次没做分离」。
mid = (a + b) / 2
return min(turns, key=lambda t: min(abs(mid - t["start"]),
abs(mid - t["end"])))["speaker"]
segments = []
for seg in transcript["segments"]:
words = seg.get("words") or []
if not words:
segments.append({"speaker": spk_of(seg["start"], seg["end"]),
"start": seg["start"], "end": seg["end"],
"text": seg["text"]})
continue
labelled = [(spk_of(w["start"], w["end"]), w) for w in words]
groups, cur = [], [labelled[0]]
for spk, w in labelled[1:]:
if spk == cur[0][0]:
cur.append((spk, w))
else:
groups.append(cur)
cur = [(spk, w)]
groups.append(cur)
groups = _absorb_stray_groups(groups)
for grp in groups:
text = "".join(w["word"] for _, w in grp).strip()
if not text:
continue
segments.append({"speaker": grp[0][0],
"start": round(grp[0][1]["start"], 3),
"end": round(grp[-1][1]["end"], 3),
"text": text})
text = "\n".join(f"说话人{s['speaker']}:{s['text']}" for s in segments)
speakers = sorted({s["speaker"] for s in segments if s["speaker"]},
key=lambda x: int(x) if x.isdigit() else 0)
return {
"duration": transcript["duration"],
"language": transcript.get("language", ""),
"text": text,
"segments": segments,
"speakers": speakers,
}
def run(audio: Path, out_dir: Path, language: str = "zh",
num_speakers: int | None = None, stages: str = "wav,transcribe,diarize,merge") -> dict:
"""完整跑一遍,返回后端要的形状。out_dir 里落各阶段缓存。"""
out_dir.mkdir(parents=True, exist_ok=True)
want = {s.strip() for s in stages.split(",") if s.strip()}
t0 = time.time()
with _LOCK:
wav = out_dir / "audio16k.wav"
if "wav" in want:
wav = ensure_wav(audio, out_dir)
transcript = transcribe(wav, out_dir, language) if "transcribe" in want else \
json.loads((out_dir / "whisper_raw.json").read_text())
# 这里曾经有一句 `if "diarize" in want: _free_fw()`。
# 现已移进 `_pipeline()`:腾显存是加载方的事,不是调用方要记住的规矩 ——
# 放在调用方时,只要有一条路径绕开它就会 OOM(`_fw_model` 那侧原来就是这样)。
diar = diarize(wav, out_dir, num_speakers) if "diarize" in want else \
json.loads((out_dir / "diarization.json").read_text())
merged = assign_speakers(transcript, diar)
merged["model"] = "faster-whisper-large-v3 + pyannote/speaker-diarization-3.1(本地)"
merged["elapsed_seconds"] = round(time.time() - t0, 1)
(out_dir / "asr_result.json").write_text(
json.dumps(merged, ensure_ascii=False, indent=1))
(out_dir / "transcript.txt").write_text(merged["text"])
log(f"合并完成:{len(merged['segments'])} 段,{len(merged['speakers'])} 个说话人,"
f"正文 {len(merged['text'])} 字,总耗时 {merged['elapsed_seconds']/60:.1f} 分钟")
return merged
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""把本地 ASR 包成一个 OpenAI 兼容的 /v1/audio/transcriptions 端点。
存在的意义:后端 internal/skills/packages/audio_transcribe/transcribe.go 是按
OpenAI 兼容协议写的(multipart 发 model/language/file,读回
{duration,text,segments:[{speaker,start,end,text}]})。只要本地服务说同一套协议,
**Go 侧一行都不用改**,改的是 config/ai_config.json 里那条 audio_routes 的 base_url。
用法:
./venv/bin/python serve.py --port 8090
然后 config/ai_config.json 加:
"audio_route_local_whisperx": {
"provider": "local_asr", "base_url": "http://127.0.0.1:8090/v1",
"endpoint": "/audio/transcriptions", "model": "large-v3",
"supports_speakers": true, "timeout_seconds": 3600
}
⚠️ 光改 base_url 还不够:transcribe.go:85 在 APIKey 为空时直接报错,而 key 只从
ProviderSecretKey[provider] 映射来。所以还得往 internal/config/json_loader.go 的
ProviderSecretKey 加一行 "local_asr": "LOCAL_ASR_API_KEY",并在 ai_secrets.json
里塞个占位值(本地服务不校验它,但 Go 侧要求非空)。
curl -s -X POST http://127.0.0.1:8090/v1/audio/transcriptions \
-H "Authorization: Bearer local" -F file=@x.mp3 -F model=large-v3 -F language=zh
"""
import argparse
import hashlib
import json
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import asr_core # noqa: E402
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile # noqa: E402
from fastapi.responses import JSONResponse # noqa: E402
from starlette.concurrency import run_in_threadpool # noqa: E402
import uvicorn # noqa: E402
CACHE = asr_core.ROOT / "cache"
CACHE.mkdir(exist_ok=True)
app = FastAPI(title="本地 ASR(OpenAI 兼容)", version="0.1.0")
STARTED_AT = time.time()
@app.get("/health")
def health():
return {
"status": "ok",
"uptime_seconds": round(time.time() - STARTED_AT, 1),
"model": "faster-whisper-large-v3 + pyannote/speaker-diarization-3.1",
"models_dir": str(asr_core.MODELS),
}
@app.get("/v1/models")
def models():
# 后端不查这个端点,但 OpenAI 兼容服务一般都有,留着方便人工确认。
return {"object": "list",
"data": [{"id": "large-v3", "object": "model", "owned_by": "local"}]}
@app.post("/v1/audio/transcriptions")
async def transcriptions(
file: UploadFile = File(...),
model: str = Form("large-v3"),
language: str = Form(""),
response_format: str = Form("json"),
num_speakers: str = Form(""),
authorization: str = Header(default=""),
):
# 后端会带 Authorization: Bearer <key>。这里只要求「有」,不校验具体值 ——
# 本地服务绑 127.0.0.1,本来就不对外。
if not authorization.strip():
raise HTTPException(401, "缺少 Authorization 头")
raw = await file.read()
if not raw:
raise HTTPException(400, "上传的音频是空的")
digest = hashlib.sha256(raw).hexdigest()
work = CACHE / digest
cached = work / "asr_result.json"
# 按内容哈希缓存:同一份音频重发(比如后端重试、或我复跑)不该再烧一次 GPU。
if cached.exists() and cached.stat().st_size > 0:
asr_core.log(f"命中缓存 {digest[:12]},直接返回")
return JSONResponse(_to_openai(json.loads(cached.read_text()), model))
work.mkdir(parents=True, exist_ok=True)
suffix = Path(file.filename or "audio.mp3").suffix or ".mp3"
src = work / f"source{suffix}"
if not src.exists():
src.write_bytes(raw)
n = int(num_speakers) if num_speakers.strip().isdigit() else None
asr_core.log(f"新请求 {digest[:12]}:{file.filename} "
f"({len(raw)/1048576:.1f} MB, language={language or 'auto'})")
try:
# 丢到工作线程去跑,**不能在事件循环里直接调**。
#
# asr_core.run 是分钟级的阻塞调用(26 分钟音频实测 3 分钟),
# 而 uvicorn 只有一个事件循环:直接调的话,这段时间里 /v1/models
# 一个字都回不了。平台的健康探测正是打 /v1/models 的,于是
# 「正在转写」被读成「本地服务挂了」→ audio_route_auto 解到云端路由 →
# 下一个任务的音频就出公网了。整个过程没有任何报错。
#
# 转写本身仍然是串行的(asr_core 里的 _LOCK),这里只是把「等 GPU」
# 从事件循环里挪出去,让探活和排队中的请求还能被受理。
result = await run_in_threadpool(asr_core.run, src, work, language or "zh", n)
except Exception as e:
# 不吞异常:ASR 失败必须让调用方看见 5xx,不能返回一个空稿当成功。
# 后端那边对「空文本」也是按失败处理的(transcribe.go:203)。
asr_core.log(f"失败:{type(e).__name__}: {e}")
raise HTTPException(500, f"本地 ASR 失败:{type(e).__name__}: {e}")
return JSONResponse(_to_openai(result, model))
def _to_openai(result: dict, model: str) -> dict:
"""转成 OpenAI 兼容响应。
`usage` 里带 duration 是照着 SiliconFlow 的 diarize 模型来的 —— 后端
ai/credits.go 按这个记用量。本地跑不要钱,但字段留着,免得下游解析时
因为缺字段而报错。
"""
return {
"text": result["text"],
"duration": result["duration"],
"language": result.get("language", ""),
"model": model,
"segments": result["segments"],
"usage": {"type": "duration", "seconds": result["duration"]},
# 非标准字段,方便人工核对;后端只读上面那几个,多给的会被忽略。
"_local": {"speakers": result.get("speakers", []),
"elapsed_seconds": result.get("elapsed_seconds")},
}
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8090)
args = ap.parse_args()
asr_core.log(f"本地 ASR 服务启动于 http://{args.host}:{args.port}")
asr_core.log(f"模型目录 {asr_core.MODELS}")
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
#
# 把原型机上的本地语音转写(~/asr-poc)安装成 systemd 常驻服务。
#
# 做的事就是 DELIVERY.md 第 2.5 节,只是写成了可重复执行的脚本:
# 建账号 → 搬 venv/模型到 /opt/eai_agentplatform-asr → 装 unit 与 env →
# enable --now → 自检。
#
# 用法:
# sudo bash deploy/install_asr_local.sh # 先打印计划,再执行
# sudo bash deploy/install_asr_local.sh --dry-run # 只看计划
#
# 幂等:重复执行不会重搬 venv/模型(那两项是按「目标不存在才做」判断的)。
#
set -euo pipefail
SRC_DIR="${ASR_SRC_DIR:-$(getent passwd "${SUDO_USER:-root}" | cut -d: -f6)/asr-poc}"
DST_DIR=/opt/eai_agentplatform-asr
SVC_USER=eai_agentplatform
UNIT=/etc/systemd/system/eai_agentplatform-asr.service
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
say() { printf ' %s\n' "$*"; }
step() { printf '\n== %s\n' "$*"; }
run() {
if [[ "$DRY_RUN" -eq 1 ]]; then
printf ' [dry-run] %s\n' "$*"
else
"$@"
fi
}
die() { printf '\n[FATAL] %s\n' "$*" >&2; exit 1; }
# ── 0. 前置检查 ────────────────────────────────────────────────
step "0/7 前置检查"
[[ "$(id -u)" -eq 0 ]] || die "需要 root:sudo bash $0"
say "root ✓"
# 服务代码(serve.py / asr_core.py)从**仓库**取,不从 $SRC_DIR 取。
#
# 原先是从原型目录 cp 的,那是错的:DELIVERY.md 的清理计划里明确写着装完要
# `rm -rf ~/asr-poc`,也就是这服务的唯一副本会变成 /opt 下那份 root 所有、
# 不在任何版本库里的文件 —— 改动没法 review、丢了没法重建。
# 现在仓库里的 deploy/asr/ 是唯一事实源,$SRC_DIR 只负责 venv 与模型(那两样
# 太大,天生不该进仓库)。
for f in serve.py asr_core.py; do
[[ -f "$DEPLOY_DIR/asr/$f" ]] || die "缺少 $DEPLOY_DIR/asr/$f"
done
say "服务代码 $DEPLOY_DIR/asr ✓"
# 只有 venv 与模型还需要原型目录。
[[ -d "$SRC_DIR" ]] || die "找不到原型目录 $SRC_DIR —— 用 ASR_SRC_DIR=... 指定"
say "原型目录 $SRC_DIR ✓"
# venv 只在「还没装过」时才是必需的
if [[ ! -d "$DST_DIR/venv" ]]; then
[[ -x "$SRC_DIR/venv/bin/python" ]] || die "$SRC_DIR/venv/bin/python 不可执行,venv 不完整"
say "venv ✓"
else
say "venv 已就位,本次不搬(幂等)"
fi
# 模型:原型上是软链指向仓库,交付机上必须是实体目录
if [[ ! -d "$DST_DIR/models" ]]; then
[[ -e "$SRC_DIR/models" ]] || die "$SRC_DIR/models 不存在"
if [[ -L "$SRC_DIR/models" ]]; then
say "模型是软链($(readlink "$SRC_DIR/models"))—— 会解开拷成实体目录"
fi
need_kb=$(( $(du -sL "$SRC_DIR/models" | cut -f1) / 1024 + 1024 ))
free_kb=$(df -Pk "$(dirname "$DST_DIR")" | awk 'NR==2{print $4}')
(( free_kb > need_kb )) || die "磁盘不够:需要约 ${need_kb}MB,可用 ${free_kb}MB"
say "磁盘余量 ${free_kb}MB ≥ 需要 ${need_kb}MB ✓"
else
say "模型已就位,本次不拷(幂等)"
fi
for f in asr.env eai_agentplatform-asr.service OFFLINE.md; do
[[ -f "$DEPLOY_DIR/$f" ]] || die "缺少交付件 $DEPLOY_DIR/$f"
done
say "交付件齐备 ✓"
# 8090 上如果不是本服务在听,先让开,否则新服务起不来。
#
# 只认「cmdline 里含 serve.py」的那一个 PID —— 不做按名字的模糊匹配。
# 这台机器上还跑着别的常驻服务(10231/10232 的 dev server、llama-server),
# 模糊匹配会误杀它们。
port_pid() {
command -v ss >/dev/null 2>&1 || return 1
ss -ltnp "( sport = :8090 )" 2>/dev/null \
| sed -n '2,$p' | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2
}
STALE_PID="$(port_pid || true)"
if [[ -n "${STALE_PID:-}" ]]; then
if systemctl is-active --quiet eai_agentplatform-asr 2>/dev/null; then
say "8090 是本服务的上一实例,稍后 restart 接管"
elif tr '\0' ' ' < "/proc/$STALE_PID/cmdline" 2>/dev/null | grep -q 'serve\.py'; then
say "8090 上跑着原型机的 serve.py(PID $STALE_PID),安装时会让它退出"
run kill "$STALE_PID"
sleep 2
run kill -9 "$STALE_PID" 2>/dev/null || true
else
say "8090 被一个**不是**本服务的进程占用:"
ps -o pid=,args= -p "$STALE_PID" 2>/dev/null | sed 's/^/ /'
die "请先手动停掉它,本服务才能绑定 8090"
fi
fi
# ── 1. 账号 ────────────────────────────────────────────────────
step "1/7 服务账号 $SVC_USER"
if id "$SVC_USER" >/dev/null 2>&1; then
say "已存在,跳过"
else
run useradd -r -s /usr/sbin/nologin "$SVC_USER"
say "已创建(系统账号,不可登录)"
fi
# ── 2. 目录与代码 ──────────────────────────────────────────────
step "2/7 目录与代码 → $DST_DIR"
run mkdir -p "$DST_DIR"
run install -m 0644 "$DEPLOY_DIR/asr/serve.py" "$DEPLOY_DIR/asr/asr_core.py" "$DST_DIR/"
run install -m 0644 "$DEPLOY_DIR/OFFLINE.md" "$DST_DIR/OFFLINE.md"
say "serve.py / asr_core.py / OFFLINE.md"
# ── 3. venv ───────────────────────────────────────────────────
step "3/7 venv(约 9.4 GB)"
if [[ -d "$DST_DIR/venv" ]]; then
say "已就位,跳过"
else
# 用 mv 不用 cp:同一个文件系统上是重命名,瞬时且不占额外空间。
# 两台机器上各留一份 venv 没有意义,DELIVERY.md 也是这么写的。
run systemctl stop eai_agentplatform-asr 2>/dev/null || true
run mv "$SRC_DIR/venv" "$DST_DIR/venv"
say "已从 $SRC_DIR/venv 移入(原型目录将不再能独立运行)"
fi
# ── 4. 模型 ───────────────────────────────────────────────────
step "4/7 模型(约 4.1 GB)"
if [[ -d "$DST_DIR/models" ]]; then
say "已就位,跳过"
else
# -L 解开软链:原型机上 models 指向仓库的 external_download/,
# 客户机上没有仓库可指,必须落成实体目录。
run cp -aL "$SRC_DIR/models" "$DST_DIR/models"
say "已拷成实体目录"
fi
# ── 5. 权限 ───────────────────────────────────────────────────
step "5/7 权限与 cache 目录"
run chown -R "$SVC_USER:$SVC_USER" "$DST_DIR"
run mkdir -p "$DST_DIR/cache"
run chown "$SVC_USER:$SVC_USER" "$DST_DIR/cache"
# 只读是刻意的:ProtectSystem=strict 下服务只能写 cache/,
# venv 与模型保持只读,跑起来之后谁也别想改。
run chmod -R a-w "$DST_DIR/venv" "$DST_DIR/models"
run chmod u+w "$DST_DIR/cache"
say "venv/模型 只读;cache/ 可写"
# ── 6. 单元与 env ─────────────────────────────────────────────
step "6/7 systemd 单元与 env"
run install -m 0644 "$DEPLOY_DIR/asr.env" "$DST_DIR/asr.env"
run install -m 0644 "$DEPLOY_DIR/eai_agentplatform-asr.service" "$UNIT"
run systemctl daemon-reload
say "已安装 $UNIT"
# ── 7. 启动与自检 ─────────────────────────────────────────────
step "7/7 启动与自检"
if [[ "$DRY_RUN" -eq 1 ]]; then
say "[dry-run] 到此为止,未做任何改动"
exit 0
fi
systemctl enable --now eai_agentplatform-asr >/dev/null
printf ' [.] 等待 8090 就绪(首次加载模型要几十秒)'
ok=0
for _ in $(seq 1 90); do
if curl -fsS -m 2 -o /dev/null http://127.0.0.1:8090/v1/models 2>/dev/null; then
ok=1; break
fi
printf '.'; sleep 2
done
printf '\n'
if [[ "$ok" -eq 1 ]]; then
echo " [OK] 本地语音转写服务就绪 (127.0.0.1:8090)"
else
echo " [FAIL] 90 次探测都没起来。看日志:"
echo " journalctl -u eai_agentplatform-asr -n 80 --no-pager"
echo " —— 若报 CUDA OOM,多半是显存被 llama-server 占了,见 bugs_and_errors.md E06"
exit 1
fi
cat <<'EOF'
下一步(不是必须,但建议做):
1) 确认平台侧认到了:后台 → AI 路由 → 语音,看「本地」那条 healthy 是否为真。
平台每 30 分钟探一次,想立刻重探就打一次 POST /api/ai/reload。
2) 确认「默认语音路由」指向本地 —— 这才是「优先本地」的开关。
EOF
@@ -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)
}
}
@@ -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]
@@ -0,0 +1,121 @@
package skillapi
import (
"encoding/json"
"strings"
"testing"
"eai_agentplatform/backend/internal/model"
audiotranscribe "eai_agentplatform/backend/internal/skills/packages/audio_transcribe"
)
// 这一组用例钉的是「降级」这件事在**产物里的往返**:第 2 步写进去的字段,
// 必须原样被下游读出来。
//
// 为什么值得单独写:这条链上全是**字符串 key**,没有一处会被编译器检查。
// 把 capability_degraded 拼错一个字母,Go 照样编译,`go test` 照样全绿,
// 而结果是降级提示不再出现、闸门也不再判「本次没有说话人分离」——
// 用户拿到一份看不出残缺的产物,依赖说话人身份的几步却已经在后台跑不了了。
//
// 手写 JSON 的用例(audio_speakers_gate_test.go)盖不到这一段:它们自己造
// ContentJSON,验的是读的一侧。这里验的是写的一侧,以及写读之间对得上。
// degradedResult 造一个「主路由能分离、实际跑的这条不能」的转写结果。
func degradedResult() *audiotranscribe.Result {
return &audiotranscribe.Result{
Text: "测试转写内容",
Language: "zh",
Duration: 12.5,
// 无分离 ASR 的输出:segment 在,speaker 是空的。
Segments: []audiotranscribe.Segment{{Speaker: "", Start: 0, End: 12.5, Text: "测试转写内容"}},
HasSpeakers: false,
Model: "XingChenAGI/XingChenASR-V3.2-Ultra",
RouteID: "audio_route_siliconflow_asr_ultra",
IsLocal: false,
FellBack: true,
// 关键:本该用的那条能分离,实际这条不能。
PrimaryRouteID: "audio_route_local_whisper",
CapabilityDegraded: true,
}
}
// TestTranscribeArtifactCarriesCapabilityDegraded 产物里必须带着降级的两个字段。
func TestTranscribeArtifactCarriesCapabilityDegraded(t *testing.T) {
output := transcribeArtifactOutput(
degradedResult(), model.MediaFile{ID: 1, Filename: "a.mp3"}, "转写正文", "zh")
if got, ok := output["capability_degraded"].(bool); !ok || !got {
t.Errorf("产物里 capability_degraded = %#v,期望 true —— "+
"界面靠它显示「本次没有说话人分离,后续步骤不可用」", output["capability_degraded"])
}
zh, _ := output["capability_zh"].(string)
if !strings.Contains(zh, "后续步骤不可用") {
t.Errorf("capability_zh 没有说清后果:%q", zh)
}
if !strings.Contains(zh, "逐字稿") {
t.Errorf("capability_zh 没有告诉用户逐字稿还在(那是这次唯一拿得到的东西):%q", zh)
}
}
// TestTranscribeArtifactNotDegradedByDefault 没降级时不许留下这句话。
//
// 反向断言不能省:一个永远返回 true 的字段会让每一条正常转写都顶着
// 「本次没有说话人分离」的红字,闸门也会把所有任务拦在第 5 步。
func TestTranscribeArtifactNotDegradedByDefault(t *testing.T) {
result := degradedResult()
result.CapabilityDegraded = false
result.HasSpeakers = true
result.Segments[0].Speaker = "0"
output := transcribeArtifactOutput(
result, model.MediaFile{ID: 1, Filename: "a.mp3"}, "转写正文", "zh")
if got, _ := output["capability_degraded"].(bool); got {
t.Error("没降级却标了 capability_degraded —— 正常转写会被当成缺了说话人分离")
}
if zh, _ := output["capability_zh"].(string); zh != "" {
t.Errorf("没降级时 capability_zh 该是空串,实际 %q", zh)
}
}
// TestTranscribeArtifactOutputRoundTripsToGate 是这一组里最关键的一条:
// 把产物**序列化成 JSON**(persistAudioStep 就是这么落库的)再交给下游那两个
// 读它的函数,必须得出「本次没有说话人分离」。
//
// 直接调写和读的函数而不比字段名是刻意的:要比的正是「写进去的 key」
// 与「读出来的 key」是同一个 —— 两边各写一遍字面量,正是这类 bug 的温床。
func TestTranscribeArtifactOutputRoundTripsToGate(t *testing.T) {
output := transcribeArtifactOutput(
degradedResult(), model.MediaFile{ID: 1, Filename: "a.mp3"}, "转写正文", "zh")
raw, err := json.Marshal(output)
if err != nil {
t.Fatalf("产物序列化失败:%v", err)
}
contentJSON := string(raw)
if !audioTranscriptDegraded(contentJSON) {
t.Error("产物里写着降级,落库后再读却读不出降级 —— " +
"第 3 步会把「本次没有说话人分离」误报成「配了分离却没输出」")
}
if keys := audiotranscribe.SpeakerKeysOf(contentJSON); len(keys) != 0 {
t.Errorf("降级产物理应读不出说话人标签,实际读到 %v", keys)
}
// 有标签的那一次走同一个往返,确保上面不是「永远读不出标签」的假绿。
kept := degradedResult()
kept.CapabilityDegraded = false
kept.HasSpeakers = true
kept.Segments[0].Speaker = "0"
raw2, err := json.Marshal(transcribeArtifactOutput(
kept, model.MediaFile{ID: 1, Filename: "a.mp3"}, "转写正文", "zh"))
if err != nil {
t.Fatalf("产物序列化失败:%v", err)
}
if audioTranscriptDegraded(string(raw2)) {
t.Error("没降级的产物被读成了降级")
}
if keys := audiotranscribe.SpeakerKeysOf(string(raw2)); len(keys) != 1 || keys[0] != "0" {
t.Errorf("有标签的产物读出的说话人是 %v,期望 [\"0\"]", keys)
}
}
@@ -2,6 +2,7 @@ package skillapi
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
@@ -47,7 +48,7 @@ var audioStepSpecs = map[string]audioStepSpec{
Step: "transcribe",
ActionTitle: "生成逐字转写文本",
ArtifactTitle: "逐字转写稿",
ArtifactType: "transcript",
ArtifactType: audioTranscriptArtifactType,
},
"speakers": {
Step: "speakers",
@@ -254,9 +255,16 @@ func runAudioScopeStep(user *model.User, req audioStepReq) (*audioStepOutcome, e
if language != "" {
languageText = audioLanguageLabel(language)
}
// 说「区分」时要把降级的可能一并说掉:可分离的路由都不成时,回退链会退到
// 一条不分离的(config.GetFallbackAudioRoutes 的两级排序),届时依赖说话人
// 身份的后续步骤不可用。第 1 步是用户唯一能**事先**看到这个后果的地方。
speakerText := "不区分(当前音频路由的模型不输出说话人)"
if route.SupportsSpeakers {
speakerText = "区分(当前音频路由的模型输出说话人标签)"
if config.AudioFallbackDegradesSpeakers(route.RouteID) {
speakerText += ";该路由不可用时会依次回退,最后可能退到一条不区分说话人的路由," +
"那种情况下后续依赖说话人身份的步骤不可用"
}
}
lines := []string{
@@ -361,20 +369,7 @@ func runAudioTranscribeStep(user *model.User, req audioStepReq) (*audioStepOutco
"media_id": media.ID,
"language": language,
},
Output: gin.H{
"media_id": media.ID,
"file_name": media.Filename,
"text": result.Text,
"transcript": transcript,
"language": result.Language,
"duration": result.Duration,
"segments": result.Segments,
"has_speakers": result.HasSpeakers,
"model": result.Model,
"route_id": result.RouteID,
"is_local": result.IsLocal,
"fell_back": result.FellBack,
},
Output: transcribeArtifactOutput(result, media, transcript, language),
Logs: transcribeLogs(result, media, durationText),
SourceRefs: []gin.H{{"type": "upload", "title": media.Filename, "media_id": media.ID}},
Extra: gin.H{
@@ -394,6 +389,10 @@ func runAudioTranscribeStep(user *model.User, req audioStepReq) (*audioStepOutco
// fallback_reason 是原始错误串,留作排障,不往用户脸上摆。
"fell_back_zh": audioFallbackLabel(result),
"fallback_reason": result.FallbackReason,
// 降级与回退是两件事,界面上要分开说:回退是「换了条路」,
// 降级是「换的那条路少了一样能力」。可分离路由都没跑成时才降级。
"capability_degraded": result.CapabilityDegraded,
"capability_zh": audioCapabilityLabel(result),
},
}, nil
}
@@ -441,6 +440,54 @@ func audioFallbackLabel(result *audiotranscribe.Result) string {
audioRouteDisplayName(result.PrimaryRouteID), audioRouteDisplayName(result.RouteID))
}
// transcribeArtifactOutput 第 2 步产物的结构化字段(它会被 marshal 成
// task_artifact.content_json,也原样作为 result 回给前端)。
//
// 单独拎出来是为了能被测试直接调。这些 key 有两条**按字符串**读的消费路径:
// 前端的结果卡片(capability_degraded / capability_zh)与后端自己
// (audioTranscriptDegraded 读 capability_degraded、SpeakerKeysOf 读 segments)。
// key 打错一个字既不会编译报错,也不会让任何用例变红,只会让降级提示和
// 「本次没有说话人分离」的判断一起静默失效 —— 那正是这个改动最怕的失败方式。
func transcribeArtifactOutput(
result *audiotranscribe.Result, media model.MediaFile, transcript, language string,
) gin.H {
return gin.H{
"media_id": media.ID,
"file_name": media.Filename,
"text": result.Text,
"transcript": transcript,
"language": result.Language,
"duration": result.Duration,
"segments": result.Segments,
"has_speakers": result.HasSpeakers,
"model": result.Model,
"route_id": result.RouteID,
"is_local": result.IsLocal,
"fell_back": result.FellBack,
// 降级要一路带到界面:回退链允许退到不分离的路由,
// 而「这一次没有说话人分离」直接决定后面的步骤还能不能跑。
"capability_degraded": result.CapabilityDegraded,
"capability_zh": audioCapabilityLabel(result),
}
}
// audioCapabilityLabel 把「这一次有没有说话人分离」说成人话,降级时点明后果。
//
// 只在降级时返回非空。正常拿到标签时界面不需要多一句话;而「本来该有、这次没有」
// 是用户必须当场知道的事 —— 它决定了后面的步骤还能不能跑,不能等到点了第 5 步
// 才从一句报错里反推。措辞与第 3 步的失败文案共用同一个判断(见
// audioTranscriptSpeakerKeys),两处不能各说各的。
func audioCapabilityLabel(result *audiotranscribe.Result) string {
if result == nil || !result.CapabilityDegraded {
return ""
}
return fmt.Sprintf(
"本次没有说话人分离,后续步骤不可用:"+
"能区分说话人的路由这次都没跑成,已改用「%s」完成转写。"+
"逐字稿可以正常查看,但「识别说话人身份」及其后的整理与纪要都跑不了。",
audioRouteDisplayName(result.RouteID))
}
// transcribeLogs 第 2 步的日志行。
//
// 出网那一行只在本地的没走成时出现:本地转写是这个平台的常态,不该每次
@@ -456,6 +503,11 @@ func transcribeLogs(result *audiotranscribe.Result, media model.MediaFile, durat
"⚠ 主路由 %s 不可用(%s),已回退到 %s",
result.PrimaryRouteID, result.FallbackReason, result.RouteID))
}
if result.CapabilityDegraded {
logs = append(logs, fmt.Sprintf(
"⚠ 本次没有说话人分离(%s 不支持),后续依赖说话人身份的步骤不可用",
result.RouteID))
}
if !result.IsLocal {
logs = append(logs, audioEgressLabel(false))
}
@@ -472,21 +524,24 @@ func transcribeLogs(result *audiotranscribe.Result, media model.MediaFile, durat
// 任务写成「待确认」—— 两处都是既有枚举,界面上也已经有对应的展示(右栏产物区
// 显示「待确认」,左侧任务列表显示「待确认」),不需要任何新的展示代码。
func runAudioSpeakersStep(c *gin.Context, task model.TaskRecord, req audioStepReq) (*audioStepOutcome, error) {
transcript, found := latestArtifact(task.ID, "transcript")
transcript, found := latestArtifact(task.ID, audioTranscriptArtifactType)
if !found || strings.TrimSpace(transcript.ContentText) == "" {
return nil, fmt.Errorf("本任务还没有「逐字转写稿」,请先完成转写再来识别说话人")
}
keys := audiotranscribe.SpeakerKeysOf(transcript.ContentJSON)
if len(keys) == 0 {
// 硬失败,不当作「没有说话人」自动放行。
// 硬失败,不当作「没有说话人」自动放行(G02 反例 A:状态未知就默认视为通过)。
//
// 这里对应 G02 反例 A 的形状:「状态未知就默认视为通过」。当前音频路由是
// 开着说话人分离的(audio_route_siliconflow_diarize 的 supports_speakers
// 为 true),配了却没吐出说话人标签,只可能是转写出了问题 —— 那种情况下
// 悄悄跳过身份确认,会让后面几步把「说话人 0」当成一个人名写进纪要。
return nil, fmt.Errorf("逐字稿里没有说话人标签:当前音频路由配了说话人分离却没输出," +
"请检查第 2 步的转写结果,或换一条音频路由后重跑")
// 措辞不能断言「当前路由配了说话人分离」:回退链允许降级,
// 配了分离却退到一条不分离的路由是**预期内**的结果,不是故障。
// 分岔看第 2 步产物里记的 capability_degraded —— 降级走共用哨兵错误,
// 与第 5/6 步的闸门说同一句话;没降级才是「配了却没输出」那种异常。
if audioTranscriptDegraded(transcript.ContentJSON) {
return nil, errAudioSpeakersUnavailableThisRun
}
return nil, fmt.Errorf("逐字稿里没有说话人标签,识别不了说话人身份:" +
"请检查第 2 步的转写结果,或换一条支持说话人分离的音频路由后重新转写")
}
route, err := resolveAudioChatRoute(req.AiRouteID)
@@ -561,7 +616,7 @@ func runAudioTextStep(c *gin.Context, task model.TaskRecord, req audioStepReq, k
var sourceType, sourceLabel string
switch kind {
case audiotranscribe.KindStructure:
sourceType, sourceLabel = "transcript", "逐字转写稿"
sourceType, sourceLabel = audioTranscriptArtifactType, "逐字转写稿"
case audiotranscribe.KindMinutes:
sourceType, sourceLabel = "document", "结构化纪要"
default:
@@ -752,6 +807,13 @@ func latestArtifact(taskID uint, artifactType string) (model.TaskArtifact, bool)
return artifact, true
}
// audioTranscriptArtifactType 第 2 步「逐字转写稿」产物的类型。
//
// 与 audioSpeakerArtifactType 同一个理由:写它的地方一处(步骤表),读它的地方
// 三处(第 3 步、闸门、第 5/6 步)。各写一遍字面量的话,改类型名时只会漏掉一处,
// 而闸门漏掉的症状是「永远当作还没转写」—— 静默放行,比卡死更糟。
const audioTranscriptArtifactType = "transcript"
// audioSpeakerArtifactType 「说话人名单」产物的类型。
//
// 单独拎出来是因为有三处要用同一个字面量:识别那一步写它、确认那一步读它、
@@ -759,26 +821,50 @@ func latestArtifact(taskID uint, artifactType string) (model.TaskArtifact, bool)
// 「闸门永远说没确认过」—— 任务卡死,且看不出为什么。
const audioSpeakerArtifactType = "speakers"
// audioRouteSupportsSpeakers 单独拎成变量,只为让测试能覆盖「这条音频路由不输出
// 说话人分离」那一支。音频路由读的是 config/ai_config.json,没有环境变量可以改写
// 它指向的文件,测试里没法安全地换一条路由(那是用户线上正在用的配置)。
// errAudioSpeakersUnavailableThisRun 本次转写没有说话人分离,依赖身份的步骤不可用。
//
// 这一支必须被测到:它是**唯一的放行口**,写反了(比如把取反写掉)会让所有
// 用 Qwen3-ASR 这类无说话人分离路由的任务永久卡死在第 5 步,没有任何办法绕过去。
// 同一个模式见 internal/skills/core/allowed_skills.go 的 allowedSkillKeyProvider。
// 读的是**声明路由**(default_audio_route)而不是解析 audio_transcribe:
// 后者现在指向 auto,会按此刻哪台服务活着挑一条,于是这一问的答案会随着
// 本机 ASR 起没起而变化 —— 而这个答案必须是常量。第 1 步(确认范围)、
// 第 2 步(转写)、第 5/6 步(本闸门)是三次独立解析,中间隔着几分钟,
// 用 auto 就会出现「闸门以为有标签、稿子里其实没有」这种最坏的自相矛盾。
// auto 侧的配套约束见 config/route_health.go 的 resolveAutoRoute:
// 它只在说话人能力相同的候选里挑,所以声明值就是实际会跑的那一类。
var audioRouteSupportsSpeakers = func() (bool, error) {
route, err := config.GetDeclaredAudioRoute()
if err != nil {
return false, fmt.Errorf("音频路由不可用:%w", err)
// 单列成哨兵错误是为了让测试断言「拦下来的理由」而不只是「拦下来了」——
// 「没有说话人分离」和「名单还没确认」都返回 error,混在一个 error 里
// 就分不清闸门是拦对了还是拦错了。
var errAudioSpeakersUnavailableThisRun = errors.New(
"本次没有说话人分离,后续步骤不可用:逐字稿里没有说话人标签," +
"识别不了说话人身份,整理与纪要也就没法把姓名替换进正文。" +
"逐字稿已在第 2 步产物中,可以直接查看;" +
"若要跑完整流程,请换一条支持说话人分离的音频路由后重新转写")
// audioTranscriptSpeakerKeys 返回第 2 步逐字稿里**实际出现**的说话人标签,
// 以及「本任务转写过没有」。单独拎成变量,只为让测试能构造三种情形
// (有标签 / 无标签 / 还没转写),同 audioRouteSupportsSpeakers 的老做法。
//
// 判据从「路由声明的能力」改成「稿子里到底有没有标签」,是因为回退链现在允许
// 降级:能分离的路由全挂时会退到一条不分离的(见 config.GetFallbackAudioRoutes),
// 于是「配置说支持」和「这次真的拿到了」不再恒等,只有稿子说的是真的。
//
// 这也让闸门和第 3 步同源:runAudioSpeakersStep 判断「能不能识别身份」用的
// 就是同一句 SpeakerKeysOf。两处若各判各的,重新长出「闸门以为有标签、
// 稿子里其实没有」只是时间问题 —— 而那正是当初把它们绑在一起的初衷。
var audioTranscriptSpeakerKeys = func(taskID uint) (keys []string, transcribed bool) {
artifact, found := latestArtifact(taskID, audioTranscriptArtifactType)
if !found {
return nil, false
}
return route.SupportsSpeakers, nil
return audiotranscribe.SpeakerKeysOf(artifact.ContentJSON), true
}
// audioTranscriptDegraded 读第 2 步产物里记的「本次是否降级」。
//
// 读产物而不是读路由配置:降级是**这一次**的事实,配置只说明想要什么 ——
// 而这两件事现在可以不一致,正是本改动引入的自由度。
// 解析失败按 false 处理:降级字段是后加的,老任务的产物里没有它,
// 那种情况下「配了分离却没输出」的旧解释才是对的。
func audioTranscriptDegraded(contentJSON string) bool {
var payload struct {
CapabilityDegraded bool `json:"capability_degraded"`
}
if err := json.Unmarshal([]byte(contentJSON), &payload); err != nil {
return false
}
return payload.CapabilityDegraded
}
// ensureAudioSpeakersConfirmed 闸门:说话人身份没被用户确认过,下游两步不许跑。
@@ -789,21 +875,26 @@ var audioRouteSupportsSpeakers = func() (bool, error) {
// 以这个身份发出去,「张三说」和「李四说」就换了人。所以这一步不放行,
// 而不是放行后补一句「仅供参考」——后者是 AR05 §6.6 明确不接受的做法。
//
// 音频路由不支持说话人分离时直接放行:那种情况下第 3 步根本跑不起来,
// 不存在「有一个待确认的身份」这件事,卡在这里就成了死锁。
// 本次没有说话人分离时判为**不可用**(errAudioSpeakersUnavailableThisRun),
// 而不是像以前那样放行。放行的结果是正文里那些「说话人 0」被当成一个称谓
// 写进正式纪要,用户拿到手看不出来这里少了东西 —— 那就是 G02 反例 A
// 「状态未知就默认视为通过」的形状,只不过这次是「能力缺失就默认视为够用」。
// 用户已拍板:这种时候宁可只交付逐字稿,也不要一份看不出残缺的纪要。
func ensureAudioSpeakersConfirmed(taskID uint) error {
supports, err := audioRouteSupportsSpeakers()
if err != nil {
return err
}
if !supports {
keys, transcribed := audioTranscriptSpeakerKeys(taskID)
if !transcribed {
// 还没转过写:这不是本闸门该报的错,调用方各自的前置检查(
// latestArtifactText 的「请先完成上一步」)会先开口。
return nil
}
if len(keys) == 0 {
return errAudioSpeakersUnavailableThisRun
}
artifact, found := latestArtifact(taskID, audioSpeakerArtifactType)
if !found {
return fmt.Errorf(
"本任务还没有「说话人名单」:当前音频路由输出说话人标签," +
"本任务还没有「说话人名单」:逐字稿里有说话人标签," +
"请先执行「识别说话人身份」并确认后再继续")
}
if specialistruntime.NormalizeArtifactStatus(artifact.Status) != specialistruntime.ArtifactStatusApproved {
@@ -1,12 +1,12 @@
package skillapi
import (
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"eai_agentplatform/backend/internal/config"
"eai_agentplatform/backend/internal/model"
audiotranscribe "eai_agentplatform/backend/internal/skills/packages/audio_transcribe"
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
@@ -67,26 +67,41 @@ func writeSpeakerArtifact(t *testing.T, taskID uint, status, contentJSON string)
return artifact
}
// requireDiarizingAudioRoute 确认当前音频路由真的输出说话人标签。
// transcriptJSON 造一份第 2 步「逐字转写稿」产物的 ContentJSON。
//
// 不 Skip 而 Fatal,是因为「路由不输出了」正是这个功能整体失效的方式:
// 换一条没有说话人分离的 ASR(比如 Qwen3-ASR),第 3 步会硬失败、闸门会直接放行,
// 于是这几个用例会全部变绿而什么都没测。那种绿比红危险得多。
// 读**声明路由**(default_audio_route),与 audioRouteSupportsSpeakers 用同一个
// 来源。不能读 GetAudioRoute("audio_transcribe"):那条现在指向 auto,会按本机
// ASR 此刻起没起挑路由,于是这套用例的成败取决于跑测试的机器上 8090 有没有在监听
// —— 那是一种「绿得毫无意义、红得莫名其妙」的测试。声明值才是与机器状态无关的事实。
func requireDiarizingAudioRoute(t *testing.T) {
// 形状与 runAudioTranscribeStep 写的一致(Output 直接 marshal 进 ContentJSON)。
// capabilityDegraded 传 true 用来构造「本次降级到不分离路由」那种产物:
// 它有 segments,但每个 segment 的 speaker 都是空串 —— 无分离 ASR 的输出就长这样。
func transcriptJSON(capabilityDegraded bool, speakers ...string) string {
segs := make([]string, 0, len(speakers))
for i, sp := range speakers {
segs = append(segs, fmt.Sprintf(
`{"speaker":%q,"start":%d,"end":%d,"text":"第 %d 句"}`, sp, i*10, i*10+10, i+1))
}
return fmt.Sprintf(`{"has_speakers":%v,"capability_degraded":%v,"segments":[%s]}`,
len(speakers) > 0, capabilityDegraded, strings.Join(segs, ","))
}
// writeTranscriptArtifact 往库里塞一条第 2 步的「逐字转写稿」产物。
//
// 这组用例必须先有它。闸门判「这一次到底有没有说话人分离」读的就是这份产物
// (audioTranscriptSpeakerKeys),不再是路由声明的能力 —— 回退链允许降级之后,
// 「配置说支持」和「这次真的拿到了」可以不相等,只有稿子说的是真的。
func writeTranscriptArtifact(t *testing.T, taskID uint, contentJSON string) model.TaskArtifact {
t.Helper()
route, err := config.GetDeclaredAudioRoute()
if err != nil {
t.Fatalf("音频路由不可用:%v", err)
artifact := model.TaskArtifact{
TaskID: taskID,
SpecialistKey: "general-assistant",
Title: "逐字转写稿",
ArtifactType: audioTranscriptArtifactType,
Status: specialistruntime.ArtifactStatusReady,
ContentText: "## 逐字转写稿\n",
ContentJSON: contentJSON,
}
if !route.SupportsSpeakers {
t.Fatalf("当前音频路由 %s 的 supports_speakers=false:"+
"「识别说话人身份」这一步跑不起来,本文件所有断言都会退化成空断言。"+
"若确实要换掉带说话人分离的路由,请连同这套确认流程一起重新评估", route.RouteID)
if err := store.DB.Create(&artifact).Error; err != nil {
t.Fatalf("创建逐字转写稿产物失败:%v", err)
}
return artifact
}
// rosterJSON 造一份「说话人名单」产物的 ContentJSON(形状与 runAudioSpeakersStep 写的一致)。
@@ -102,9 +117,19 @@ func rosterJSON(roster ...audiotranscribe.SpeakerIdentity) string {
func TestAudioSpeakersGateBlocksWithoutConfirmation(t *testing.T) {
setupSpeakerGateDB(t)
requireDiarizingAudioRoute(t)
task := newSpeakerGateTask(t)
// 0) 稿子还没有 —— 这时不该由闸门开口,见
// TestAudioSpeakersGatePassesThroughWhenNotTranscribedYet。
// 写在这里是为了把「还没转写」与「转写了但没有说话人」分开,
// 后者必须拦(TestAudioSpeakersGateBlocksDegradedTranscript)。
if err := ensureAudioSpeakersConfirmed(task.ID); err != nil {
t.Errorf("还没转写时闸门不该报「没有说话人分离」,会把用户引向错误的原因:%v", err)
}
// 本次转写有说话人标签 —— 下面几条断言的前提。
writeTranscriptArtifact(t, task.ID, transcriptJSON(false, "0", "1"))
// 1) 连名单都还没有 —— 这一步没跑过,下游不能跑。
// (走不到这里的正常路径:人在第 3 步就停住了;但直接把请求打到第 5 步就能绕过前端。)
if err := ensureAudioSpeakersConfirmed(task.ID); err == nil {
@@ -143,8 +168,8 @@ func TestAudioSpeakersGateBlocksWithoutConfirmation(t *testing.T) {
// → 闸门如果只看「历史上有没有确认过」,就会拿**上一版**的身份去写新稿子。
func TestAudioSpeakersGateReopensWhenReinferred(t *testing.T) {
setupSpeakerGateDB(t)
requireDiarizingAudioRoute(t)
task := newSpeakerGateTask(t)
writeTranscriptArtifact(t, task.ID, transcriptJSON(false, "0"))
writeSpeakerArtifact(t, task.ID, specialistruntime.ArtifactStatusApproved, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "0", Name: "张三"},
@@ -162,51 +187,100 @@ func TestAudioSpeakersGateReopensWhenReinferred(t *testing.T) {
}
}
// TestAudioSpeakersGatePassesThroughWhenRouteHasNoDiarization 钉住闸门的唯一放行口。
// TestAudioSpeakersGatePassesThroughWhenNotTranscribedYet 钉住闸门不越权报错。
//
// 换一条不输出说话人分离的 ASR(如 Qwen3-ASR)时,「识别说话人身份」这一步
// 根本没有对象可做,闸门必须直接放行。这一支写反的后果不是少一道校验,
// 而是**所有这类任务永久卡死**:第 3 步跑不出名单 → 闸门说没有名单 → 不许跑第 5 步,
// 而用户无论如何也变不出那份名单。没有别的路可以绕过去。
//
// 断言的是「一个名单都没有的情况下也放行」:若改成先查名单,这条会红。
func TestAudioSpeakersGatePassesThroughWhenRouteHasNoDiarization(t *testing.T) {
// 第 5/6 步拿不到前置产物时,该开口的是 latestArtifactText 的「请先完成上一步」。
// 闸门若抢着报「本次没有说话人分离」,用户会去查一条根本不存在的路由问题,
// 而真正的原因是他还没跑第 2 步。
func TestAudioSpeakersGatePassesThroughWhenNotTranscribedYet(t *testing.T) {
setupSpeakerGateDB(t)
task := newSpeakerGateTask(t)
// 换上「这条路由不输出说话人标签」。测试替身必须在断言之前换好,
// 换晚了等于没换(测的还是线上那条真路由)。
original := audioRouteSupportsSpeakers
audioRouteSupportsSpeakers = func() (bool, error) { return false, nil }
t.Cleanup(func() { audioRouteSupportsSpeakers = original })
// 刻意不写任何产物:没有名单、没有确认,什么都不该拦。
if err := ensureAudioSpeakersConfirmed(task.ID); err != nil {
t.Fatalf("音频路由不输出说话人分离时,闸门仍把后续步骤拦住了(这类任务将永远卡死):%v", err)
t.Fatalf("还没转写时闸门就报错了(会把用户引向错误的原因):%v", err)
}
}
// TestAudioSpeakersGateFailsClosedWhenRouteUnavailable 钉住「路由读不出来时不放行」。
// TestAudioSpeakersGateBlocksDegradedTranscript 钉住本改动引入的核心行为。
//
// 与上一条相反的方向:读不到路由配置(ai_config.json 被改坏、audio_routes 缺项)
// 时**不能**当作「不支持说话人分离」而放行。放行等于跳过确认,正是这道闸门
// 要防的事;而报错会让用户看见问题在哪。
func TestAudioSpeakersGateFailsClosedWhenRouteUnavailable(t *testing.T) {
// 回退链现在允许降级:能分离说话人的路由全挂时会退到一条不分离的
// (config.GetFallbackAudioRoutes 的两级排序),逐字稿仍然拿得到。
// 代价是稿子里只剩「说话人 0」这样的裸标签 —— 没有身份可确认,
// 所以第 3 步与第 5/6 步要一起判为不可用。
//
// 老行为是「路由不支持分离就直接放行」,而放行的结果是把裸标签当成称谓写进
// 正式纪要,用户拿到手看不出这里少了东西(G02 反例 A 的形状)。
// 用户已拍板:这种时候宁可只交付逐字稿。
//
// 名单存在、且已确认,也照样要拦:那份名单是**上一次**转写的产物,
// 拿它去解释这一次的稿子,等于给这一版说话人安上上一版的身份。
func TestAudioSpeakersGateBlocksDegradedTranscript(t *testing.T) {
setupSpeakerGateDB(t)
task := newSpeakerGateTask(t)
original := audioRouteSupportsSpeakers
audioRouteSupportsSpeakers = func() (bool, error) {
return false, fmt.Errorf("音频路由 %q 未在 ai_config.json 的 audio_routes 中定义", "x")
}
t.Cleanup(func() { audioRouteSupportsSpeakers = original })
writeTranscriptArtifact(t, task.ID, transcriptJSON(true))
writeSpeakerArtifact(t, task.ID, specialistruntime.ArtifactStatusApproved, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "0", Org: "某某局", Title: "处长", Name: "张三"},
))
err := ensureAudioSpeakersConfirmed(task.ID)
if err == nil {
t.Fatal("路由读不出来时闸门放行了 —— 配置坏掉会静默跳过说话人确认")
t.Fatal("本次没有说话人分离,闸门却放行了 —— 裸标签会被当成称谓写进正式纪要")
}
if !strings.Contains(err.Error(), "audio_routes") {
t.Errorf("报错没有把真实原因(路由配置)带出来:%v", err)
if !errors.Is(err, errAudioSpeakersUnavailableThisRun) {
t.Errorf("拦下来的理由不是「本次没有说话人分离」:%v", err)
}
if !strings.Contains(err.Error(), "后续步骤不可用") {
t.Errorf("报错没有说清后果:%v", err)
}
if !strings.Contains(err.Error(), "逐字稿") {
t.Errorf("报错没有告诉用户逐字稿还在(那是这次唯一拿得到的东西):%v", err)
}
}
// TestAudioSpeakersGateDistinguishesBlockReasons 钉住两种「拦」的理由能分开。
//
// 两种情形都返回 error,但用户要做的事完全相反:没有说话人分离时该换一条支持
// 分离的路由重跑;名单没确认时该去核对并确认名单。混成同一句话,
// 用户照着报错做完还是过不去。
func TestAudioSpeakersGateDistinguishesBlockReasons(t *testing.T) {
setupSpeakerGateDB(t)
degraded := newSpeakerGateTask(t)
writeTranscriptArtifact(t, degraded.ID, transcriptJSON(true))
degradedErr := ensureAudioSpeakersConfirmed(degraded.ID)
unconfirmed := newSpeakerGateTask(t)
writeTranscriptArtifact(t, unconfirmed.ID, transcriptJSON(false, "0"))
writeSpeakerArtifact(t, unconfirmed.ID, specialistruntime.ArtifactStatusReady, rosterJSON(
audiotranscribe.SpeakerIdentity{Key: "0", Name: "张三"},
))
unconfirmedErr := ensureAudioSpeakersConfirmed(unconfirmed.ID)
if degradedErr == nil || unconfirmedErr == nil {
t.Fatalf("两种情形都该被拦住,实际:降级=%v,未确认=%v", degradedErr, unconfirmedErr)
}
if errors.Is(unconfirmedErr, errAudioSpeakersUnavailableThisRun) {
t.Errorf("「名单还没确认」被报成了「没有说话人分离」:%v", unconfirmedErr)
}
if degradedErr.Error() == unconfirmedErr.Error() {
t.Errorf("两种拦截理由说的是同一句话,用户分不清该做什么:\n%s", degradedErr)
}
}
// TestAudioSpeakersGateFailsClosedOnCorruptTranscript 钉住「读不出来时不放行」。
//
// 稿子在但 ContentJSON 解析不出来(历史数据、写库出错)时,SpeakerKeysOf 返回 nil,
// 于是闸门看到的是「没有标签」。这个方向是**故意**的:分不清「真的没有分离」和
// 「解析失败」时,宁可拦下来(用户看得见、能重跑),也不要放行 ——
// 放行等于让一份身份不明的稿子直接进纪要。
func TestAudioSpeakersGateFailsClosedOnCorruptTranscript(t *testing.T) {
setupSpeakerGateDB(t)
task := newSpeakerGateTask(t)
writeTranscriptArtifact(t, task.ID, "{ 这不是 JSON")
if err := ensureAudioSpeakersConfirmed(task.ID); err == nil {
t.Fatal("逐字稿解析不出来时闸门放行了 —— 身份不明的稿子会直接进纪要")
}
}
@@ -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)
@@ -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 组装「主路由 + 回退链」。
@@ -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 = [