feat(tools-pve/asr-pipeline): multi-backend ASR web app with LiteLLM routing
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s

This commit is contained in:
Codex
2026-11-01 22:07:57 -08:00
parent d1790a05b0
commit 2367c5eb4e
23 changed files with 2462 additions and 451 deletions
+6 -4
View File
@@ -1,7 +1,9 @@
.git
.env
tests
README.md
.venv
__pycache__
*.pyc
seed
state/
cache/
dry-run/
logs/
.env
+40 -7
View File
@@ -1,11 +1,44 @@
ZENMUX_API_KEY=
ASR_AGENT_TOKEN=
ASR_PUBLIC_URL=http://192.168.50.49:8788
# Deployment example only. Do not commit real secrets.
ASR_WORKSPACE=/home/claw/codex-workspace/meeting-asr
ASR_VAULT=/home/claw/obsidian-vault
ASR_STATE_DIR=/home/claw/codex-workspace/meeting-asr/state
ASR_CACHE_DIR=/home/claw/codex-workspace/meeting-asr/cache
ASR_LOG=/home/claw/codex-workspace/meeting-asr/logs/asr-pipeline.log
ASR_ENV_FILE=/home/claw/codex-workspace/meeting-asr/.env
ASR_RECORDINGS_ROOT=/home/claw/codex-workspace/meeting-asr/recordings
MEETING_INBOX_BASE=/home/claw/codex-workspace/meeting-asr/recordings
ASR_CONTROL_DB=/home/claw/codex-workspace/meeting-asr/state/intake.db
# ASR Provider: qwen3cam | soniox | gemini | openai | grok
ASR_PROVIDER=qwen3cam
ASR_DIARIZE_URL=http://192.168.50.100:10099/asr
ASR_VOICEPRINT_ENROLL_URL=http://192.168.50.100:10099/voiceprints/enroll
# Unified LiteLLM gateway (replaces deprecated ZenMux)
LITELLM_BASE_URL=http://192.168.50.49:4000/v1
LITELLM_API_KEY=
ASR_GEMINI_MODEL=aistudio/gemini-3.5-transcribe
ASR_OPENAI_MODEL=openai/gpt-4o-transcribe-diarize
ASR_CLEAN_MODEL=aistudio/gemini-flash-lite-latest
ASR_KEYWORD_MODEL=aistudio/gemini-flash-lite-latest
ASR_DRAFT_MODEL=codex/gpt-sol-latest
# Direct cloud ASR keys (when not routed through LiteLLM)
SONIOX_API_KEY=
SONIOX_MODEL=stt-async-v5
XAI_API_KEY=
ASR_GROK_MODEL=grok-voice-transcribe-2.0
# Knowledge & Obsidian integration
HINDSIGHT_API=http://192.168.50.47:8888
HINDSIGHT_BANK=personal-context
ASR_GIT_SYNC=0
# Web & Notifications
ASR_PUBLIC_URL=http://192.168.50.49:8788
ASR_REVIEW_PUBLIC_HOST=192.168.50.145
ASR_REVIEW_PORT=8788
ASR_AGENT_TOKEN=
ASR_WEB_PASSWORD=
ASR_NOTIFY=0
ASR_NOTIFY_BACKEND=none
HERMES_WEBHOOK_URL=
HERMES_WEBHOOK_SECRET=
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
+7 -1
View File
@@ -2,7 +2,7 @@ FROM python:3.13-slim-bookworm@sha256:ed86c82274b3c69b52fb5820f358f0bd7df0b60333
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl tini \
ca-certificates curl ffmpeg git tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -15,6 +15,7 @@ COPY termbank/ ./termbank/
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app \
ASR_DATA_DIR=/data \
ASR_STATE_DIR=/state \
ASR_VAULT=/data/vault \
ASR_TERMBANK_DIR=/app/termbank \
@@ -24,6 +25,11 @@ ENV PYTHONUNBUFFERED=1 \
ASR_DRAFTS_DIR=/data/artifacts/drafts \
ASR_RESULTS_DIR=/data/artifacts/results \
ASR_REVISION_QUEUE_DIR=/state/revision-queue \
ASR_PROVIDER=qwen3cam \
LITELLM_BASE_URL=http://192.168.50.49:4000/v1 \
ASR_CLEAN_MODEL=aistudio/gemini-flash-lite-latest \
ASR_KEYWORD_MODEL=aistudio/gemini-flash-lite-latest \
ASR_DRAFT_MODEL=codex/gpt-sol-latest \
ASR_GIT_SYNC=0 \
ASR_NOTIFY=0 \
ASR_REVIEW_HOST=0.0.0.0 \
+46 -42
View File
@@ -1,51 +1,55 @@
# ASR Pipeline on tools-pve
# Meeting ASR
独立的录音转写、精修、人工审核和导出服务。WebUI 与 Agent API 共用 `192.168.50.49:8788`;worker 调用 unRaid 上的 Qwen3-ASR/CAM++ 服务以及 ZenMux 模型 API。
独立的会议/通话录音转写与整理 Web 应用(`asrservice` + `asrpipe`)。支持本地 GPU 声纹服务(`qwen3cam`)与云端多模型语音识别(`soniox`、`gemini`、`openai`、`grok`),可通过 Arcane 部署到本地 VM(`tools-pve`)或外网 VPS。
## 数据边界
## 核心架构
- `/mnt/truenas/personal/ASR-Pipeline` → 容器 `/data`:原始录音、转写、ASR 原始响应、草稿、正式稿、日志和 SQLite 在线备份。
- `/opt/asr-pipeline/state` → 容器 `/state`:活动 SQLite、worker heartbeat、revision queue。SQLite WAL 不放在 NFS。
- `/srv/asr/inbox`:指向 TrueNAS inbox 的宿主机链接,供 `scp`/`sftp` 上传。
- Secret 只存在于 Arcane Project 环境和 tools-pve 的 `0600` 恢复副本,不进入 Git。
1. **Web 控制与审核面(`asrservice.web`)**
- 极简零构建服务端渲染 HTML + 原生 JS(深浅色自适应)。
- 上传录音时可按任务切换 ASR 引擎(`qwen3cam` / `soniox` / `gemini` / `openai` / `grok`)。
- 任务详情页内置支持 HTTP Range 的音频播放器、失败一键切换模型重试、草稿与纪要 Markdown 在线编辑。
- `/review/` 提供低置信度词语、关系确认与说话人身份确认(本地 `qwen3cam` 自动注册声纹至 `:10099`,线上多说话人模型直接更新草稿与词库而不依赖 `:10099`)。
- `/lexicon` 提供热词、同音纠错、人物关系管理与 Obsidian 词库一键同步。
- `/settings` 展示全部 ASR 引擎、LiteLLM 网关、Obsidian 与 Hindsight 实时就绪状态。
- 支持可选 `ASR_WEB_PASSWORD` Cookie 鉴权(外网 VPS 部署时自动保护 `/ui/*` 与 `/review/*`)。
SSH 大文件建议先上传为 `.part`,完成后在同一目录原子改名为受支持扩展名:
2. **后台消费与修订队列(`asrservice.worker`)**
- 监控 `.asr-inbox/`(Web 上传或目录投递)并认领 `intake.db` 任务。
- 调用统一多引擎层 `asrpipe.providers.transcribe_audio()` 生成带结构化注释头的原始转写。
- 执行 `asr-refine draft`:结合 SQLite 词库、Obsidian Vault(`People/`、`Companies/`、`Projects/`、`minutes/`、`_glossary.md`)与 Hindsight(`personal-context`)进行分段去口语化、保真检查与会议纪要生成。
- 监控 `state/revision-queue/` 处理 Web 审核提交的修订重跑请求,确认通过后发布正式纪要至 Obsidian 并可选执行 Git 自动提交推送。
3. **语音识别(ASR)与大模型网关边界**
- **LLM 后处理**:彻底移除 ZenMux,统一通过 **LiteLLM**(内网 `http://192.168.50.49:4000/v1` / 外网 `https://api.opfo.xyz/v1`)调用:
- `ASR_CLEAN_MODEL` / `ASR_KEYWORD_MODEL`:默认 `aistudio/gemini-flash-lite-latest`
- `ASR_DRAFT_MODEL`:默认 `codex/gpt-sol-latest`
- **ASR 引擎**:
- `qwen3cam`(内网默认):对接局域网 GPU `Qwen3-ASR 1.7B + CAM++`(`http://192.168.50.100:10099/asr`),支持声纹特征比对与增量注册。
- `soniox`(线上高精度首选):因 LiteLLM 同步 `/v1/audio/transcriptions` 不支持 Soniox 异步 Files API、结构化 `context` 热词注入与逐词 `speaker` 分离且有 180s 超时限制,由 `asrpipe.providers` 通过 `SONIOX_API_KEY` 直连 Soniox `stt-async-v5`(自动注入 SQLite 词库热词与任务背景,完成后自动清理云端临时音频)。
- `gemini`:通过 LiteLLM 统一网关调用 `aistudio/gemini-3.5-transcribe`(长音频自动 `ffmpeg` 切片)。
- `openai`:通过 LiteLLM 统一网关调用 `openai/gpt-4o-transcribe-diarize`(支持 `diarized_json` 角色分离与 >23MB 自动压缩切片)。
- `grok`:通过 `XAI_API_KEY` 调用 xAI `grok-voice-transcribe-2.0`(支持多说话人分离)。
## 目录结构
- `asrpipe/`:核心转写引擎适配(`providers.py`)、词库召回(`lexicon.py`)、分段去口语化(`segment.py`、`refine.py`)、审核流(`review.py`)与 Obsidian/Hindsight 集成。
- `asrservice/`:容器化 Web 服务(`web.py`)、后台 Worker(`worker.py`)、SQLite 任务状态机(`db.py`)、Webhook 通知(`notify.py`)与健康检查(`healthcheck.py`)。
- `recordings/bin/`:录音监听与 CLI 转写脚本(兼容 `MEETING_ASR_BACKEND` 多引擎调度)。
- `termbank/`:专业词库源数据。
- `tests/`:核心管线、多引擎契约与 Web 服务单元测试。
- `Dockerfile` & `compose.yaml`:面向 Arcane 部署的单镜像双角色(`asr-web` + `asr-worker`)容器编排。
## 本地开发与测试
```bash
scp call.m4a tools@192.168.50.49:/srv/asr/inbox/call.m4a.part
ssh tools@192.168.50.49 'mv /srv/asr/inbox/call.m4a.part /srv/asr/inbox/call.m4a'
python3 -m venv .venv
.venv/bin/pip install -e .
ASR_NOTIFY=0 ASR_GIT_SYNC=0 .venv/bin/python -m unittest discover -s tests -v
```
## Agent API
## Arcane 部署(内网 VM / 外网 VPS)
所有 `/api/v1/*` 路由使用 `Authorization: Bearer $ASR_AGENT_TOKEN`。
```bash
curl -X POST http://192.168.50.49:8788/api/v1/jobs \
-H "Authorization: Bearer $ASR_AGENT_TOKEN" \
-H 'X-Filename: call.m4a' -H 'X-Title: 项目通话' \
-H 'X-Context: 参与人和项目背景' --data-binary @call.m4a
curl -H "Authorization: Bearer $ASR_AGENT_TOKEN" \
http://192.168.50.49:8788/api/v1/jobs/JOB_ID
```
路由:
- `POST /api/v1/jobs`:上传并创建异步任务。
- `GET /api/v1/jobs?limit=&offset=&status=`:分页查询。
- `GET /api/v1/jobs/{id}`:任务详情、事件时间线与产物链接。
- `GET /api/v1/jobs/{id}/artifacts/{transcript|draft|result}`:读取产物。
- `GET /api/v1/jobs/{id}/reviews`:待人工确认项。
- `POST /api/v1/jobs/{id}/reviews/{question_id}`:JSON `{"answer":"..."}`。
- `POST /api/v1/jobs/{id}/retry`:重试失败任务。
`/healthz` 仅表示 Web 进程存活;`/readyz` 同时检查数据库与 worker heartbeat。
## 通知
`ASR_NOTIFY_BACKEND=none|telegram|hermes`。Telegram 需要 `TELEGRAM_BOT_TOKEN` 与 `TELEGRAM_CHAT_ID`;Hermes 需要一个明确存在的签名 webhook URL 与共享密钥。发送失败只影响提醒,不改变任务结果。
## 运维
项目由 Arcane Git Sync 管理,自动同步、自动 pull 和自动 redeploy 均关闭。Compose 变更先在 Git 中验证,再对 tools-pve 的准确 Sync 手动同步。活动数据库每小时通过 SQLite backup API 写到 `/data/backups/*.latest`。
1. 复制 `.env.example` 为 `.env`,按环境配置:
- **内网 VM(`tools-pve`)**:`MEETING_ASR_BACKEND=qwen3cam`(同时可配 `SONIOX_API_KEY` 等以便在 Web UI 按需切换)、`LITELLM_BASE_URL=http://192.168.50.49:4000/v1`、`HINDSIGHT_URL=http://192.168.50.47:8888`。
- **外网 VPS**:`MEETING_ASR_BACKEND=soniox`(或 `gemini` / `openai` / `grok`)、`LITELLM_BASE_URL=https://api.opfo.xyz/v1`、设置 `ASR_WEB_PASSWORD` 开启 Web 登录保护。
2. 使用 `compose.yaml` 启动 `asr-web`(默认 `:8788`)与 `asr-worker`。
@@ -209,7 +209,9 @@ def cmd_doctor(a):
print("credentials")
try:
k = CFG.api_key()
chk("api key", len(k) > 20, f"len={len(k)} source={'env' if os.environ.get('ANTHROPIC_AUTH_TOKEN') else CFG.env_file.name}")
env_set = any(os.environ.get(x) for x in (
"LITELLM_API_KEY", "ASR_LLM_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_AUTH_TOKEN"))
chk("api key", len(k) > 10, f"len={len(k)} source={'env' if env_set else CFG.env_file.name}")
except Exception as e:
chk("api key", False, str(e))
+127 -65
View File
@@ -1,15 +1,11 @@
#!/usr/bin/env python3
"""Configuration for the standalone ASR post-processing pipeline.
"""Configuration for the standalone ASR post-processing pipeline and web service.
This program deliberately has **no dependency on Claude Code, opencode, or any
agent CLI**. It talks to the model provider over plain HTTPS and does its own file
and git handling, so it can run from systemd, cron, or a shell with nothing but
python3 and a network route.
This program has **no dependency on Claude Code, opencode, or any agent CLI**.
It talks to LiteLLM / model providers over plain HTTPS and does its own file
and git handling, so it can run inside Docker (via Arcane), systemd, or a shell.
Everything is overridable by environment variable so the systemd unit stays the
single place where deployment-specific values live. Credentials are read from the
same 600-perm env file the legacy unit uses, so token rotation remains a one-file
change.
Everything is overridable by environment variable.
"""
from __future__ import annotations
@@ -18,8 +14,8 @@ from dataclasses import dataclass, field
from pathlib import Path
PKG = Path(__file__).resolve().parent
ROOT = PKG.parent # OBSIDIAN/asr-pipeline
OBSIDIAN = ROOT.parent # OBSIDIAN project dir
ROOT = PKG.parent
OBSIDIAN = Path(os.environ.get("ASR_WORKSPACE", str(ROOT)))
def _env(name: str, default: str) -> str:
@@ -46,60 +42,124 @@ class Config:
# ---- paths ----------------------------------------------------------
vault: Path = field(default_factory=lambda: Path(
_env("ASR_VAULT", str(Path.home() / "obsidian-vault"))))
obsidian: Path = OBSIDIAN
obsidian: Path = field(default_factory=lambda: Path(
_env("ASR_WORKSPACE", str(ROOT))))
root: Path = ROOT
# ---- models ---------------------------------------------------------
# Cleaning: measured against claude-haiku-4.5 on a 118k-char English-mixed call,
# gemini-3.5-flash-lite tripped the fidelity gate 19/87 times vs haiku's 78/87
# (haiku's failures were 69x "over-compression" — it behaves like a summarizer
# and deleted terms such as AUTOIMMUNE/ADCS/CD), while being 2x faster and
# 2.9x cheaper. Hence flash-lite is the default cleaner.
clean_model: str = _env("ASR_CLEAN_MODEL", "google/gemini-3.5-flash-lite")
# Drafting: one call, needs judgement and structure. sonnet-5.5 replaces the
# legacy headless opus-4-8.
draft_model: str = _env("ASR_DRAFT_MODEL", "anthropic/claude-sonnet-5.5")
keyword_model: str = _env("ASR_KEYWORD_MODEL", "google/gemini-3.5-flash-lite")
# ---- LiteLLM / LLM routing (ZenMux deprecated) ----------------------
litellm_base_url: str = field(default_factory=lambda: _env(
"LITELLM_BASE_URL",
_env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")).rstrip("/"))
endpoint: str = _env("ASR_ENDPOINT", "https://zenmux.ai/api/v1/chat/completions")
# ZenMux returned HTTP 429 at ~12 concurrent requests and was stable at 6.
concurrency: int = _envi("CONCURRENCY", 6)
http_timeout: float = _envf("HTTP_TIMEOUT", 420.0)
max_retries: int = _envi("MAX_RETRIES", 5)
clean_model: str = field(default_factory=lambda: _env(
"ASR_CLEAN_MODEL", "aistudio/gemini-flash-lite-latest"))
draft_model: str = field(default_factory=lambda: _env(
"ASR_DRAFT_MODEL", "codex/gpt-sol-latest"))
keyword_model: str = field(default_factory=lambda: _env(
"ASR_KEYWORD_MODEL", "aistudio/gemini-flash-lite-latest"))
endpoint: str = field(default_factory=lambda: _env(
"ASR_ENDPOINT",
_env("LITELLM_BASE_URL",
_env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")).rstrip("/")
+ "/chat/completions"))
transcriptions_endpoint: str = field(default_factory=lambda: _env(
"ASR_TRANSCRIPTIONS_ENDPOINT",
_env("LITELLM_BASE_URL",
_env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")).rstrip("/")
+ "/audio/transcriptions"))
concurrency: int = field(default_factory=lambda: _envi("CONCURRENCY", 6))
http_timeout: float = field(default_factory=lambda: _envf("HTTP_TIMEOUT", 420.0))
max_retries: int = field(default_factory=lambda: _envi("MAX_RETRIES", 5))
# ---- ASR providers --------------------------------------------------
asr_provider: str = field(default_factory=lambda: _env(
"ASR_PROVIDER", _env("MEETING_ASR_BACKEND", "qwen3cam")).lower())
diarize_url: str = field(default_factory=lambda: _env(
"ASR_DIARIZE_URL",
_env("MEETING_DIARIZE_URL", "http://192.168.50.100:10099/asr")))
voiceprint_enroll_url: str = field(default_factory=lambda: _env(
"ASR_VOICEPRINT_ENROLL_URL",
"http://192.168.50.100:10099/voiceprints/enroll"))
soniox_api_key: str = field(default_factory=lambda: _env("SONIOX_API_KEY", ""))
soniox_api_base: str = field(default_factory=lambda: _env(
"SONIOX_API_BASE", "https://api.soniox.com/v1").rstrip("/"))
soniox_model: str = field(default_factory=lambda: _env(
"SONIOX_MODEL", "stt-async-v5"))
gemini_asr_model: str = field(default_factory=lambda: _env(
"ASR_GEMINI_MODEL", "aistudio/gemini-3.5-transcribe"))
openai_asr_model: str = field(default_factory=lambda: _env(
"ASR_OPENAI_MODEL", "openai/gpt-4o-transcribe-diarize"))
grok_api_key: str = field(default_factory=lambda: _env(
"XAI_API_KEY", _env("GROK_API_KEY", "")))
grok_api_base: str = field(default_factory=lambda: _env(
"XAI_API_BASE", "https://api.x.ai/v1").rstrip("/"))
grok_asr_model: str = field(default_factory=lambda: _env(
"ASR_GROK_MODEL", "grok-voice-transcribe-2.0"))
segment_seconds: int = field(default_factory=lambda: _envi(
"SEGMENT_SECONDS", int(_env("MEETING_ASR_SEGMENT_SECONDS", "540"))))
# ---- chunking -------------------------------------------------------
chunk_budget: int = _envi("CHUNK_BUDGET", 1800)
chunk_overlap: int = _envi("CHUNK_OVERLAP", 200)
collapse_noise: bool = _env("ASR_COLLAPSE_NOISE", "1") != "0"
chunk_budget: int = field(default_factory=lambda: _envi("CHUNK_BUDGET", 1800))
chunk_overlap: int = field(default_factory=lambda: _envi("CHUNK_OVERLAP", 200))
collapse_noise: bool = field(
default_factory=lambda: _env("ASR_COLLAPSE_NOISE", "1") != "0")
# ---- fidelity gate --------------------------------------------------
gate_enabled: bool = _env("ASR_GATE", "1") != "0"
gate_min_ratio: float = _envf("GATE_MIN_RATIO", 0.55)
gate_max_ratio: float = _envf("GATE_MAX_RATIO", 1.35)
gate_enabled: bool = field(
default_factory=lambda: _env("ASR_GATE", "1") != "0")
gate_min_ratio: float = field(
default_factory=lambda: _envf("GATE_MIN_RATIO", 0.55))
gate_max_ratio: float = field(
default_factory=lambda: _envf("GATE_MAX_RATIO", 1.35))
# ---- knowledge sources ----------------------------------------------
use_termbank: bool = _env("ASR_TERMBANK", "1") != "0"
use_hindsight: bool = _env("ASR_HINDSIGHT", "1") != "0"
use_keyword_recall: bool = _env("ASR_KEYWORD_RECALL", "1") != "0"
use_lexicon: bool = _env("ASR_LEXICON", "1") != "0"
use_normalize: bool = _env("ASR_NORMALIZE", "1") != "0"
use_evidence: bool = _env("ASR_EVIDENCE", "1") != "0"
hindsight_api: str = _env("HINDSIGHT_API", "http://192.168.50.47:8888")
hindsight_bank: str = _env("HINDSIGHT_BANK", "personal-context")
hindsight_timeout: float = _envf("HINDSIGHT_TIMEOUT", 12.0)
flag_min_score: float = _envf("FLAG_MIN_SCORE", 0.86)
review_min_score: float = _envf("REVIEW_MIN_SCORE", 0.82)
review_max_score: float = _envf("REVIEW_MAX_SCORE", 0.96)
review_host: str = _env("ASR_REVIEW_HOST", "0.0.0.0")
review_public_host: str = _env("ASR_REVIEW_PUBLIC_HOST", "192.168.50.145")
review_port: int = _envi("REVIEW_PORT", 8765)
review_idle_seconds: int = _envi("REVIEW_IDLE_SECONDS", 2700)
review_max_seconds: int = _envi("REVIEW_MAX_SECONDS", 7200)
use_termbank: bool = field(
default_factory=lambda: _env("ASR_TERMBANK", "1") != "0")
use_hindsight: bool = field(
default_factory=lambda: _env("ASR_HINDSIGHT", "1") != "0")
use_keyword_recall: bool = field(
default_factory=lambda: _env("ASR_KEYWORD_RECALL", "1") != "0")
use_lexicon: bool = field(
default_factory=lambda: _env("ASR_LEXICON", "1") != "0")
use_normalize: bool = field(
default_factory=lambda: _env("ASR_NORMALIZE", "1") != "0")
use_evidence: bool = field(
default_factory=lambda: _env("ASR_EVIDENCE", "1") != "0")
hindsight_api: str = field(default_factory=lambda: _env(
"HINDSIGHT_API", "http://192.168.50.47:8888"))
hindsight_bank: str = field(default_factory=lambda: _env(
"HINDSIGHT_BANK", "personal-context"))
hindsight_timeout: float = field(
default_factory=lambda: _envf("HINDSIGHT_TIMEOUT", 12.0))
flag_min_score: float = field(
default_factory=lambda: _envf("FLAG_MIN_SCORE", 0.86))
review_min_score: float = field(
default_factory=lambda: _envf("REVIEW_MIN_SCORE", 0.82))
review_max_score: float = field(
default_factory=lambda: _envf("REVIEW_MAX_SCORE", 0.96))
review_host: str = field(
default_factory=lambda: _env("ASR_REVIEW_HOST", "0.0.0.0"))
review_public_host: str = field(
default_factory=lambda: _env("ASR_REVIEW_PUBLIC_HOST", "192.168.50.145"))
review_port: int = field(default_factory=lambda: _envi("REVIEW_PORT", 8765))
review_idle_seconds: int = field(
default_factory=lambda: _envi("REVIEW_IDLE_SECONDS", 2700))
review_max_seconds: int = field(
default_factory=lambda: _envi("REVIEW_MAX_SECONDS", 7200))
# ---- behaviour ------------------------------------------------------
git_sync: bool = _env("ASR_GIT_SYNC", "1") != "0"
notify: bool = _env("ASR_NOTIFY", "1") != "0"
cache_llm: bool = _env("ASR_CACHE", "0") != "0" # off in production
git_sync: bool = field(
default_factory=lambda: _env("ASR_GIT_SYNC", "1") != "0")
notify: bool = field(
default_factory=lambda: _env("ASR_NOTIFY", "1") != "0")
cache_llm: bool = field(
default_factory=lambda: _env("ASR_CACHE", "0") != "0")
@property
def inbox(self) -> Path:
@@ -120,7 +180,6 @@ class Config:
@property
def pending_index(self) -> Path:
# Legacy compatibility only; the Web queue replaced the Vault draft index.
return self.vault / "minutes" / "_drafts" / "_待确认.md"
@property
@@ -150,15 +209,16 @@ class Config:
@property
def notifier(self) -> Path:
return self.obsidian / "bin" / "meeting-status-notify.py"
return Path(_env("ASR_NOTIFIER", str(ROOT / "scripts" / "meeting-status-notify.py")))
@property
def env_file(self) -> Path:
"""600-perm credentials file, shared with the legacy systemd unit."""
return Path(_env("ASR_ENV_FILE", str(self.obsidian / "bin" / ".asr-refine.env")))
"""600-perm credentials file."""
return Path(_env("ASR_ENV_FILE", str(ROOT / ".env")))
def api_key(self) -> str:
for k in ("ZENMUX_API_KEY", "ANTHROPIC_AUTH_TOKEN"):
for k in ("LITELLM_API_KEY", "ASR_LLM_API_KEY", "OPENAI_API_KEY",
"ANTHROPIC_AUTH_TOKEN"):
v = os.environ.get(k)
if v:
return v
@@ -166,13 +226,15 @@ class Config:
if p.exists():
for line in p.read_text(errors="ignore").splitlines():
line = line.strip()
if line.startswith("ANTHROPIC_AUTH_TOKEN="):
val = line.split("=", 1)[1].strip().strip("'\"")
if val:
return val
for prefix in ("LITELLM_API_KEY=", "ASR_LLM_API_KEY=",
"OPENAI_API_KEY=", "ANTHROPIC_AUTH_TOKEN="):
if line.startswith(prefix):
val = line.split("=", 1)[1].strip().strip("'\"")
if val:
return val
raise RuntimeError(
"no API key: set ZENMUX_API_KEY/ANTHROPIC_AUTH_TOKEN or provide "
f"{p} with ANTHROPIC_AUTH_TOKEN=…")
"no API key: set LITELLM_API_KEY / ASR_LLM_API_KEY / OPENAI_API_KEY "
f"or provide {p}")
CFG = Config()
+17 -22
View File
@@ -1,19 +1,7 @@
#!/usr/bin/env python3
"""HTTPS client for the model provider. No agent CLI, no SDK.
"""HTTPS client for LiteLLM / OpenAI-compatible model gateways. No agent CLI, no SDK.
Deliberately built on urllib so the program has zero third-party HTTP
dependencies: it must keep working inside a bare systemd unit years from now.
Behaviours encoded from measurement rather than guesswork:
* concurrency 6 (HTTP 429 appears at ~12)
* HTTP 402 "Payment Required" is transient on ZenMux — credit seems reserved
against max_tokens, so a burst of large-budget calls can trip it and then
succeed moments later; it gets a much longer backoff than a rate limit
* `temperature` is rejected outright by claude-sonnet-5.5 / opus-5 but accepted by
haiku-4.5, sonnet-4.6, gemini and qwen, so it cannot be inferred from the
vendor prefix; the client self-heals on the 400 and remembers
* an empty completion (reasoning models burning the whole budget on hidden
thoughts) is retried rather than silently propagated as ""
Built on urllib so the program has zero third-party HTTP dependencies.
"""
from __future__ import annotations
@@ -30,14 +18,22 @@ from .config import CFG
from .log import log
PRICES = {
"aistudio/gemini-flash-lite-latest": (0.3, 2.5),
"vertex/gemini-flash-lite-latest": (0.3, 2.5),
"gemini-flash-lite-latest": (0.3, 2.5),
"google/gemini-3.5-flash-lite": (0.3, 2.5),
"google/gemini-3.5-flash": (1.5, 9.0),
"aistudio/gemini-flash-latest": (1.5, 9.0),
"vertex/gemini-flash-latest": (1.5, 9.0),
"aistudio/gemini-pro-latest": (2.5, 12.0),
"codex/gpt-sol-latest": (2.0, 8.0),
"openai/gpt-sol-latest": (2.0, 8.0),
"codex/gpt-luna-latest": (1.5, 6.0),
"xai/grok-latest": (2.0, 10.0),
"anthropic/claude-haiku-4.5": (1.0, 5.0),
"anthropic/claude-sonnet-5": (2.0, 10.0),
"anthropic/claude-sonnet-5.5": (2.0, 10.0),
"anthropic/claude-sonnet-4.6": (3.0, 15.0),
"anthropic/claude-opus-5": (5.0, 25.0),
"qwen/qwen3.7-plus": (0.1373076, 0.5492304),
}
_sem = threading.BoundedSemaphore(CFG.concurrency)
@@ -45,6 +41,8 @@ _NO_TEMPERATURE: set[str] = {
"anthropic/claude-sonnet-5",
"anthropic/claude-sonnet-5.5",
"anthropic/claude-opus-5",
"codex/gpt-sol-latest",
"codex/gpt-luna-latest",
}
_lock = threading.Lock()
_key: list[str] = []
@@ -143,7 +141,7 @@ def chat(model: str, prompt: str, *, system: str | None = None,
except Exception:
pass
last = f"HTTP{e.code}: {detail[:200]}"
if e.code == 400 and "temperature" in detail:
if e.code == 400 and "temperature" in detail.lower():
with _lock:
_NO_TEMPERATURE.add(model)
payload = payload_of()
@@ -151,13 +149,12 @@ def chat(model: str, prompt: str, *, system: str | None = None,
cpath = _cache_path(model, payload)
retries += 1
continue
if e.code not in (402, 408, 429, 500, 502, 503, 504):
if e.code not in (408, 429, 500, 502, 503, 504):
if usage:
usage.failures += 1
raise RuntimeError(f"{model} {last}") from e
retries += 1
delay = (min(20 * (attempt + 1), 120) if e.code == 402
else min(2 ** attempt + random.uniform(0, 1.5), 45))
delay = min(2 ** attempt + random.uniform(0, 1.5), 45)
log(f"llm retry {attempt+1}/{CFG.max_retries} {model} {last} "
f"sleep {delay:.0f}s", level="WARN")
time.sleep(delay)
@@ -203,8 +200,6 @@ def chat_many(model: str, prompts: list[str], **kw) -> list[str]:
except BaseException as e: # noqa: BLE001 - recorded, decided by caller
errors.append((i, e))
if errors:
# Partial failure must not silently yield empty chunks: the caller falls
# back to the original text for these indices, which the gate then flags.
for i, e in errors:
log(f"chunk {i} failed: {e}", level="ERROR")
raise PartialFailure(out, [i for i, _ in errors])
@@ -166,6 +166,20 @@ SPEAKER_NOTE = {
"- 不同 speaker_N 是不同真人(CAM++ 已分),但可能过度聚类(同一人被拆成多个 N),"
"按内容合并同一真人。\n"
"- frontmatter 的 people 优先填**声纹命名的真人**。"),
"soniox": (
"转写来自 Soniox 线上分角色模型:\n"
"- `speaker_N` 为自动区分的说话人标签,按内容与背景推断身份并标「疑似」,"
"待人工在 Web 审核中确认。"),
"openai": (
"转写来自 OpenAI Transcribe 模型:\n"
"- 若含 `speaker_N` 标签,为单场自动区分的说话人,按内容推断身份并标「疑似」;"
"若无说话人标签则按内容推断发言归属。"),
"gemini": (
"转写来自 Gemini 3.5 Transcribe 模型:\n"
"- 按内容推断发言归属,不要臆造未出现的 speaker 标签。"),
"grok": (
"转写来自 Grok Voice Transcribe 2.0 分角色模型:\n"
"- `speaker_N` 为自动区分的说话人标签,按内容推断身份并标「疑似」。"),
"vibevoice": "转写带 S# 说话人标签,仅作软提示,块间编号不保证一致。",
"qwen3-plain": "转写**无说话人信息**,据内容推断发言归属,不要臆造 speaker 标签。",
}
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
"""Unified ASR provider abstraction for local GPU (qwen3cam) and online models
(Soniox, Gemini 3.5 Transcribe via LiteLLM, OpenAI Transcribe via LiteLLM, Grok).
"""
from __future__ import annotations
import json
import mimetypes
import os
import re
import shutil
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .config import CFG
from .log import log
PROVIDERS: dict[str, dict[str, str]] = {
"qwen3cam": {
"label": "内网 Qwen3-ASR 1.7B + CAM++ (分角色 + 声纹)",
"default_model": "qwen3-asr-1.7b-cam++",
"mode": "lan-diarized-voiceprint",
},
"soniox": {
"label": "Soniox Async v5 (线上分角色 + 词库热词注入)",
"default_model": "stt-async-v5",
"mode": "cloud-diarized",
},
"gemini": {
"label": "Gemini 3.5 Transcribe (LiteLLM 统一路由)",
"default_model": "aistudio/gemini-3.5-transcribe",
"mode": "litellm-segmented",
},
"openai": {
"label": "OpenAI GPT-4o Transcribe Diarize (LiteLLM 统一路由)",
"default_model": "openai/gpt-4o-transcribe-diarize",
"mode": "litellm-diarized",
},
"grok": {
"label": "Grok Voice Transcribe 2.0 (线上分角色)",
"default_model": "grok-voice-transcribe-2.0",
"mode": "cloud-diarized",
},
}
@dataclass
class TranscribeResult:
provider: str
model: str
transcript_text: str
raw_response: Any
diarized: bool = True
voiceprint_session: str = ""
duration_seconds: int = 0
meta: dict[str, Any] = field(default_factory=dict)
def normalize_provider_name(name: str | None) -> str:
raw = (name or CFG.asr_provider or "qwen3cam").strip().lower()
if raw in ("paraformer", "qwen3", "qwen3-cam"):
return "qwen3cam"
if raw in ("gemini-3.5-transcribe", "aistudio/gemini-3.5-transcribe",
"vertex/gemini-3.5-transcribe"):
return "gemini"
if raw in ("openai-diarize", "gpt-4o-transcribe-diarize", "gpt-4o-transcribe",
"gpt-transcribe"):
return "openai"
if raw in ("xai", "grok-voice-transcribe-2.0"):
return "grok"
return raw if raw in PROVIDERS else "qwen3cam"
def provider_status() -> list[dict[str, Any]]:
"""Return readiness metadata for each ASR provider without exposing secrets."""
has_litellm = False
try:
has_litellm = bool(CFG.api_key())
except Exception:
has_litellm = False
return [
{
"id": "qwen3cam",
"label": PROVIDERS["qwen3cam"]["label"],
"model": PROVIDERS["qwen3cam"]["default_model"],
"ready": bool(CFG.diarize_url),
"endpoint": CFG.diarize_url,
"via": "LAN GPU (:10099)",
},
{
"id": "soniox",
"label": PROVIDERS["soniox"]["label"],
"model": CFG.soniox_model,
"ready": bool(CFG.soniox_api_key),
"endpoint": CFG.soniox_api_base,
"via": "SONIOX_API_KEY 直连",
},
{
"id": "gemini",
"label": PROVIDERS["gemini"]["label"],
"model": CFG.gemini_asr_model,
"ready": has_litellm,
"endpoint": CFG.transcriptions_endpoint,
"via": "LiteLLM 统一网关",
},
{
"id": "openai",
"label": PROVIDERS["openai"]["label"],
"model": CFG.openai_asr_model,
"ready": has_litellm,
"endpoint": CFG.transcriptions_endpoint,
"via": "LiteLLM 统一网关",
},
{
"id": "grok",
"label": PROVIDERS["grok"]["label"],
"model": CFG.grok_asr_model,
"ready": bool(CFG.grok_api_key),
"endpoint": CFG.grok_api_base + "/stt",
"via": "XAI_API_KEY 直连",
},
]
def collect_hotwords(limit: int = 120) -> list[str]:
"""Collect canonical entity names and glossary terms for ASR context biasing."""
terms: list[str] = []
seen: set[str] = set()
def add(word: str) -> None:
w = word.strip()
if not w or len(w) < 2 or len(w) > 40 or w in seen:
return
if re.match(r"^\d{4}-\d{2}|^_|speaker_", w):
return
seen.add(w)
terms.append(w)
try:
from . import lexicon
con = lexicon.connect()
for row in con.execute(
"SELECT canonical FROM entity ORDER BY synced_at DESC LIMIT ?",
(limit,)).fetchall():
add(str(row["canonical"]))
for row in con.execute(
"SELECT DISTINCT canonical FROM alias WHERE confirmed=1 LIMIT ?",
(limit,)).fetchall():
add(str(row["canonical"]))
con.close()
except Exception:
pass
if CFG.glossary.exists():
for raw in CFG.glossary.read_text(errors="ignore").splitlines():
s = raw.strip()
if not s.startswith(("-", "*")) or re.search(r"[→⇒]|->", s):
continue
s = re.split(r"\s[—–-]\s|[::]",
re.sub(r"^[-*+\d\.\)\s]+", "", s), maxsplit=1)[0].strip()
s = re.sub(r"\[\[|\]\]", "", s).strip()
add(s)
if len(terms) >= limit:
break
return terms[:limit]
def _normalize_speaker_label(raw: Any, mapping: dict[str, str]) -> str:
s = str(raw if raw is not None else "").strip()
if not s or s == "?":
return "speaker_0"
if re.match(r"^[\u4e00-\u9fff]{2,8}$", s):
return s
if re.match(r"^speaker_\d+$", s):
return s
m = re.match(r"^[sS](?:peaker)?[_ -]?(\d+)$", s)
if m:
return f"speaker_{m.group(1)}"
if s.isdigit():
# Soniox often uses 1-based speaker numbers ("1", "2"); normalize to speaker_0..N-1
if s not in mapping:
mapping[s] = f"speaker_{len(mapping)}"
return mapping[s]
if s not in mapping:
mapping[s] = f"speaker_{len(mapping)}"
return mapping[s]
def format_diarized_transcript(
segments: list[dict[str, Any]],
*,
provider: str,
model: str,
header_title: str,
voiceprint_session: str = "",
matched_names: list[str] | None = None,
) -> str:
if not segments:
raise RuntimeError(f"ASR ({provider}) response contains no segments")
speaker_map: dict[str, str] = {}
turns: list[list[str]] = []
speakers_seen: set[str] = set()
for seg in segments:
raw_spk = seg.get("speaker")
text = str(seg.get("text") or "").strip()
if not text:
continue
spk = (_normalize_speaker_label(raw_spk, speaker_map)
if provider != "qwen3cam"
else str(raw_spk if raw_spk is not None else "speaker_0"))
speakers_seen.add(spk)
if turns and turns[-1][0] == spk:
turns[-1][1] += text
else:
turns.append([spk, text])
if not turns:
raise RuntimeError(f"ASR ({provider}) segments contained no text")
lines = [f"<!-- asr_provider: {provider} model: {model} -->"]
if voiceprint_session or provider == "qwen3cam":
lines.append(
f"<!-- voiceprint_session: {voiceprint_session} "
f"clusters: {len(speakers_seen)} -->")
hdr = f"# 转写({header_title})"
if matched_names:
hdr += " | 声纹命名: " + ", ".join(matched_names)
lines.append(hdr)
lines.append("")
lines.extend(f"【{spk}】{txt}\n" for spk, txt in turns)
return "\n".join(lines).strip() + "\n"
def transcript_from_qwen3cam_response(payload: dict[str, Any]) -> str:
segments = payload.get("segments")
if not isinstance(segments, list) or not segments:
raise RuntimeError("ASR response contains no segments")
vp = payload.get("voiceprint") or {}
sid = str(vp.get("session_id") or "")
matched = [
f"{m.get('speaker_local')}={m.get('person_name')}"
for m in vp.get("matches", [])
if m.get("status") == "matched"
]
return format_diarized_transcript(
segments,
provider="qwen3cam",
model="qwen3-asr-1.7b-cam++",
header_title="Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹",
voiceprint_session=sid,
matched_names=matched,
)
def transcript_from_soniox_response(
payload: dict[str, Any],
*,
model: str = "stt-async-v5",
) -> str:
"""Convert Soniox token-level or segment-level diarized response into turns."""
tokens = payload.get("tokens")
segments: list[dict[str, Any]] = []
if isinstance(tokens, list) and tokens:
cur_speaker: Any = None
cur_pieces: list[str] = []
for tok in tokens:
txt = str(tok.get("text") or "")
if not txt:
continue
spk = tok.get("speaker")
if cur_speaker is not None and spk != cur_speaker and cur_pieces:
segments.append({"speaker": cur_speaker,
"text": "".join(cur_pieces).strip()})
cur_pieces = []
cur_speaker = spk if spk is not None else (cur_speaker or "1")
cur_pieces.append(txt)
if cur_pieces:
segments.append({"speaker": cur_speaker or "1",
"text": "".join(cur_pieces).strip()})
elif isinstance(payload.get("segments"), list) and payload["segments"]:
segments = payload["segments"]
elif isinstance(payload.get("text"), str) and payload["text"].strip():
segments = [{"speaker": "speaker_0", "text": payload["text"].strip()}]
return format_diarized_transcript(
segments,
provider="soniox",
model=model,
header_title=f"Soniox {model} 分角色",
)
def transcript_from_openai_response(
payload: dict[str, Any],
*,
model: str,
) -> str:
"""Convert OpenAI / LiteLLM transcription response (diarized or plain) into text."""
segments = payload.get("segments")
if isinstance(segments, list) and segments and any(
s.get("speaker") is not None for s in segments if isinstance(s, dict)):
return format_diarized_transcript(
segments,
provider="openai",
model=model,
header_title=f"OpenAI {model} 分角色",
)
text = str(payload.get("text") or payload.get("transcript") or "").strip()
if not text and isinstance(segments, list):
text = "".join(str(s.get("text") or "") for s in segments).strip()
if not text:
raise RuntimeError(f"OpenAI ASR ({model}) returned empty transcript")
if re.search(r"【[^】]+】", text):
return (
f"<!-- asr_provider: openai model: {model} -->\n"
f"# 转写(OpenAI {model})\n\n{text}\n"
)
return (
f"<!-- asr_provider: openai model: {model} -->\n"
f"--- 第1段 [00:00 - 结束] ---\n{text}\n"
)
def transcript_from_grok_response(
payload: dict[str, Any],
*,
model: str = "grok-voice-transcribe-2.0",
) -> str:
segments = payload.get("segments") or payload.get("utterances") or payload.get("turns")
if isinstance(segments, list) and segments:
norm_segs = []
for s in segments:
if not isinstance(s, dict):
continue
spk = s.get("speaker")
if spk is None:
spk = s.get("speaker_id")
if spk is None:
spk = "speaker_0"
txt = str(s.get("text") or s.get("transcript") or "").strip()
if txt:
norm_segs.append({"speaker": spk, "text": txt})
if norm_segs:
return format_diarized_transcript(
norm_segs,
provider="grok",
model=model,
header_title=f"Grok {model} 分角色",
)
words = payload.get("words")
if isinstance(words, list) and words and any(
isinstance(w, dict) and w.get("speaker") is not None for w in words):
norm_segs = []
cur_spk = None
buf: list[str] = []
for w in words:
if not isinstance(w, dict):
continue
txt = str(w.get("word") or w.get("text") or "")
spk = w.get("speaker")
if cur_spk is not None and spk != cur_spk and buf:
norm_segs.append({"speaker": cur_spk, "text": "".join(buf).strip()})
buf = []
cur_spk = spk if spk is not None else (cur_spk or "0")
buf.append(txt)
if buf:
norm_segs.append({"speaker": cur_spk or "0", "text": "".join(buf).strip()})
if norm_segs:
return format_diarized_transcript(
norm_segs,
provider="grok",
model=model,
header_title=f"Grok {model} 分角色",
)
text = str(payload.get("text") or payload.get("transcript") or "").strip()
if not text:
raise RuntimeError(f"Grok ASR ({model}) returned empty transcript")
return format_diarized_transcript(
[{"speaker": "speaker_0", "text": text}],
provider="grok",
model=model,
header_title=f"Grok {model}",
)
def probe_duration_seconds(audio: Path) -> int:
try:
out = subprocess.check_output([
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", str(audio),
], text=True, timeout=30).strip()
return max(1, int(float(out) + 0.999)) if out else 0
except Exception:
return 0
def _encode_multipart(
fields: dict[str, str],
file_field: str,
file_path: Path,
) -> tuple[bytes, str]:
boundary = f"----ASRBoundary{uuid.uuid4().hex}"
body = bytearray()
for name, val in fields.items():
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
body.extend(str(val).encode("utf-8"))
body.extend(b"\r\n")
mime = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="{file_field}"; '
f'filename="{file_path.name}"\r\n'.encode())
body.extend(f"Content-Type: {mime}\r\n\r\n".encode())
body.extend(file_path.read_bytes())
body.extend(f"\r\n--{boundary}--\r\n".encode())
return bytes(body), f"multipart/form-data; boundary={boundary}"
def _http_multipart(
url: str,
*,
fields: dict[str, str],
file_field: str,
file_path: Path,
headers: dict[str, str] | None = None,
timeout: int = 900,
) -> dict[str, Any]:
data, content_type = _encode_multipart(fields, file_field, file_path)
req_headers = {"Content-Type": content_type, **(headers or {})}
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
try:
return json.loads(raw)
except ValueError as exc:
raise RuntimeError(f"ASR returned invalid JSON: {raw[:200]}") from exc
def _http_json(
method: str,
url: str,
*,
payload: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: int = 120,
) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
req_headers = {"Accept": "application/json", **(headers or {})}
if data is not None:
req_headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
if not raw.strip():
return {}
try:
return json.loads(raw)
except ValueError as exc:
raise RuntimeError(f"ASR returned invalid JSON: {raw[:200]}") from exc
def _compress_or_split_audio(
audio: Path,
tmpdir: Path,
*,
max_bytes: int = 23 * 1024 * 1024,
segment_seconds: int = 2400,
force_split: bool = False,
) -> list[tuple[Path, int, int]]:
"""Return [(segment_path, start_sec, end_sec), ...] suitable for upstream API."""
duration = probe_duration_seconds(audio)
if not force_split and audio.stat().st_size <= max_bytes and audio.suffix.lower() in {
".mp3", ".m4a", ".wav", ".ogg", ".webm", ".flac"}:
return [(audio, 0, duration)]
if not shutil.which("ffmpeg"):
if audio.stat().st_size <= max_bytes:
return [(audio, 0, duration)]
raise RuntimeError("audio exceeds size limit and ffmpeg is not installed")
if not force_split:
compressed = tmpdir / "compressed.mp3"
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(audio), "-ar", "16000", "-ac", "1", "-b:a", "48k",
str(compressed),
], check=True, timeout=600)
if compressed.exists() and compressed.stat().st_size <= max_bytes:
return [(compressed, 0, duration)]
total = max(duration, 1)
step = max(60, segment_seconds)
n_segs = (total + step - 1) // step
out: list[tuple[Path, int, int]] = []
for i in range(n_segs):
start = i * step
end = min(total, start + step)
seg = tmpdir / f"segment_{i + 1:03d}.mp3"
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-ss", str(start), "-t", str(step), "-i", str(audio),
"-ar", "16000", "-ac", "1", "-b:a", "64k", str(seg),
], check=True, timeout=600)
out.append((seg, start, end))
return out
def transcribe_qwen3cam(audio: Path, *, model: str = "") -> TranscribeResult:
result = subprocess.run([
"curl", "-sS", "--fail-with-body", "--max-time", "900",
"-F", f"file=@{audio}", "-F", "language=Chinese",
"-F", "save_voiceprint_session=true", CFG.diarize_url,
], capture_output=True, timeout=930)
if result.returncode:
detail = result.stderr.decode("utf-8", errors="ignore")[:240]
raise RuntimeError(f"qwen3cam ASR HTTP failed ({result.returncode}): {detail}")
try:
payload = json.loads(result.stdout)
except ValueError as exc:
raise RuntimeError("qwen3cam returned invalid JSON") from exc
text = transcript_from_qwen3cam_response(payload)
vp = payload.get("voiceprint") or {}
return TranscribeResult(
provider="qwen3cam",
model=model or PROVIDERS["qwen3cam"]["default_model"],
transcript_text=text,
raw_response=payload,
diarized=True,
voiceprint_session=str(vp.get("session_id") or ""),
)
def build_soniox_context(title: str = "", user_context: str = "") -> dict[str, Any]:
ctx: dict[str, Any] = {}
general = [{"key": "domain", "value": "Biotech / Investment / Corporate Meeting"}]
if title.strip():
general.append({"key": "title", "value": title.strip()[:120]})
ctx["general"] = general
if user_context.strip():
ctx["text"] = user_context.strip()[:2000]
terms = collect_hotwords(120)
if terms:
ctx["terms"] = terms
return ctx
def transcribe_soniox(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
poll_interval: float = 3.0,
max_wait_seconds: float = 900.0,
) -> TranscribeResult:
key = CFG.soniox_api_key
if not key:
raise RuntimeError("SONIOX_API_KEY is not configured")
base = CFG.soniox_api_base
chosen_model = model or CFG.soniox_model or "stt-async-v5"
headers = {"Authorization": f"Bearer {key}"}
file_resp = _http_multipart(
f"{base}/files",
fields={},
file_field="file",
file_path=audio,
headers=headers,
timeout=600,
)
file_id = str(file_resp.get("id") or "")
if not file_id:
raise RuntimeError(f"Soniox file upload returned no id: {file_resp}")
transcription_id = ""
try:
job_payload: dict[str, Any] = {
"model": chosen_model,
"file_id": file_id,
"enable_speaker_diarization": True,
"enable_language_identification": True,
"language_hints": ["zh", "en"],
"context": build_soniox_context(title, user_context),
}
job_resp = _http_json(
"POST", f"{base}/transcriptions",
payload=job_payload, headers=headers, timeout=60,
)
transcription_id = str(job_resp.get("id") or "")
if not transcription_id:
raise RuntimeError(f"Soniox transcription create returned no id: {job_resp}")
deadline = time.time() + max_wait_seconds
status_resp: dict[str, Any] = {}
while time.time() < deadline:
status_resp = _http_json(
"GET", f"{base}/transcriptions/{transcription_id}",
headers=headers, timeout=60,
)
st = str(status_resp.get("status") or "").lower()
if st == "completed":
break
if st in ("error", "failed"):
err = status_resp.get("error_message") or status_resp
raise RuntimeError(f"Soniox transcription failed: {err}")
time.sleep(poll_interval)
else:
raise RuntimeError("Soniox transcription timed out waiting for completion")
if "tokens" in status_resp or "segments" in status_resp:
transcript_payload = status_resp
else:
transcript_payload = _http_json(
"GET", f"{base}/transcriptions/{transcription_id}/transcript",
headers=headers, timeout=120,
)
text = transcript_from_soniox_response(
transcript_payload, model=chosen_model)
return TranscribeResult(
provider="soniox",
model=chosen_model,
transcript_text=text,
raw_response=transcript_payload,
diarized=True,
)
finally:
for cleanup_url in (
f"{base}/transcriptions/{transcription_id}" if transcription_id else "",
f"{base}/files/{file_id}" if file_id else "",
):
if cleanup_url:
try:
_http_json("DELETE", cleanup_url, headers=headers, timeout=15)
except Exception:
pass
def transcribe_gemini(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
chosen_model = model or CFG.gemini_asr_model or "aistudio/gemini-3.5-transcribe"
headers = {"Authorization": f"Bearer {CFG.api_key()}"}
with tempfile.TemporaryDirectory(prefix="asr-gemini-") as tmp:
segments = _compress_or_split_audio(
audio, Path(tmp),
max_bytes=18 * 1024 * 1024,
segment_seconds=CFG.segment_seconds,
force_split=probe_duration_seconds(audio) > CFG.segment_seconds + 60,
)
responses: list[dict[str, Any]] = []
parts: list[str] = [f"<!-- asr_provider: gemini model: {chosen_model} -->"]
for idx, (seg_path, start, end) in enumerate(segments, 1):
resp = _http_multipart(
CFG.transcriptions_endpoint,
fields={"model": chosen_model, "response_format": "json"},
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
responses.append(resp)
seg_text = str(resp.get("text") or resp.get("transcript") or "").strip()
if not seg_text:
raise RuntimeError(f"Gemini segment {idx} returned empty text")
parts.append(
f"--- 第{idx}段 [{start // 60:02d}:{start % 60:02d} - "
f"{end // 60:02d}:{end % 60:02d}] ---\n{seg_text}\n")
return TranscribeResult(
provider="gemini",
model=chosen_model,
transcript_text="\n\n".join(parts).strip() + "\n",
raw_response={"segments": responses},
diarized=False,
)
def transcribe_openai(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
chosen_model = model or CFG.openai_asr_model or "openai/gpt-4o-transcribe-diarize"
headers = {"Authorization": f"Bearer {CFG.api_key()}"}
is_diarize = "diarize" in chosen_model.lower()
with tempfile.TemporaryDirectory(prefix="asr-openai-") as tmp:
segments = _compress_or_split_audio(
audio, Path(tmp),
max_bytes=23 * 1024 * 1024,
segment_seconds=2400,
force_split=False,
)
responses: list[dict[str, Any]] = []
all_diarized_segs: list[dict[str, Any]] = []
plain_parts: list[str] = [f"<!-- asr_provider: openai model: {chosen_model} -->"]
hotwords = collect_hotwords(80)
prompt = ""
if not is_diarize and hotwords:
prompt = "会议专有名词参考:" + "、".join(hotwords)[:800]
for idx, (seg_path, start, end) in enumerate(segments, 1):
fields: dict[str, str] = {
"model": chosen_model,
"language": "zh",
"response_format": "diarized_json" if is_diarize else "json",
}
if prompt:
fields["prompt"] = prompt
try:
resp = _http_multipart(
CFG.transcriptions_endpoint,
fields=fields,
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
except RuntimeError:
if is_diarize:
# Fallback if gateway enforces response_format=json
fields["response_format"] = "json"
resp = _http_multipart(
CFG.transcriptions_endpoint,
fields=fields,
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
else:
raise
responses.append(resp)
if isinstance(resp.get("segments"), list) and resp["segments"]:
all_diarized_segs.extend(resp["segments"])
else:
txt = str(resp.get("text") or resp.get("transcript") or "").strip()
if txt:
plain_parts.append(
f"--- 第{idx}段 [{start // 60:02d}:{start % 60:02d} - "
f"{end // 60:02d}:{end % 60:02d}] ---\n{txt}\n")
if all_diarized_segs:
text = transcript_from_openai_response(
{"segments": all_diarized_segs}, model=chosen_model)
diarized = True
elif len(responses) == 1:
text = transcript_from_openai_response(responses[0], model=chosen_model)
diarized = "【" in text
else:
if len(plain_parts) <= 1:
raise RuntimeError(f"OpenAI ASR ({chosen_model}) returned no text")
text = "\n\n".join(plain_parts).strip() + "\n"
diarized = False
return TranscribeResult(
provider="openai",
model=chosen_model,
transcript_text=text,
raw_response={"segments": responses} if len(responses) > 1 else responses[0],
diarized=diarized,
)
def transcribe_grok(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
key = CFG.grok_api_key
if not key:
raise RuntimeError("XAI_API_KEY is not configured")
chosen_model = model or CFG.grok_asr_model or "grok-voice-transcribe-2.0"
headers = {"Authorization": f"Bearer {key}"}
url = f"{CFG.grok_api_base}/stt"
with tempfile.TemporaryDirectory(prefix="asr-grok-") as tmp:
segments = _compress_or_split_audio(
audio, Path(tmp), max_bytes=23 * 1024 * 1024, segment_seconds=2400)
all_texts: list[str] = []
raw_list: list[dict[str, Any]] = []
for seg_path, _start, _end in segments:
resp = _http_multipart(
url,
fields={"model": chosen_model, "diarize": "true", "language": "zh"},
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
raw_list.append(resp)
all_texts.append(transcript_from_grok_response(resp, model=chosen_model))
text = all_texts[0] if len(all_texts) == 1 else "\n\n".join(all_texts)
return TranscribeResult(
provider="grok",
model=chosen_model,
transcript_text=text,
raw_response=raw_list[0] if len(raw_list) == 1 else {"chunks": raw_list},
diarized=True,
)
def transcribe_audio(
audio: Path,
*,
provider: str | None = None,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
"""Dispatch transcription to the selected provider and return normalized result."""
prov = normalize_provider_name(provider)
log(f"transcribe start provider={prov} model={model or 'default'} file={audio.name}")
if prov == "qwen3cam":
return transcribe_qwen3cam(audio, model=model)
if prov == "soniox":
return transcribe_soniox(audio, model=model, title=title, user_context=user_context)
if prov == "gemini":
return transcribe_gemini(audio, model=model, title=title, user_context=user_context)
if prov == "openai":
return transcribe_openai(audio, model=model, title=title, user_context=user_context)
if prov == "grok":
return transcribe_grok(audio, model=model, title=title, user_context=user_context)
raise ValueError(f"unsupported ASR provider: {prov}")
@@ -28,6 +28,10 @@ from .log import log
SOURCE_DETAIL = {
"qwen3cam": "Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹自动命名",
"soniox": "Soniox stt-async-v5 线上分角色转写",
"gemini": "Gemini 3.5 Transcribe (LiteLLM)",
"openai": "OpenAI GPT-4o Transcribe (LiteLLM)",
"grok": "Grok Voice Transcribe 2.0",
"vibevoice": "VibeVoice 分角色",
"qwen3-plain": "Qwen3-ASR 1.7B",
}
@@ -185,7 +185,8 @@ def _voiceprint_name(value: str) -> str:
def _confirm_voiceprint(draft_path: str, speaker_id: str, value: str) -> bool:
"""Persist an explicit speaker identity and immediately enroll its session."""
"""Persist an explicit speaker identity and, when a voiceprint session exists,
enroll it against the acoustic voiceprint service."""
name = _voiceprint_name(value)
if not name:
return False
@@ -196,8 +197,6 @@ def _confirm_voiceprint(draft_path: str, speaker_id: str, value: str) -> bool:
return False
text = path.read_text(encoding="utf-8", errors="ignore")
session_id = _frontmatter_value(text, "voiceprint_session")
if not session_id:
return False
fm = re.match(r"(?s)^---\n(.*?)\n---", text)
if not fm:
return False
@@ -205,27 +204,34 @@ def _confirm_voiceprint(draft_path: str, speaker_id: str, value: str) -> bool:
speaker_line = rf"(?m)^(\s+{re.escape(speaker_id)}:)\s*.*$"
if re.search(speaker_line, block):
block = re.sub(speaker_line, rf"\1 {name}", block)
elif re.search(r"(?m)^voiceprint_speakers:\s*(?:\{\})?\s*$", block):
block = re.sub(
r"(?m)^voiceprint_speakers:\s*(?:\{\})?\s*$",
f"voiceprint_speakers:\n {speaker_id}: {name}", block, count=1)
elif re.search(r"(?m)^voiceprint_speakers:\s*$", block):
block = re.sub(
r"(?m)^(voiceprint_speakers:\s*)$",
rf"\1\n {speaker_id}: {name}", block, count=1)
else:
block += f"\nvoiceprint_speakers:\n {speaker_id}: {name}"
if re.search(r"(?m)^voiceprint_enroll:", block):
block = re.sub(r"(?m)^voiceprint_enroll:.*$",
"voiceprint_enroll: true", block)
else:
block += "\nvoiceprint_enroll: true"
if session_id:
if re.search(r"(?m)^voiceprint_enroll:", block):
block = re.sub(r"(?m)^voiceprint_enroll:.*$",
"voiceprint_enroll: true", block)
else:
block += "\nvoiceprint_enroll: true"
text = text[:fm.start()] + "---\n" + block + "\n---" + text[fm.end():]
from . import vault
vault.write_draft_file(path, text)
if not session_id or not CFG.voiceprint_enroll_url:
return False
body = json.dumps({
"session_id": session_id, "speaker_id": int(speaker_id.split("_")[1]),
"name": name,
}).encode()
req = urllib.request.Request(
"http://192.168.50.100:10099/voiceprints/enroll", data=body,
CFG.voiceprint_enroll_url, data=body,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=20) as response:
@@ -29,6 +29,8 @@ def _url(token: str) -> str:
def request_start(job_id: str = "") -> bool:
"""Ensure control service is running and send a task-scoped review URL."""
if os.environ.get("ASR_ROLE"):
return True
try:
r = subprocess.run(["systemctl", "--user", "start", UNIT],
capture_output=True, text=True, timeout=15)
@@ -22,6 +22,7 @@ import re
from dataclasses import dataclass, field
VOICEPRINT_HDR = re.compile(r"^<!--\s*voiceprint_session:")
PROVIDER_HDR = re.compile(r"^<!--\s*asr_provider:\s*(\S+)(?:\s+model:\s*(\S+))?\s*-->")
SEGMENT_BANNER = re.compile(r"^---\s*第\d+段.*---\s*$")
TURN_TAG = re.compile(r"^【([^】]+)】\s*(.*)$", re.S)
VIBE_TAG = re.compile(r"^\[(\d{1,2}:\d{2})\]\s*(S\d+)\s*[::]\s*(.*)$", re.S)
@@ -95,6 +96,11 @@ class Chunk:
def detect_backend(raw: str) -> str:
head = raw[:500]
m_prov = PROVIDER_HDR.search(head)
if m_prov:
prov = m_prov.group(1).strip().lower()
if prov in {"qwen3cam", "soniox", "gemini", "openai", "grok", "vibevoice"}:
return prov
if "CAM++" in head or TURN_TAG.match(raw.strip().splitlines()[-1] if raw.strip() else ""):
return "qwen3cam"
if re.search(r"^\[\d{1,2}:\d{2}\]\s*S\d+\s*[::]", raw, re.M):
@@ -108,7 +114,7 @@ def parse(raw: str) -> tuple[list[Turn], dict]:
"""Split a transcript into turns plus header metadata."""
meta: dict = {
"voiceprint_session": "", "voiceprint_named": {}, "banners": [],
"intake": {},
"intake": {}, "asr_provider": "", "asr_model": "",
}
lines = raw.splitlines()
body_lines = []
@@ -120,6 +126,11 @@ def parse(raw: str) -> tuple[list[Turn], dict]:
except (TypeError, ValueError):
pass
continue
m_prov = PROVIDER_HDR.match(ln)
if m_prov:
meta["asr_provider"] = m_prov.group(1) or ""
meta["asr_model"] = m_prov.group(2) or ""
continue
if VOICEPRINT_HDR.match(ln):
m = re.search(r"voiceprint_session:\s*(\S+)", ln)
if m:
@@ -1 +1 @@
"""Standalone ASR service for web, Agent API, and SSH drop-folder intake."""
"""Standalone WebUI, REST API, and background worker service for Meeting ASR."""
@@ -3,23 +3,27 @@ from __future__ import annotations
import os
from pathlib import Path
from asrpipe.config import CFG
DATA = Path(os.getenv("ASR_DATA_DIR", "/data"))
INBOX = DATA / "inbox"
PROCESSING = DATA / "processing"
PROCESSED = DATA / "processed"
FAILED = DATA / "failed"
TRANSCRIPTS = DATA / "transcripts"
STATE = Path(os.getenv("ASR_STATE_DIR", "/state"))
DRAFTS = DATA / "artifacts" / "drafts"
RESULTS = DATA / "artifacts" / "results"
STATE = Path(os.getenv("ASR_STATE_DIR", str(CFG.state_dir)))
DRAFTS = Path(os.getenv("ASR_DRAFTS_DIR", str(DATA / "artifacts" / "drafts")))
RESULTS = Path(os.getenv("ASR_RESULTS_DIR", str(DATA / "artifacts" / "results")))
LOGS = DATA / "logs"
META = STATE / "uploads"
REVISION_QUEUE = STATE / "revision-queue"
REVISION_QUEUE = Path(os.getenv("ASR_REVISION_QUEUE_DIR", str(STATE / "revision-queue")))
DB = STATE / "intake.db"
SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg", ".opus", ".webm", ".flac"}
PUBLIC_URL = os.getenv("ASR_PUBLIC_URL", "http://192.168.50.49:8788").rstrip("/")
AGENT_TOKEN = os.getenv("ASR_AGENT_TOKEN", "")
DIARIZE_URL = os.getenv("ASR_DIARIZE_URL", "http://192.168.50.100:10099/asr")
WEB_PASSWORD = os.getenv("ASR_WEB_PASSWORD", "")
DIARIZE_URL = os.getenv("ASR_DIARIZE_URL", CFG.diarize_url)
DEFAULT_PROVIDER = os.getenv("ASR_PROVIDER", CFG.asr_provider)
MAX_UPLOAD = int(os.getenv("ASR_UPLOAD_MAX_BYTES", str(4 * 1024**3)))
POLL_SECONDS = max(1, int(os.getenv("ASR_POLL_SECONDS", "5")))
STABLE_SECONDS = max(1, int(os.getenv("ASR_STABLE_SECONDS", "10")))
@@ -27,8 +31,13 @@ MAX_ATTEMPTS = max(1, int(os.getenv("ASR_MAX_ATTEMPTS", "3")))
def ensure_dirs() -> None:
for path in (INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, DRAFTS,
RESULTS, STATE, LOGS, META, REVISION_QUEUE, DATA / "cache",
DATA / "artifacts" / "raw", DATA / "vault" / "minutes",
DATA / "vault" / "People", DATA / "vault" / "Companies"):
# Keep asrpipe CFG aligned with container/service paths when running under asrservice
CFG.state_dir
for path in (
INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, DRAFTS,
RESULTS, STATE, LOGS, META, REVISION_QUEUE, DATA / "cache",
DATA / "artifacts" / "raw", DATA / "artifacts" / "results",
DATA / "vault" / "minutes", DATA / "vault" / "People",
DATA / "vault" / "Companies", DATA / "vault" / "Projects",
):
path.mkdir(parents=True, exist_ok=True)
+47 -20
View File
@@ -1,14 +1,15 @@
from __future__ import annotations
import json
import sqlite3
import time
from typing import Any
from .config import DB, ensure_dirs
from .config import DB, DEFAULT_PROVIDER, ensure_dirs
STATUSES = {"uploading", "queued", "transcribing", "refining", "review",
"publishing", "complete", "failed"}
STATUSES = {
"uploading", "queued", "transcribing", "refining", "review",
"publishing", "complete", "failed",
}
def connect() -> sqlite3.Connection:
@@ -26,6 +27,8 @@ def connect() -> sqlite3.Connection:
draft TEXT NOT NULL DEFAULT '', result TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'web', sha256 TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0, error_code TEXT NOT NULL DEFAULT '',
asr_provider TEXT NOT NULL DEFAULT 'qwen3cam',
asr_model TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL, updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS job_event(
@@ -37,9 +40,14 @@ def connect() -> sqlite3.Connection:
""")
cols = {r[1] for r in con.execute("PRAGMA table_info(intake_job)")}
additions = {
"draft": "TEXT NOT NULL DEFAULT ''", "result": "TEXT NOT NULL DEFAULT ''",
"source": "TEXT NOT NULL DEFAULT 'web'", "sha256": "TEXT NOT NULL DEFAULT ''",
"attempts": "INTEGER NOT NULL DEFAULT 0", "error_code": "TEXT NOT NULL DEFAULT ''",
"draft": "TEXT NOT NULL DEFAULT ''",
"result": "TEXT NOT NULL DEFAULT ''",
"source": "TEXT NOT NULL DEFAULT 'web'",
"sha256": "TEXT NOT NULL DEFAULT ''",
"attempts": "INTEGER NOT NULL DEFAULT 0",
"error_code": "TEXT NOT NULL DEFAULT ''",
"asr_provider": f"TEXT NOT NULL DEFAULT '{DEFAULT_PROVIDER}'",
"asr_model": "TEXT NOT NULL DEFAULT ''",
}
for name, ddl in additions.items():
if name not in cols:
@@ -48,23 +56,40 @@ def connect() -> sqlite3.Connection:
return con
def create_job(job_id: str, original_name: str, stored_name: str, *, title: str = "",
context: str = "", size: int = 0, source: str = "web") -> dict[str, Any]:
def create_job(
job_id: str,
original_name: str,
stored_name: str,
*,
title: str = "",
context: str = "",
size: int = 0,
source: str = "web",
asr_provider: str = "",
asr_model: str = "",
) -> dict[str, Any]:
now = time.time()
prov = (asr_provider or DEFAULT_PROVIDER or "qwen3cam").strip().lower()
with connect() as con:
con.execute("INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
"status,source,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
(job_id, original_name, stored_name, title, context, size,
"queued", source, now, now))
con.execute("INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
(job_id, "queued", "录音已进入队列", now))
con.execute(
"INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
"status,source,asr_provider,asr_model,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(job_id, original_name, stored_name, title, context, size,
"queued", source, prov, asr_model, now, now))
con.execute(
"INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
(job_id, "queued", f"录音已进入队列 (ASR: {prov})", now))
return get_job(job_id) or {}
def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> bool:
if status not in STATUSES:
raise ValueError(f"invalid status: {status}")
allowed = {"transcript", "draft", "result", "sha256", "attempts", "error_code"}
allowed = {
"transcript", "draft", "result", "sha256", "attempts",
"error_code", "asr_provider", "asr_model",
}
changes = {k: v for k, v in fields.items() if k in allowed and v is not None}
sets = ["status=?", "updated_at=?", "detail=?"]
values: list[Any] = [status, time.time(), detail[:1000]]
@@ -75,8 +100,9 @@ def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> boo
with connect() as con:
cur = con.execute(f"UPDATE intake_job SET {','.join(sets)} WHERE id=?", values)
if cur.rowcount:
con.execute("INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
(job_id, status, detail[:1000], time.time()))
con.execute(
"INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
(job_id, status, detail[:1000], time.time()))
return bool(cur.rowcount)
@@ -88,8 +114,9 @@ def get_job(job_id: str) -> dict[str, Any] | None:
def find_by_stored(stored_name: str) -> dict[str, Any] | None:
with connect() as con:
row = con.execute("SELECT * FROM intake_job WHERE stored_name=? ORDER BY created_at DESC LIMIT 1",
(stored_name,)).fetchone()
row = con.execute(
"SELECT * FROM intake_job WHERE stored_name=? ORDER BY created_at DESC LIMIT 1",
(stored_name,)).fetchone()
return dict(row) if row else None
@@ -2,4 +2,6 @@ import time
from .config import STATE
heartbeat = STATE / "worker.heartbeat"
raise SystemExit(0 if heartbeat.exists() and time.time() - heartbeat.stat().st_mtime < 90 else 1)
raise SystemExit(
0 if heartbeat.exists() and time.time() - heartbeat.stat().st_mtime < 90 else 1
)
@@ -15,21 +15,28 @@ def send(status: str, filename: str = "", detail: str = "") -> bool:
chat = os.getenv("TELEGRAM_CHAT_ID", "")
if not token or not chat:
return False
body = parse.urlencode({"chat_id": chat,
"text": "\n".join(x for x in (status, filename, detail) if x)}).encode()
req = request.Request(f"https://api.telegram.org/bot{token}/sendMessage",
data=body, method="POST")
body = parse.urlencode({
"chat_id": chat,
"text": "\n".join(x for x in (status, filename, detail) if x),
}).encode()
req = request.Request(
f"https://api.telegram.org/bot{token}/sendMessage",
data=body, method="POST")
elif backend == "hermes":
url = os.getenv("HERMES_WEBHOOK_URL", "")
secret = os.getenv("HERMES_WEBHOOK_SECRET", "")
if not url or not secret:
return False
body = json.dumps({"status": status, "filename": filename, "detail": detail},
ensure_ascii=False).encode()
body = json.dumps(
{"status": status, "filename": filename, "detail": detail},
ensure_ascii=False).encode()
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
req = request.Request(url, data=body, method="POST",
headers={"Content-Type": "application/json",
"X-Webhook-Signature": sig})
req = request.Request(
url, data=body, method="POST",
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": sig,
})
else:
return False
with request.urlopen(req, timeout=10) as response:
File diff suppressed because it is too large Load Diff
@@ -6,17 +6,17 @@ import json
import os
import shutil
import sqlite3
import subprocess
import time
import uuid
from pathlib import Path
from asrpipe import refine, review, revise, vault
from asrpipe import graph, lexicon, providers, refine, review, revise, vault
from . import db
from .config import (DIARIZE_URL, DRAFTS, FAILED, INBOX, MAX_ATTEMPTS, META,
POLL_SECONDS, PROCESSED, PROCESSING, PUBLIC_URL,
REVISION_QUEUE, STABLE_SECONDS, STATE, SUPPORTED, TRANSCRIPTS, LOGS,
ensure_dirs)
from .config import (
DATA, DEFAULT_PROVIDER, DRAFTS, FAILED, INBOX, LOGS, MAX_ATTEMPTS,
POLL_SECONDS, PROCESSED, PROCESSING, PUBLIC_URL, REVISION_QUEUE,
STABLE_SECONDS, STATE, SUPPORTED, TRANSCRIPTS, ensure_dirs,
)
from .notify import send
@@ -35,69 +35,83 @@ def sha256(path: Path) -> str:
def transcript_from_response(payload: dict) -> str:
segments = payload.get("segments")
if not isinstance(segments, list) or not segments:
raise RuntimeError("ASR response contains no segments")
vp = payload.get("voiceprint") or {}
sid = str(vp.get("session_id") or "")
matched = [f"{m.get('speaker_local')}={m.get('person_name')}" for m in vp.get("matches", [])
if m.get("status") == "matched"]
speakers = {str(s.get("speaker")) for s in segments if s.get("speaker") is not None}
lines = [f"<!-- voiceprint_session: {sid} clusters: {len(speakers)} -->",
"# 转写(Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹)"
+ (" | 声纹命名: " + ", ".join(matched) if matched else ""), ""]
turns: list[list[str]] = []
for segment in segments:
speaker, text = str(segment.get("speaker", "?")), str(segment.get("text") or "").strip()
if not text:
continue
if turns and turns[-1][0] == speaker:
turns[-1][1] += text
else:
turns.append([speaker, text])
lines.extend(f"【{speaker}】{text}\n" for speaker, text in turns)
return "\n".join(lines).strip() + "\n"
"""Backward-compatible helper for qwen3cam JSON responses."""
return providers.transcript_from_qwen3cam_response(payload)
def transcribe(audio: Path, job_id: str) -> Path:
def transcribe(
audio: Path,
job_id: str,
*,
provider: str = "",
model: str = "",
title: str = "",
user_context: str = "",
) -> tuple[Path, providers.TranscribeResult]:
res = providers.transcribe_audio(
audio,
provider=provider or DEFAULT_PROVIDER,
model=model,
title=title,
user_context=user_context,
)
response_path = TRANSCRIPTS / f"{job_id}.asr.json"
result = subprocess.run([
"curl", "-sS", "--fail-with-body", "--max-time", "900",
"-F", f"file=@{audio}", "-F", "language=Chinese",
"-F", "save_voiceprint_session=true", DIARIZE_URL,
], capture_output=True, timeout=930)
if result.returncode:
raise RuntimeError(f"ASR HTTP failed ({result.returncode})")
response_path.write_bytes(result.stdout)
try:
payload = json.loads(result.stdout)
except ValueError as exc:
raise RuntimeError("ASR returned invalid JSON") from exc
response_path.write_text(
json.dumps(res.raw_response, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
pass
transcript = TRANSCRIPTS / f"{job_id}.txt"
transcript.write_text(transcript_from_response(payload), encoding="utf-8")
return transcript
transcript.write_text(res.transcript_text, encoding="utf-8")
return transcript, res
def process_audio(source: Path, job: dict) -> None:
job_id = job["id"]
attempts = int(job.get("attempts") or 0) + 1
prov = providers.normalize_provider_name(job.get("asr_provider") or DEFAULT_PROVIDER)
model = str(job.get("asr_model") or "")
digest = sha256(source)
db.update_job(job_id, "transcribing", "正在进行分角色转写", sha256=digest,
attempts=attempts, error_code="")
transcript = transcribe(source, job_id)
intake = {"job_id": job_id, "title": job.get("title", ""),
"context": job.get("context", ""), "source": job.get("source", "")}
prov_label = providers.PROVIDERS.get(prov, {}).get("label", prov)
db.update_job(
job_id, "transcribing", f"正在转写 ({prov_label})",
sha256=digest, attempts=attempts, error_code="",
asr_provider=prov, asr_model=model,
)
transcript, res = transcribe(
source, job_id,
provider=prov, model=model,
title=str(job.get("title") or ""),
user_context=str(job.get("context") or ""),
)
intake = {
"job_id": job_id,
"title": job.get("title", ""),
"context": job.get("context", ""),
"source": job.get("source", ""),
"asr_provider": res.provider,
"asr_model": res.model,
}
raw = transcript.read_text(encoding="utf-8")
raw = f"<!-- intake: {json.dumps(intake, ensure_ascii=False)} -->\n" + raw
transcript.write_text(raw, encoding="utf-8")
db.update_job(job_id, "refining", "转写完成,正在生成整理稿", transcript=str(transcript))
result = refine.refine(raw, date=time.strftime("%Y-%m-%d"),
transcript_path=str(transcript), queue_review=True)
db.update_job(
job_id, "refining", f"转写完成 ({res.provider}:{res.model}),正在生成整理稿",
transcript=str(transcript), asr_provider=res.provider, asr_model=res.model,
)
result = refine.refine(
raw, date=time.strftime("%Y-%m-%d"),
transcript_path=str(transcript), queue_review=True,
)
draft = DRAFTS / f"{job_id}.md"
vault.write_draft_file(draft, result.draft)
questions = review.enqueue_draft(str(transcript), draft)
db.update_job(job_id, "review", f"整理稿已生成,{questions} 个问题待确认",
transcript=str(transcript), draft=str(draft))
db.update_job(
job_id, "review", f"整理稿已生成,{questions} 个问题待确认",
transcript=str(transcript), draft=str(draft),
)
target = PROCESSED / source.name
if target.exists():
target = PROCESSED / f"{source.stem}-{job_id[:8]}{source.suffix}"
@@ -110,8 +124,11 @@ def claim(path: Path) -> tuple[Path, dict]:
job = db.find_by_stored(path.name)
if not job:
job_id = uuid.uuid4().hex[:24]
job = db.create_job(job_id, path.name, path.name, title=path.stem,
size=path.stat().st_size, source="ssh")
job = db.create_job(
job_id, path.name, path.name, title=path.stem,
size=path.stat().st_size, source="ssh",
asr_provider=DEFAULT_PROVIDER,
)
target = PROCESSING / path.name
if target.exists():
target = PROCESSING / f"{path.stem}-{job['id'][:8]}{path.suffix}"
@@ -132,10 +149,12 @@ def scan_inbox() -> None:
except Exception as exc:
attempts = int((db.get_job(job["id"]) or {}).get("attempts") or 0)
retry = attempts < MAX_ATTEMPTS
db.update_job(job["id"], "queued" if retry else "failed",
"处理失败,等待重试" if retry else "处理失败",
error_code=type(exc).__name__)
log(f"job={job['id']} failed type={type(exc).__name__}")
db.update_job(
job["id"], "queued" if retry else "failed",
f"处理失败,等待重试 ({exc})" if retry else f"处理失败:{exc}",
error_code=type(exc).__name__,
)
log(f"job={job['id']} failed type={type(exc).__name__} err={exc}")
target = INBOX / source.name if retry else FAILED / source.name
if source.exists():
shutil.move(str(source), target)
@@ -151,27 +170,41 @@ def process_revisions() -> None:
revise.revise(draft)
current = db.get_job(job_id) or {}
if current.get("status") == "complete":
send("ASR 正式记录已生成", current.get("title") or current.get("original_name", ""),
send("ASR 正式记录已生成",
current.get("title") or current.get("original_name", ""),
f"{PUBLIC_URL}/jobs/{job_id}/")
elif current.get("status") != "review":
db.update_job(job_id, "review", "定稿预览已生成,等待发布确认", draft=str(draft))
db.update_job(
job_id, "review", "定稿预览已生成,等待发布确认",
draft=str(draft),
)
marker.unlink(missing_ok=True)
except Exception as exc:
log(f"revision marker={marker.name} failed type={type(exc).__name__}")
log(f"revision marker={marker.name} failed type={type(exc).__name__} err={exc}")
def recover() -> None:
for job in db.pending_recovery():
if job["status"] in {"transcribing", "refining"}:
candidate = next((p for p in PROCESSING.iterdir() if p.name == job["stored_name"]), None)
candidate = next(
(p for p in PROCESSING.iterdir() if p.name == job["stored_name"]),
None,
)
if candidate:
shutil.move(str(candidate), INBOX / candidate.name)
db.update_job(job["id"], "queued", "worker 重启后重新排队")
def sync_vault_knowledge() -> None:
try:
lexicon.sync_from_vault()
graph.sync()
except Exception as exc:
log(f"vault knowledge sync skipped: {exc}")
def backup_databases() -> None:
# STATE is local disk; durable copies belong on the NFS data volume.
backup_dir = Path("/data/backups")
backup_dir = DATA / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
for name in ("intake.db", "lexicon.db"):
source = STATE / name
@@ -184,22 +217,34 @@ def backup_databases() -> None:
temp.replace(target)
def main() -> None:
def run_loop() -> None:
os.environ.setdefault("ASR_ROLE", "worker")
os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE))
ensure_dirs()
lock = (STATE / "worker.lock").open("w")
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
recover()
sync_vault_knowledge()
(STATE / "worker.heartbeat").write_text(str(time.time()))
last_backup = 0.0
last_vault_sync = time.time()
while True:
scan_inbox()
process_revisions()
if time.time() - last_backup >= 3600:
now = time.time()
if now - last_vault_sync >= 900:
sync_vault_knowledge()
last_vault_sync = now
if now - last_backup >= 3600:
backup_databases()
last_backup = time.time()
(STATE / "worker.heartbeat").write_text(str(time.time()))
last_backup = now
(STATE / "worker.heartbeat").write_text(str(now))
time.sleep(POLL_SECONDS)
def main() -> None:
run_loop()
if __name__ == "__main__":
main()
+19 -6
View File
@@ -1,11 +1,24 @@
x-asr-environment: &asr-environment
ZENMUX_API_KEY: ${ZENMUX_API_KEY:-}
ASR_DRAFT_MODEL: ${ASR_DRAFT_MODEL:-anthropic/claude-sonnet-5.5}
ASR_AGENT_TOKEN: ${ASR_AGENT_TOKEN:-}
ASR_PUBLIC_URL: ${ASR_PUBLIC_URL:-http://192.168.50.49:8788}
ASR_PROVIDER: ${ASR_PROVIDER:-qwen3cam}
ASR_DIARIZE_URL: ${ASR_DIARIZE_URL:-http://192.168.50.100:10099/asr}
ASR_VOICEPRINT_ENROLL_URL: ${ASR_VOICEPRINT_ENROLL_URL:-http://192.168.50.100:10099/voiceprints/enroll}
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://192.168.50.49:4000/v1}
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
ASR_GEMINI_MODEL: ${ASR_GEMINI_MODEL:-aistudio/gemini-3.5-transcribe}
ASR_OPENAI_MODEL: ${ASR_OPENAI_MODEL:-openai/gpt-4o-transcribe-diarize}
SONIOX_API_KEY: ${SONIOX_API_KEY:-}
SONIOX_MODEL: ${SONIOX_MODEL:-stt-async-v5}
XAI_API_KEY: ${XAI_API_KEY:-}
ASR_GROK_MODEL: ${ASR_GROK_MODEL:-grok-voice-transcribe-2.0}
ASR_CLEAN_MODEL: ${ASR_CLEAN_MODEL:-aistudio/gemini-flash-lite-latest}
ASR_KEYWORD_MODEL: ${ASR_KEYWORD_MODEL:-aistudio/gemini-flash-lite-latest}
ASR_DRAFT_MODEL: ${ASR_DRAFT_MODEL:-codex/gpt-sol-latest}
ASR_AGENT_TOKEN: ${ASR_AGENT_TOKEN:-}
ASR_WEB_PASSWORD: ${ASR_WEB_PASSWORD:-}
ASR_PUBLIC_URL: ${ASR_PUBLIC_URL:-http://192.168.50.49:8788}
HINDSIGHT_API: ${HINDSIGHT_API:-http://192.168.50.47:8888}
HINDSIGHT_BANK: ${HINDSIGHT_BANK:-personal-context}
ASR_GIT_SYNC: ${ASR_GIT_SYNC:-0}
ASR_NOTIFY_BACKEND: ${ASR_NOTIFY_BACKEND:-none}
HERMES_WEBHOOK_URL: ${HERMES_WEBHOOK_URL:-}
HERMES_WEBHOOK_SECRET: ${HERMES_WEBHOOK_SECRET:-}
@@ -14,7 +27,7 @@ x-asr-environment: &asr-environment
services:
asr-web:
image: tools-asr-pipeline:2026-09-18
image: tools-asr-pipeline:2026-11-02
pull_policy: never
build:
context: .
@@ -39,7 +52,7 @@ services:
- no-new-privileges:true
asr-worker:
image: tools-asr-pipeline:2026-09-18
image: tools-asr-pipeline:2026-11-02
pull_policy: never
container_name: tools-asr-worker
restart: unless-stopped
@@ -1,3 +1,3 @@
pypinyin==0.55.0
rapidfuzz==3.14.1
numpy==2.3.3
pypinyin>=0.51,<1
rapidfuzz>=3,<4
numpy>=1.26,<3
@@ -1,51 +1,197 @@
from __future__ import annotations
import importlib
import json
import os
import tempfile
import threading
import unittest
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from unittest import mock
TMP = tempfile.TemporaryDirectory()
os.environ["ASR_DATA_DIR"] = str(Path(TMP.name) / "data")
os.environ["ASR_STATE_DIR"] = str(Path(TMP.name) / "state")
os.environ["ASR_VAULT"] = str(Path(TMP.name) / "vault")
os.environ["ASR_RECORDINGS_ROOT"] = str(Path(TMP.name) / "recordings")
os.environ["ASR_AGENT_TOKEN"] = "test-agent-token"
os.environ["ASR_NOTIFY"] = "0"
os.environ["ASR_GIT_SYNC"] = "0"
from asrpipe import lexicon, providers, review, segment # noqa: E402
from asrservice import config as svc_cfg, db as svc_db, web as svc_web # noqa: E402
class ServiceTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
os.environ["ASR_DATA_DIR"] = str(Path(cls.tmp.name) / "data")
os.environ["ASR_STATE_DIR"] = str(Path(cls.tmp.name) / "state")
from asrservice import config, db, worker
cls.config, cls.db, cls.worker = config, db, worker
class ProviderAndServiceTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
svc_cfg.ensure_dirs()
svc_db.connect().close()
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
def tearDown(self):
self.tmp.cleanup()
def test_job_events_follow_updates(self):
row = self.db.create_job("a" * 24, "x.m4a", "x.m4a", source="ssh")
self.assertEqual(row["status"], "queued")
self.db.update_job(row["id"], "transcribing", "started", attempts=1)
current = self.db.get_job(row["id"])
self.assertEqual(current["attempts"], 1)
self.assertEqual([e["status"] for e in self.db.events(row["id"])],
["queued", "transcribing"])
def test_soniox_token_diarization_and_segment_parse(self):
payload = {
"tokens": [
{"text": "大家", "speaker": "1"},
{"text": "好,", "speaker": "1"},
{"text": "今天", "speaker": "1"},
{"text": "讨论", "speaker": "1"},
{"text": "预算。", "speaker": "1"},
{"text": "收到,", "speaker": "2"},
{"text": "我来", "speaker": "2"},
{"text": "汇报。", "speaker": "2"},
]
}
text = providers.transcript_from_soniox_response(payload, model="stt-async-v5")
parsed_turns, meta = segment.parse(text)
self.assertEqual(meta["asr_provider"], "soniox")
self.assertEqual(meta["asr_model"], "stt-async-v5")
self.assertEqual(len(parsed_turns), 2)
self.assertEqual(parsed_turns[0].speaker, "speaker_0")
self.assertEqual(parsed_turns[0].text, "大家好,今天讨论预算。")
self.assertEqual(parsed_turns[1].speaker, "speaker_1")
self.assertEqual(parsed_turns[1].text, "收到,我来汇报。")
def test_transcript_contract(self):
text = self.worker.transcript_from_response({
"segments": [
{"speaker": "speaker_0", "text": "你好。"},
{"speaker": "speaker_0", "text": "继续。"},
{"speaker": "speaker_1", "text": "收到。"},
],
"voiceprint": {"session_id": "s1", "matches": []},
})
self.assertIn("voiceprint_session: s1", text)
self.assertIn("【speaker_0】你好。继续。", text)
self.assertEqual(text.count("【speaker_0】"), 1)
def test_soniox_context_includes_lexicon_and_intake(self):
lexicon.learn_alias("lite网关", "LiteLLM", note="test")
ctx = providers.build_soniox_context(
title="网关评审",
user_context="讨论 LiteLLM 与 Soniox 集成",
)
self.assertIn("terms", ctx)
self.assertIn("LiteLLM", ctx["terms"])
self.assertIn("讨论 LiteLLM", ctx.get("text", ""))
def test_transcript_rejects_empty_segments(self):
with self.assertRaises(RuntimeError):
self.worker.transcript_from_response({"segments": []})
def test_openai_and_grok_diarized_transcriptions(self):
audio = self.root / "sample.mp3"
audio.write_bytes(b"ID3-fake-audio")
def fake_openai_multipart(url, *, fields, file_field, file_path, headers=None, timeout=900):
return {
"segments": [
{"speaker": "A", "text": "第一句发言。"},
{"speaker": "A", "text": "继续补充。"},
{"speaker": "B", "text": "第二位发言。"},
]
}
with mock.patch("asrpipe.providers._http_multipart", side_effect=fake_openai_multipart), \
mock.patch("asrpipe.providers.probe_duration_seconds", return_value=30), \
mock.patch.object(providers.CFG, "api_key", return_value="sk-test"):
res = providers.transcribe_openai(
audio,
title="OpenAI 测试",
model="openai/gpt-4o-transcribe-diarize",
)
turns, meta = segment.parse(res.transcript_text)
self.assertEqual(meta["asr_provider"], "openai")
self.assertEqual(len(turns), 2)
self.assertIn("第一句发言。继续补充。", turns[0].text)
self.assertEqual(turns[1].speaker, "speaker_1")
def fake_grok_multipart(url, *, fields, file_field, file_path, headers=None, timeout=900):
return {
"utterances": [
{"speaker": 0, "text": "Grok 说话人零。"},
{"speaker": 1, "text": "Grok 说话人一。"},
]
}
with mock.patch("asrpipe.providers._http_multipart", side_effect=fake_grok_multipart), \
mock.patch("asrpipe.providers.probe_duration_seconds", return_value=30), \
mock.patch.object(providers.CFG, "grok_api_key", "xai-test"):
gres = providers.transcribe_grok(
audio,
title="Grok 测试",
)
gturns, gmeta = segment.parse(gres.transcript_text)
self.assertEqual(gmeta["asr_provider"], "grok")
self.assertEqual(len(gturns), 2)
self.assertEqual(gturns[0].speaker, "speaker_0")
self.assertEqual(gturns[1].speaker, "speaker_1")
def test_online_speaker_confirmation_updates_draft_without_voiceprint_server(self):
drafts_dir = review.CFG.drafts
drafts_dir.mkdir(parents=True, exist_ok=True)
draft = drafts_dir / "online-draft.md"
draft.write_text(
"---\n"
"title: 线上模型会议\n"
"status: draft\n"
"asr_provider: soniox\n"
"voiceprint_session: ''\n"
"voiceprint_speakers: {}\n"
"---\n\n"
"【speaker_1】发言内容\n",
encoding="utf-8",
)
with mock.patch.object(review.CFG, "voiceprint_enroll_url", "http://127.0.0.1:9/unreachable"):
enrolled = review._confirm_voiceprint(str(draft), "speaker_1", "张三")
self.assertFalse(enrolled)
updated = draft.read_text(encoding="utf-8")
self.assertIn("speaker_1: 张三", updated)
def test_db_and_web_endpoints(self):
job_id = "0123456789abcdef01234567"
job = svc_db.create_job(
job_id=job_id,
original_name="meeting.m4a",
stored_name=f"20261102-{job_id}.m4a",
title="架构周会",
context="验证 Web API",
size=2048,
asr_provider="soniox",
asr_model="stt-async-v5",
)
self.assertEqual(job["asr_provider"], "soniox")
self.assertEqual(job["asr_model"], "stt-async-v5")
svc_web.AGENT_TOKEN = "test-agent-token"
server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
def fetch(path: str, headers: dict[str, str] | None = None) -> tuple[int, str]:
req = urllib.request.Request(
f"http://127.0.0.1:{port}{path}",
headers=headers or {},
)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, resp.read().decode("utf-8")
status, body = fetch("/health")
self.assertEqual(status, 200)
self.assertEqual(json.loads(body)["ok"], True)
status, body = fetch(
"/api/v1/providers",
headers={"Authorization": "Bearer test-agent-token"},
)
self.assertEqual(status, 200)
prov_data = json.loads(body)
prov_ids = [p["id"] for p in prov_data["providers"]]
self.assertEqual(prov_ids, ["qwen3cam", "soniox", "gemini", "openai", "grok"])
status, body = fetch(f"/jobs/{job_id}/")
self.assertEqual(status, 200)
self.assertIn("架构周会", body)
self.assertIn("soniox", body)
status, body = fetch("/lexicon")
self.assertEqual(status, 200)
self.assertIn("词库", body)
status, body = fetch("/settings")
self.assertEqual(status, 200)
self.assertIn("LiteLLM", body)
finally:
server.shutdown()
server.server_close()
if __name__ == "__main__":