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:
@@ -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("逐字稿解析不出来时闸门放行了 —— 身份不明的稿子会直接进纪要")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+81
@@ -89,6 +89,87 @@ func deadRoute(t *testing.T, id string) *config.RouteConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// audioNoSpeakerBody 是「这条路由不做说话人分离」时我们收到的形状:
|
||||
// 有 segments,但 speaker 全是空串(has_speakers 就是据此算出来的)。
|
||||
// 降级兜底拿到的正是它。
|
||||
const audioNoSpeakerBody = `{"duration":12.5,"text":"测试转写内容",
|
||||
"segments":[{"speaker":"","start":0,"end":12.5,"text":"测试转写内容"}],
|
||||
"usage":{"type":"duration","seconds":12.5}}`
|
||||
|
||||
// newStubBody 起一个固定返回 body 的桩。
|
||||
func newStubBody(t *testing.T, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestTranscribeMarksCapabilityDegraded 钉住「降级兜底」这件事被如实记下来。
|
||||
//
|
||||
// 回退链分两级(config.GetFallbackAudioRoutes):同能力的排前面,降级的排最后。
|
||||
// 真降级时产物里必须留下 capability_degraded=true —— 界面靠它告诉用户
|
||||
// 「本次没有说话人分离,后续步骤不可用」,闸门(audioTranscriptSpeakerKeys)
|
||||
// 也据它拦下第 3 步与第 5/6 步。
|
||||
//
|
||||
// 漏掉这个标记的后果不是少一句提示:用户会拿到一份看不出残缺的产物,
|
||||
// 而依赖说话人身份的那几步已经在后台被判为不可用,两边说的不是一回事。
|
||||
func TestTranscribeMarksCapabilityDegraded(t *testing.T) {
|
||||
secondary := newStubBody(t, audioNoSpeakerBody)
|
||||
primary := deadRoute(t, "audio_route_diarize_dead")
|
||||
primary.SupportsSpeakers = true
|
||||
backup := stubRoute("audio_route_no_diarize", secondary)
|
||||
backup.SupportsSpeakers = false
|
||||
|
||||
// 链路本身就是「同能力优先、降级殿后」的产物,这里照它的形状喂进去。
|
||||
withChain(t, primary, backup)
|
||||
|
||||
res, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", "")
|
||||
if err != nil {
|
||||
t.Fatalf("同能力路由全挂时应降级兜底并成功,却报错:%v", err)
|
||||
}
|
||||
if !res.CapabilityDegraded {
|
||||
t.Error("CapabilityDegraded = false,但这一次确实从「能分离」降到了「不能分离」")
|
||||
}
|
||||
if res.HasSpeakers {
|
||||
t.Error("HasSpeakers = true,但兜底那条路由的输出里没有任何 speaker")
|
||||
}
|
||||
if res.RouteID != "audio_route_no_diarize" {
|
||||
t.Errorf("RouteID = %q,期望降级后的 audio_route_no_diarize", res.RouteID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeDoesNotMarkDegradedWhenCapabilityKept 同能力之间的回退不算降级。
|
||||
//
|
||||
// 判据是「两条路由的 supports_speakers 声明不同」,不是「回退过」:
|
||||
// 本地 whisper 挂掉退到云端 Diarize 仍然有说话人分离,不该报降级,
|
||||
// 更不该因此把第 3/5/6 步关掉。
|
||||
func TestTranscribeDoesNotMarkDegradedWhenCapabilityKept(t *testing.T) {
|
||||
secondary := newStub(t, http.StatusOK, nil, 0)
|
||||
primary := deadRoute(t, "audio_route_diarize_dead")
|
||||
primary.SupportsSpeakers = true
|
||||
backup := stubRoute("audio_route_diarize_backup", secondary)
|
||||
backup.SupportsSpeakers = true
|
||||
|
||||
withChain(t, primary, backup)
|
||||
|
||||
res, err := TranscribeBytes(0, []byte("fake-audio"), "a.mp3", "mp3", "zh", "")
|
||||
if err != nil {
|
||||
t.Fatalf("回退应成功,却报错:%v", err)
|
||||
}
|
||||
if !res.FellBack {
|
||||
t.Fatal("用例前提不成立:这一次并没有回退")
|
||||
}
|
||||
if res.CapabilityDegraded {
|
||||
t.Error("CapabilityDegraded = true,但两条路由都支持说话人分离 —— 这次没有丢能力")
|
||||
}
|
||||
if !res.HasSpeakers {
|
||||
t.Error("HasSpeakers = false,但兜底路由的输出里带 speaker 标签")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeFallsBackWhenPrimaryIsUnreachable(t *testing.T) {
|
||||
var secondaryHits int32
|
||||
secondary := newStub(t, http.StatusOK, &secondaryHits, 0)
|
||||
|
||||
+13
-2
@@ -61,7 +61,15 @@ type Result struct {
|
||||
FellBack bool `json:"fell_back"`
|
||||
PrimaryRouteID string `json:"primary_route_id,omitempty"` // 原本该用的那条
|
||||
FallbackReason string `json:"fallback_reason,omitempty"` // 主路由失败的原因
|
||||
CreatedAt string `json:"created_at"` // 时间
|
||||
|
||||
// CapabilityDegraded 本次是否**降级**了:原本该用的那条能区分说话人,
|
||||
// 实际跑通的这条不能。回退链允许降级兜底(见 config.GetFallbackAudioRoutes),
|
||||
// 但降级是有代价的 —— 没有说话人标签,依赖身份的下游步骤就不可用了。
|
||||
//
|
||||
// 与 HasSpeakers 分开记:HasSpeakers=false 也可能来自「路由支持分离但这次
|
||||
// 一个标签都没输出」,那是异常,值得和「我们主动降级了」区分开。
|
||||
CapabilityDegraded bool `json:"capability_degraded"`
|
||||
CreatedAt string `json:"created_at"` // 时间
|
||||
}
|
||||
|
||||
// AllowedExt 允许转写的音频扩展名(小写,不含点)。
|
||||
@@ -115,6 +123,9 @@ func TranscribeBytes(userID uint, fileData []byte, filename, ext, language, rout
|
||||
if lastErr != nil {
|
||||
result.FallbackReason = lastErr.Error()
|
||||
}
|
||||
// 降级 = 该用的那条能分离、实际这条不能。判据取自两条路由的配置声明,
|
||||
// 与 HasSpeakers(实际拿到什么)互补:这里答「我们放弃了什么能力」。
|
||||
result.CapabilityDegraded = chain[0].SupportsSpeakers && !route.SupportsSpeakers
|
||||
return result, nil
|
||||
}
|
||||
lastErr = err
|
||||
@@ -149,7 +160,7 @@ func TranscribeBytes(userID uint, fileData []byte, filename, ext, language, rout
|
||||
// 真实配置里的回退目标是公网 ASR,要测回退就得真把音频发出去 —— 那正是这个功能
|
||||
// 要防的事,绝不能拿测试来干。注入之后整条链都留在本机,而回退逻辑仍是真的
|
||||
// (真发 HTTP、真读状态码、真走重试判定)。同一模式见 audio_handlers.go 的
|
||||
// audioRouteSupportsSpeakers。
|
||||
// audioTranscriptSpeakerKeys。
|
||||
var transcribeChainFn = transcribeChain
|
||||
|
||||
// transcribeChain 组装「主路由 + 回退链」。
|
||||
|
||||
@@ -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