Files
zWorkFlow/tests/test_openai_backend.py
btc-z 66eddaff0e feat: transcribe_voice 新增 OpenAI Whisper API 后端 (#66)
默认 local,零行为变化。opt-in 双因素:transcription_backend=openai
且 openai_api_key 都齐才生效;任一缺失静默回退 local + stderr 一行警告。
首次进入云路径会 stderr 警告"语音将上传至 OpenAI 服务器"。

新增 config.json 字段:
- transcription_backend: "local" (默认) | "openai"
- local_whisper_model: "base" (替换 mcp_server.py 里硬编码 DEFAULT_WHISPER_MODEL)
- openai_api_key: "" (默认空;openai 包为 optional,按需 pip install)

关键技术选择:
- _transcribe(wav, backend) 单一 if/else 分发,不引入插件/工厂层
  (Rule of Three —— 只有一个云后端时不值得抽象)
- 文件 > 25MB 在 OpenAI() 实例化之前提前拒绝,避免无谓上传
- 错误分类清晰: 缺 key / 缺 openai 包 / 401 / 429 / APIError 各自的提示
- PR #58 缓存 schema 自然扩展: 条目加 backend 字段,命中需 backend+model_size 都匹配
- 旧条目缺 backend 字段视为 "local",向前兼容 PR #58 已落盘的所有数据
- transcribe_chat.py 批量 CLI 与 MCP 工具共享同一份配置,保持一致

新增 2 个测试 (tests/test_openai_backend.py),只覆盖回归风险最高的两条:
- 文件 > 25MB 必须在 SDK 实例化前拒绝(隐私契约的防线)
- backend 不匹配的旧条目不命中(避免切后端时返回错后端结果)

其余路径要么琐碎(默认值读取)、要么坏掉时声音很大(SDK 错误、ImportError),
要么已被 PR #58 现有测试隐式覆盖(缺 backend 字段的旧条目),不再单独写测试。

顺手把 README 里 PR #53 漏掉的 voice 三件套(get_voice_messages /
decode_voice / transcribe_voice)补进 MCP 工具表,并新增"⚠️ 语音转录隐私"
章节说清数据流向、成本(约 \$0.006/分钟)、25MB 上限、回退行为。

Closes ylytdeng/wechat-decrypt#59
2026-05-01 13:56:32 +08:00

117 lines
4.9 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.

"""
issue #59: opt-in OpenAI Whisper API 后端的两条关键回归测试。
只测两件事:
1. 隐私契约: 文件 > 25MB 在调用 OpenAI SDK 之前就被拒绝(保证不会无意上传)
2. 缓存正确性: backend 不匹配的旧条目不会被命中(避免切后端时返回错后端结果)
其余路径要么琐碎默认值读取、要么坏掉时声音很大SDK 错误、ImportError
不再单独覆盖。
"""
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import mcp_server
class _CacheIsolationMixin:
"""与 test_voice_transcription_cache.py 同款隔离:避免污染 module-level 缓存状态。"""
def setUp(self):
self._saved_cache = mcp_server._voice_transcription_cache
self._saved_path = mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE
self._saved_warned = mcp_server._voice_transcription_save_warned
mcp_server._voice_transcription_cache = None
mcp_server._voice_transcription_save_warned = False
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = os.path.join(
self._tmp.name, "voice_transcriptions.json"
)
def tearDown(self):
mcp_server._voice_transcription_cache = self._saved_cache
mcp_server.VOICE_TRANSCRIPTION_CACHE_FILE = self._saved_path
mcp_server._voice_transcription_save_warned = self._saved_warned
class OpenAIBackendPrivacyTests(unittest.TestCase):
"""隐私契约:超限文件必须在 OpenAI SDK 实例化之前就被拒绝。
若有人把 size check 移到 OpenAI(api_key=...) 之后(即便仍在 upload 前),
本测试会失败 —— 这层防御边界值得守住。
"""
def test_oversize_audio_rejected_before_sdk_call(self):
# 写一个 26MB 临时 WAV (用稀疏写法快速生成)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
f.seek(26 * 1024 * 1024)
f.write(b"\0")
big_path = f.name
self.addCleanup(os.unlink, big_path)
# 注入一个假的 openai 模块,保证 import 成功OpenAI 构造函数若被调用即测试失败
fake_openai = MagicMock()
fake_openai.OpenAI = MagicMock(
side_effect=AssertionError("OpenAI() must not be instantiated for oversize files")
)
fake_openai.AuthenticationError = type("AuthenticationError", (Exception,), {})
fake_openai.RateLimitError = type("RateLimitError", (Exception,), {})
fake_openai.APIError = type("APIError", (Exception,), {})
with patch.dict(sys.modules, {"openai": fake_openai}):
with self.assertRaises(RuntimeError) as ctx:
mcp_server._transcribe_openai(big_path)
self.assertIn("25MB", str(ctx.exception))
fake_openai.OpenAI.assert_not_called()
class CacheBackendMatchTests(_CacheIsolationMixin, unittest.TestCase):
"""缓存正确性backend 不匹配 → 视为 miss避免切后端时返回错后端结果。"""
def test_cache_hit_requires_backend_match(self):
# 种入一条 openai 后端的缓存条目
key = mcp_server._voice_transcription_cache_key("wxid_test", 42)
cache = mcp_server._load_voice_transcription_cache()
cache[key] = {
"text": "openai-result",
"language": "zh",
"create_time": 1700000000,
"backend": "openai",
"model_size": "whisper-1",
}
mcp_server._save_voice_transcription_cache()
# 当前后端是 local应当 miss → 走转录流程而非返回 "openai-result"
with patch.object(mcp_server, "TRANSCRIPTION_BACKEND", "local"), \
patch.object(mcp_server, "OPENAI_API_KEY", ""), \
patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \
patch.object(mcp_server, "_fetch_voice_row",
return_value=(b"\x02fake-silk-blob", 1700000001)), \
patch.object(mcp_server, "_silk_to_wav",
return_value=("/tmp/fake.wav", 24000 * 2)), \
patch.object(mcp_server, "_transcribe_local",
return_value={"text": "local-result", "language": "zh"}), \
patch.dict(sys.modules, {"whisper": MagicMock(), "pysilk": MagicMock()}):
result = mcp_server.transcribe_voice("test_contact", 42)
# 没返回旧 openai 缓存,而是走了 local 转录流程
self.assertNotIn("openai-result", result)
self.assertIn("local-result", result)
# 落盘的新条目应记录当前后端
mcp_server._voice_transcription_cache = None
reloaded = mcp_server._load_voice_transcription_cache()
self.assertEqual(reloaded[key]["backend"], "local")
self.assertEqual(reloaded[key]["text"], "local-result")
if __name__ == "__main__":
unittest.main()