回退链从「一条主路由 + 一串替补」改成两级:先把同能力(有说话人分离)的路由 试完,全挂才降级到不分离的路由。降级是**本次事实**而不是配置事实,写进第 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>
152 lines
6.2 KiB
Python
152 lines
6.2 KiB
Python
#!/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")
|