From b4c8ea38d19be56e3b103c7326e4c1117b3f4aae Mon Sep 17 00:00:00 2001 From: eaiadmin Date: Sat, 26 Sep 2026 23:36:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(asr):=20=E9=99=8D=E7=BA=A7=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E2=80=94=E2=80=94=E5=88=86=E7=A6=BB=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=85=A8=E6=8C=82=E6=97=B6=E9=80=80=E5=88=B0=E6=97=A0=E5=88=86?= =?UTF-8?q?=E7=A6=BB=E8=B7=AF=E7=94=B1=EF=BC=8C=E5=8F=AA=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E9=80=90=E5=AD=97=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回退链从「一条主路由 + 一串替补」改成两级:先把同能力(有说话人分离)的路由 试完,全挂才降级到不分离的路由。降级是**本次事实**而不是配置事实,写进第 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=(该目录在 unit 的 ReadWritePaths 里) 顺带收口一处交付缺口:服务源码原先只有 ~/asr-poc 一份,而 DELIVERY.md 的清理计划 要 rm -rf 它 —— 那会让唯一副本变成 /opt 下 root 所有、不在任何版本库里的文件。 现在 deploy/asr/ 是唯一事实源,装机脚本与文档同步改。 已知偏离 / 未做(记在案): - 界面那句「本次没有说话人分离,后续步骤不可用」只验到后端接口层,没有造出真实 降级场景渲染出来看过 - deploy/asr/ 的引入改变了装机来源:原型目录 $SRC_DIR 从此只提供 venv 与模型, 服务代码一律从仓库取 Co-Authored-By: Claude Code --- bugs_and_errors.md | 213 +++++++++ .../backend-go/config/ai_config.json | 71 ++- .../backend-go/deploy/DELIVERY.md | 9 +- .../backend-go/deploy/OFFLINE.md | 80 ++++ eai_agentplatform/backend-go/deploy/asr.env | 13 + .../backend-go/deploy/asr/asr_core.py | 429 ++++++++++++++++++ .../backend-go/deploy/asr/serve.py | 151 ++++++ .../backend-go/deploy/install_asr_local.sh | 203 +++++++++ .../api/audio_degraded_endpoint_test.go | 200 ++++++++ .../internal/config/audio_route_test.go | 89 +++- .../backend-go/internal/config/json_loader.go | 46 +- .../internal/config/route_health.go | 14 +- .../api/audio_capability_roundtrip_test.go | 121 +++++ .../internal/skills/api/audio_handlers.go | 191 ++++++-- .../skills/api/audio_speakers_gate_test.go | 170 +++++-- .../audio_transcribe/fallback_test.go | 81 ++++ .../packages/audio_transcribe/transcribe.go | 15 +- .../src/components/chat/SpecialistPanel.vue | 22 +- .../frontend/src/skills/shared/audioSkill.js | 35 +- 19 files changed, 2002 insertions(+), 151 deletions(-) create mode 100644 eai_agentplatform/backend-go/deploy/OFFLINE.md create mode 100644 eai_agentplatform/backend-go/deploy/asr/asr_core.py create mode 100644 eai_agentplatform/backend-go/deploy/asr/serve.py create mode 100644 eai_agentplatform/backend-go/deploy/install_asr_local.sh create mode 100644 eai_agentplatform/backend-go/internal/api/audio_degraded_endpoint_test.go create mode 100644 eai_agentplatform/backend-go/internal/skills/api/audio_capability_roundtrip_test.go diff --git a/bugs_and_errors.md b/bugs_and_errors.md index 2bbf2dc..257b6d5 100644 --- a/bugs_and_errors.md +++ b/bugs_and_errors.md @@ -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 是并发那条。 diff --git a/eai_agentplatform/backend-go/config/ai_config.json b/eai_agentplatform/backend-go/config/ai_config.json index b4828a0..054cec7 100644 --- a/eai_agentplatform/backend-go/config/ai_config.json +++ b/eai_agentplatform/backend-go/config/ai_config.json @@ -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": { diff --git a/eai_agentplatform/backend-go/deploy/DELIVERY.md b/eai_agentplatform/backend-go/deploy/DELIVERY.md index 27939f3..1a70db5 100644 --- a/eai_agentplatform/backend-go/deploy/DELIVERY.md +++ b/eai_agentplatform/backend-go/deploy/DELIVERY.md @@ -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 diff --git a/eai_agentplatform/backend-go/deploy/OFFLINE.md b/eai_agentplatform/backend-go/deploy/OFFLINE.md new file mode 100644 index 0000000..6c65611 --- /dev/null +++ b/eai_agentplatform/backend-go/deploy/OFFLINE.md @@ -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 " 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` 会一直 +在一条永远不健康的本地路由上打转,每次都白等一轮探测超时。 diff --git a/eai_agentplatform/backend-go/deploy/asr.env b/eai_agentplatform/backend-go/deploy/asr.env index f5418df..39203d6 100644 --- a/eai_agentplatform/backend-go/deploy/asr.env +++ b/eai_agentplatform/backend-go/deploy/asr.env @@ -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,本机到那边是**不通**的, diff --git a/eai_agentplatform/backend-go/deploy/asr/asr_core.py b/eai_agentplatform/backend-go/deploy/asr/asr_core.py new file mode 100644 index 0000000..74e95ba --- /dev/null +++ b/eai_agentplatform/backend-go/deploy/asr/asr_core.py @@ -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 diff --git a/eai_agentplatform/backend-go/deploy/asr/serve.py b/eai_agentplatform/backend-go/deploy/asr/serve.py new file mode 100644 index 0000000..b3a6bc0 --- /dev/null +++ b/eai_agentplatform/backend-go/deploy/asr/serve.py @@ -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 。这里只要求「有」,不校验具体值 —— + # 本地服务绑 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") diff --git a/eai_agentplatform/backend-go/deploy/install_asr_local.sh b/eai_agentplatform/backend-go/deploy/install_asr_local.sh new file mode 100644 index 0000000..962cd5c --- /dev/null +++ b/eai_agentplatform/backend-go/deploy/install_asr_local.sh @@ -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 diff --git a/eai_agentplatform/backend-go/internal/api/audio_degraded_endpoint_test.go b/eai_agentplatform/backend-go/internal/api/audio_degraded_endpoint_test.go new file mode 100644 index 0000000..eedfb8a --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/audio_degraded_endpoint_test.go @@ -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) + } +} diff --git a/eai_agentplatform/backend-go/internal/config/audio_route_test.go b/eai_agentplatform/backend-go/internal/config/audio_route_test.go index d93a781..e282341 100644 --- a/eai_agentplatform/backend-go/internal/config/audio_route_test.go +++ b/eai_agentplatform/backend-go/internal/config/audio_route_test.go @@ -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) } } diff --git a/eai_agentplatform/backend-go/internal/config/json_loader.go b/eai_agentplatform/backend-go/internal/config/json_loader.go index d708b94..07350ad 100644 --- a/eai_agentplatform/backend-go/internal/config/json_loader.go +++ b/eai_agentplatform/backend-go/internal/config/json_loader.go @@ -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。 diff --git a/eai_agentplatform/backend-go/internal/config/route_health.go b/eai_agentplatform/backend-go/internal/config/route_health.go index a64c9cd..4cad870 100644 --- a/eai_agentplatform/backend-go/internal/config/route_health.go +++ b/eai_agentplatform/backend-go/internal/config/route_health.go @@ -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] diff --git a/eai_agentplatform/backend-go/internal/skills/api/audio_capability_roundtrip_test.go b/eai_agentplatform/backend-go/internal/skills/api/audio_capability_roundtrip_test.go new file mode 100644 index 0000000..a8d6f43 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/api/audio_capability_roundtrip_test.go @@ -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) + } +} diff --git a/eai_agentplatform/backend-go/internal/skills/api/audio_handlers.go b/eai_agentplatform/backend-go/internal/skills/api/audio_handlers.go index 10a5e93..bb9d7d7 100644 --- a/eai_agentplatform/backend-go/internal/skills/api/audio_handlers.go +++ b/eai_agentplatform/backend-go/internal/skills/api/audio_handlers.go @@ -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 { diff --git a/eai_agentplatform/backend-go/internal/skills/api/audio_speakers_gate_test.go b/eai_agentplatform/backend-go/internal/skills/api/audio_speakers_gate_test.go index d8070cd..f628228 100644 --- a/eai_agentplatform/backend-go/internal/skills/api/audio_speakers_gate_test.go +++ b/eai_agentplatform/backend-go/internal/skills/api/audio_speakers_gate_test.go @@ -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("逐字稿解析不出来时闸门放行了 —— 身份不明的稿子会直接进纪要") } } diff --git a/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/fallback_test.go b/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/fallback_test.go index a47eb5f..dbe1c2e 100644 --- a/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/fallback_test.go +++ b/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/fallback_test.go @@ -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) diff --git a/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/transcribe.go b/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/transcribe.go index 7b47e23..8787082 100644 --- a/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/transcribe.go +++ b/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/transcribe.go @@ -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 组装「主路由 + 回退链」。 diff --git a/eai_agentplatform/frontend/src/components/chat/SpecialistPanel.vue b/eai_agentplatform/frontend/src/components/chat/SpecialistPanel.vue index 78d5c65..c134e31 100644 --- a/eai_agentplatform/frontend/src/components/chat/SpecialistPanel.vue +++ b/eai_agentplatform/frontend/src/components/chat/SpecialistPanel.vue @@ -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(() => { diff --git a/eai_agentplatform/frontend/src/skills/shared/audioSkill.js b/eai_agentplatform/frontend/src/skills/shared/audioSkill.js index 84de35b..18e0c15 100644 --- a/eai_agentplatform/frontend/src/skills/shared/audioSkill.js +++ b/eai_agentplatform/frontend/src/skills/shared/audioSkill.js @@ -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 = [