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
This commit is contained in:
btc-z
2026-05-01 01:56:32 -04:00
committed by GitHub
parent 989badd14f
commit 66eddaff0e
6 changed files with 306 additions and 32 deletions

View File

@@ -0,0 +1,116 @@
"""
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()

View File

@@ -194,7 +194,7 @@ class TranscribeVoiceCacheHitTests(_CacheIsolationMixin, unittest.TestCase):
"text": "缓存命中文本",
"language": "zh",
"create_time": 1700000000,
"model_size": mcp_server.DEFAULT_WHISPER_MODEL,
"model_size": mcp_server.LOCAL_WHISPER_MODEL,
})
with patch.object(mcp_server, "resolve_username", return_value="wxid_test") as mock_resolve, \
@@ -216,7 +216,7 @@ class TranscribeVoiceCacheHitTests(_CacheIsolationMixin, unittest.TestCase):
self._seed(key, {
"text": "历史条目",
"language": "zh",
"model_size": mcp_server.DEFAULT_WHISPER_MODEL,
"model_size": mcp_server.LOCAL_WHISPER_MODEL,
})
with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \
@@ -233,7 +233,7 @@ class TranscribeVoiceCacheHitTests(_CacheIsolationMixin, unittest.TestCase):
"text": "",
"language": "zh",
"create_time": 1700000000,
"model_size": mcp_server.DEFAULT_WHISPER_MODEL,
"model_size": mcp_server.LOCAL_WHISPER_MODEL,
})
with patch.object(mcp_server, "resolve_username", return_value="wxid_test"), \
@@ -244,7 +244,7 @@ class TranscribeVoiceCacheHitTests(_CacheIsolationMixin, unittest.TestCase):
self.assertIn("(zh)", result)
def test_model_mismatch_is_treated_as_miss(self):
# 缓存条目的 model_size 和当前 DEFAULT_WHISPER_MODEL 不一致时,
# 缓存条目的 model_size 和当前 LOCAL_WHISPER_MODEL 不一致时,
# 不应命中;进入 miss 路径(这里无 whisper 依赖,应落到"缺少依赖"分支)。
key = mcp_server._voice_transcription_cache_key("wxid_test", 10)
self._seed(key, {