feat(tools-pve/asr-pipeline): multi-backend ASR web app with LiteLLM routing
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user