Files
infra-docker-configs/servers/tools-pve/asr-pipeline/tests/test_service.py
T
Codex 3c70861463
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
2026-11-02 06:37:33 -08:00

343 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import os
import tempfile
import threading
import unittest
import urllib.parse
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 asrpipe.config import CFG # noqa: E402
from asrservice import config as svc_cfg, db as svc_db, web as svc_web # noqa: E402
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()
def tearDown(self):
self.tmp.cleanup()
def test_sqlite_settings_override_config_dynamically(self):
svc_db.save_settings({
"asr_provider": "soniox",
"litellm_base_url": "https://api.opfo.xyz/v1",
"litellm_api_key": "sk-sqlite-dynamic-key",
"clean_model": "aistudio/gemini-flash-latest",
"draft_model": "openai/gpt-sol-latest",
"soniox_api_key": "soniox-db-secret",
"use_hindsight": "0",
})
self.assertEqual(CFG.asr_provider, "soniox")
self.assertEqual(CFG.litellm_base_url, "https://api.opfo.xyz/v1")
self.assertEqual(CFG.endpoint, "https://api.opfo.xyz/v1/chat/completions")
self.assertEqual(CFG.transcriptions_endpoint, "https://api.opfo.xyz/v1/audio/transcriptions")
self.assertEqual(CFG.api_key(), "sk-sqlite-dynamic-key")
self.assertEqual(CFG.clean_model, "aistudio/gemini-flash-latest")
self.assertEqual(CFG.draft_model, "openai/gpt-sol-latest")
self.assertEqual(CFG.soniox_api_key, "soniox-db-secret")
self.assertFalse(CFG.use_hindsight)
# Restore default for subsequent tests
svc_db.save_settings({"asr_provider": "qwen3cam", "use_hindsight": "1"})
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")
with_intake = '<!-- intake: {"job_id": "abc", "asr_provider": "soniox", "asr_model": "stt-async-v5"} -->\n' + text
parsed_turns, meta = segment.parse(with_intake)
self.assertEqual(meta["asr_provider"], "soniox")
self.assertEqual(meta["asr_model"], "stt-async-v5")
self.assertEqual(meta["backend"], "soniox")
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, "收到,我来汇报。")
with self.assertRaises(providers.EmptyAudioError):
providers.transcript_from_soniox_response({"tokens": [], "text": ""}, model="stt-async-v5")
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_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_lexicon_crud_operations(self):
# Entity CRUD
lexicon.upsert_entity("测试生物", "公司", company="测试集团", role="子公司", related="李四")
ents = lexicon.list_entities(q="测试生物")
self.assertEqual(len(ents), 1)
self.assertEqual(ents[0]["company"], "测试集团")
lexicon.update_entity("测试生物", "测试生物科技", "公司", company="新集团", role="总部", related="李四,王五")
ents2 = lexicon.list_entities(q="测试生物科技")
self.assertEqual(len(ents2), 1)
self.assertEqual(ents2[0]["company"], "新集团")
self.assertTrue(lexicon.delete_entity("测试生物科技"))
self.assertEqual(len(lexicon.list_entities(q="测试生物科技")), 0)
# Alias & Correction CRUD
lexicon.learn_alias("老测试", "测试人", kind="人名", origin="manual", note="初始")
lexicon.update_alias("老测试", "测试人", "小测试", "测试人", kind="人名", confirmed=1, note="已改")
aliases = lexicon.list_aliases(q="小测试")
self.assertEqual(len(aliases), 1)
self.assertEqual(aliases[0]["note"], "已改")
self.assertTrue(lexicon.delete_alias("小测试", "测试人"))
lexicon.learn_correction("误识词", "正词", note="c1")
lexicon.update_correction("误识词", "正词", "新误识词", "正词", confirmed=1, note="c2")
cors = lexicon.list_corrections(q="新误识词")
self.assertEqual(len(cors), 1)
self.assertEqual(cors[0]["note"], "c2")
self.assertTrue(lexicon.delete_correction("新误识词", "正词"))
# Relation CRUD
lexicon.upsert_relation("张三", "任职于", "洛启生物", confidence=0.8, source="manual")
lexicon.update_relation("张三", "任职于", "洛启生物", "manual", "张三", "实控人", "洛启生物", confidence=0.95, active=1)
rels = lexicon.list_relations(q="张三")
self.assertTrue(any(r["predicate"] == "实控人" for r in rels))
self.assertTrue(lexicon.delete_relation("张三", "实控人", "洛启生物", "manual"))
def test_db_web_endpoints_multi_review_and_job_delete(self):
job1_id = "0123456789abcdef01234567"
job2_id = "fedcba987654321001234567"
t1 = svc_cfg.TRANSCRIPTS / f"{job1_id}.txt"
t2 = svc_cfg.TRANSCRIPTS / f"{job2_id}.txt"
t1.write_text("【speaker_0】录音一内容\n", encoding="utf-8")
t2.write_text("【speaker_0】录音二内容\n", encoding="utf-8")
svc_db.create_job(
job_id=job1_id,
original_name="meeting1.m4a",
stored_name=f"20261102-{job1_id}.m4a",
title="第一场尽调会",
context="验证多录音确认",
size=2048,
asr_provider="soniox",
asr_model="stt-async-v5",
)
svc_db.update_job(job1_id, "review", "待确认", transcript=str(t1))
svc_db.create_job(
job_id=job2_id,
original_name="meeting2.m4a",
stored_name=f"20261102-{job2_id}.m4a",
title="第二场投决会",
context="验证批量操作",
size=4096,
asr_provider="qwen3cam",
)
svc_db.update_job(job2_id, "review", "待确认", transcript=str(t2))
q1 = review.enqueue(str(t1), "第一场问题A?", {"kind": "draft_question", "candidates": ["选项A1", "选项A2"]})
q2 = review.enqueue(str(t1), "第一场问题B?", {"kind": "draft_question", "candidates": ["选项B1"]})
q3 = review.enqueue(str(t2), "第二场问题C?", {"kind": "draft_question", "candidates": ["选项C1"]})
os.environ["ASR_WEB_PASSWORD"] = "test-web-password"
os.environ.pop("ASR_WEB_ALLOW_ANON", None)
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:
import http.cookiejar
from asrpipe import webauth
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
login_body = urllib.parse.urlencode({"password": "test-web-password"}).encode()
opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/login", data=login_body, method="POST",
), timeout=5)
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 opener.open(req, timeout=5) as resp:
return resp.status, resp.read().decode("utf-8")
def post_form(path: str, data: dict[str, str]) -> tuple[int, str]:
payload = dict(data)
payload.setdefault("csrf", webauth.csrf_token())
encoded = urllib.parse.urlencode(payload).encode("utf-8")
req = urllib.request.Request(
f"http://127.0.0.1:{port}{path}",
data=encoded,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with opener.open(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_ids = [p["id"] for p in json.loads(body)["providers"]]
self.assertEqual(prov_ids, ["qwen3cam", "soniox", "gemini", "openai", "grok"])
# Multi-recording review page shows both recordings grouped
status, body = fetch("/review/")
self.assertEqual(status, 200)
self.assertIn("第一场尽调会", body)
self.assertIn("第二场投决会", body)
self.assertIn("第一场问题A?", body)
self.assertIn("第二场问题C?", body)
# Batch submit both questions for recording 1
status, body = post_form(f"/jobs/{job1_id}/review/", {
"question_ids": f"{q1},{q2}",
f"preset_{q1}": "选项A1",
f"custom_{q2}": "自定义回答B",
"batch_submit": "1",
})
self.assertEqual(status, 200)
self.assertEqual(len(svc_web.pending(svc_db.get_job(job1_id))), 0)
self.assertEqual(len(svc_web.pending(svc_db.get_job(job2_id))), 1)
# Edit job metadata
status, body = post_form(f"/jobs/{job1_id}/meta", {
"title": "第一场尽调会(已改名)",
"context": "更新后的背景",
"provider": "openai",
"model": "openai/gpt-4o-transcribe-diarize",
"next_action": "save",
})
self.assertEqual(status, 200)
self.assertIn("第一场尽调会(已改名)", body)
# Save settings via Web UI POST
status, body = post_form("/settings", {
"litellm_base_url": "http://192.168.50.49:4000/v1",
"clean_model": "aistudio/gemini-flash-lite-latest",
"keyword_model": "aistudio/gemini-flash-lite-latest",
"draft_model": "codex/gpt-sol-latest",
"asr_provider": "soniox",
"use_hindsight": "1",
})
self.assertEqual(status, 200)
self.assertEqual(CFG.asr_provider, "soniox")
# Delete job 2 and verify its pending review question is also cleaned up
status, _ = post_form(f"/jobs/{job2_id}/delete", {"delete_files": "1"})
self.assertEqual(status, 200)
self.assertIsNone(svc_db.get_job(job2_id))
self.assertFalse(t2.exists())
self.assertNotIn(q3, [r["id"] for r in review.pending(200)])
finally:
server.shutdown()
server.server_close()
if __name__ == "__main__":
unittest.main()