Files
pj0235-eai_agentplatform/eai_agentplatform/backend-go/deploy/asr/serve.py
T
eaiadminandClaude Code b4c8ea38d1 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>
2026-09-26 23:36:00 +08:00

152 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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")