mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
433 lines
17 KiB
Python
433 lines
17 KiB
Python
"""
|
|
验证语音输入输出提供方选择、载荷结构及兼容回退。
|
|
"""
|
|
|
|
from base64 import b64encode
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, patch
|
|
|
|
from app.agent.llm import capability as capability_module
|
|
from app.agent.llm.capability import (
|
|
AgentCapabilityManager,
|
|
MiMoAudioProvider,
|
|
MiniMaxAudioProvider,
|
|
OpenAIAudioProvider,
|
|
OpenAIChatAudioProvider,
|
|
)
|
|
from app.runtime.config import settings
|
|
from app.schemas.notification import ChannelCapability, ChannelCapabilityManager
|
|
from app.schemas.types import NotificationChannel
|
|
|
|
|
|
def test_registered_audio_providers_contains_builtin_providers() -> None:
|
|
"""
|
|
音频能力注册表包含全部内置提供方。
|
|
"""
|
|
assert "openai" in AgentCapabilityManager.get_registered_audio_providers()
|
|
assert "openai_chat_audio" in AgentCapabilityManager.get_registered_audio_providers()
|
|
assert "mimo" in AgentCapabilityManager.get_registered_audio_providers()
|
|
assert "minimax" in AgentCapabilityManager.get_registered_audio_providers()
|
|
|
|
|
|
def test_get_audio_provider_uses_separate_input_and_output_settings() -> None:
|
|
"""
|
|
语音输入与输出分别读取各自的提供方配置。
|
|
"""
|
|
with (
|
|
patch.object(settings, "AUDIO_INPUT_PROVIDER", "openai"),
|
|
patch.object(settings, "AUDIO_OUTPUT_PROVIDER", "mimo"),
|
|
):
|
|
assert isinstance(AgentCapabilityManager.get_audio_provider("input"), OpenAIAudioProvider)
|
|
assert isinstance(AgentCapabilityManager.get_audio_provider("output"), MiMoAudioProvider)
|
|
|
|
|
|
def test_chat_audio_provider_keeps_arbitrary_compatible_models() -> None:
|
|
"""
|
|
兼容音频聊天接口保留用户配置的自定义模型名。
|
|
"""
|
|
provider = OpenAIChatAudioProvider()
|
|
|
|
with (
|
|
patch.object(settings, "AUDIO_INPUT_MODEL", "vendor-omni-audio"),
|
|
patch.object(settings, "AUDIO_OUTPUT_MODEL", "vendor-tts-audio"),
|
|
):
|
|
assert provider._normalize_stt_model() == "vendor-omni-audio"
|
|
assert provider._normalize_tts_model() == "vendor-tts-audio"
|
|
|
|
|
|
def test_chat_audio_provider_uses_openai_audio_payload_shape() -> None:
|
|
"""
|
|
转写请求使用 OpenAI 兼容的输入音频结构。
|
|
"""
|
|
provider = OpenAIChatAudioProvider()
|
|
fake_client = Mock()
|
|
fake_client.chat.completions.create.return_value = SimpleNamespace(
|
|
choices=[SimpleNamespace(message=SimpleNamespace(content="你好"))]
|
|
)
|
|
|
|
with (
|
|
patch.object(provider, "_build_client", return_value=fake_client),
|
|
patch.object(settings, "AUDIO_INPUT_MODEL", "gpt-4o-audio-preview"),
|
|
patch.object(settings, "AUDIO_INPUT_LANGUAGE", "zh"),
|
|
patch.object(settings, "AUDIO_INPUT_API_KEY", "sk-test"),
|
|
patch.object(settings, "AUDIO_INPUT_BASE_URL", "https://example.com/v1"),
|
|
):
|
|
result = provider.transcribe_audio(b"audio-bytes", filename="input.wav")
|
|
|
|
assert result == "你好"
|
|
request = fake_client.chat.completions.create.call_args.kwargs
|
|
content = request["messages"][0]["content"]
|
|
assert content[0]["input_audio"] == {"data": b64encode(b"audio-bytes").decode("utf-8"), "format": "wav"}
|
|
|
|
|
|
def test_chat_audio_provider_requests_audio_modality_for_tts() -> None:
|
|
"""
|
|
语音合成请求显式声明音频模态及输出格式。
|
|
"""
|
|
provider = OpenAIChatAudioProvider()
|
|
fake_client = Mock()
|
|
audio_data = b64encode(b"wav-bytes").decode("utf-8")
|
|
fake_client.chat.completions.create.return_value = SimpleNamespace(
|
|
choices=[SimpleNamespace(message=SimpleNamespace(audio={"data": audio_data}))]
|
|
)
|
|
|
|
with (
|
|
TemporaryDirectory() as temp_dir,
|
|
patch.object(provider, "_build_client", return_value=fake_client),
|
|
patch.object(
|
|
capability_module,
|
|
"get_runtime_setting",
|
|
side_effect=lambda key, default=None: getattr(
|
|
SimpleNamespace(
|
|
TEMP_PATH=Path(temp_dir),
|
|
AUDIO_OUTPUT_MODEL="gpt-4o-audio-preview",
|
|
AUDIO_OUTPUT_VOICE="alloy",
|
|
AUDIO_OUTPUT_API_KEY="sk-test",
|
|
AUDIO_OUTPUT_BASE_URL="https://example.com/v1",
|
|
),
|
|
key,
|
|
default,
|
|
),
|
|
),
|
|
patch.object(provider, "_convert_wav_to_opus", return_value=None),
|
|
):
|
|
output_path = provider.synthesize_speech("你好")
|
|
|
|
assert output_path is not None
|
|
request = fake_client.chat.completions.create.call_args.kwargs
|
|
assert request["messages"][0]["role"] == "user"
|
|
assert request["modalities"] == ["text", "audio"]
|
|
assert request["audio"] == {"format": "wav", "voice": "alloy"}
|
|
|
|
|
|
def test_audio_input_and_output_switches_are_independent() -> None:
|
|
"""
|
|
语音输入与输出开关互不影响。
|
|
"""
|
|
provider = Mock()
|
|
provider.is_available_for_audio_input.return_value = True
|
|
provider.is_available_for_audio_output.return_value = True
|
|
|
|
with (
|
|
patch.object(settings, "LLM_SUPPORT_AUDIO_INPUT", True),
|
|
patch.object(settings, "LLM_SUPPORT_AUDIO_OUTPUT", False),
|
|
patch.object(AgentCapabilityManager, "get_audio_provider", return_value=provider),
|
|
):
|
|
assert AgentCapabilityManager.is_audio_input_available()
|
|
assert not AgentCapabilityManager.is_audio_output_available()
|
|
|
|
with (
|
|
patch.object(settings, "LLM_SUPPORT_AUDIO_INPUT", False),
|
|
patch.object(settings, "LLM_SUPPORT_AUDIO_OUTPUT", True),
|
|
patch.object(AgentCapabilityManager, "get_audio_provider", return_value=provider),
|
|
):
|
|
assert not AgentCapabilityManager.is_audio_input_available()
|
|
assert AgentCapabilityManager.is_audio_output_available()
|
|
|
|
|
|
def test_transcribe_audio_routes_to_input_provider() -> None:
|
|
"""
|
|
音频转写只路由到配置的输入提供方。
|
|
"""
|
|
provider = Mock()
|
|
provider.name = "mock_audio"
|
|
provider.is_available_for_audio_input.return_value = True
|
|
provider.transcribe_audio.return_value = "你好"
|
|
|
|
with (
|
|
patch.object(settings, "LLM_SUPPORT_AUDIO_INPUT", True),
|
|
patch.object(AgentCapabilityManager, "get_audio_provider", return_value=provider),
|
|
patch.object(capability_module.logger, "info") as log_info,
|
|
):
|
|
result = AgentCapabilityManager.transcribe_audio(b"audio")
|
|
|
|
assert result == "你好"
|
|
provider.transcribe_audio.assert_called_once()
|
|
assert any(("语音转文字开始" in call.args[0] for call in log_info.call_args_list))
|
|
assert any(("语音转文字完成" in call.args[0] for call in log_info.call_args_list))
|
|
|
|
|
|
def test_synthesize_speech_routes_to_output_provider() -> None:
|
|
"""
|
|
语音合成只路由到配置的输出提供方。
|
|
"""
|
|
provider = Mock()
|
|
provider.name = "mock_audio"
|
|
provider.is_available_for_audio_output.return_value = True
|
|
provider.synthesize_speech.return_value = Path("/tmp/reply.opus")
|
|
|
|
with (
|
|
patch.object(settings, "LLM_SUPPORT_AUDIO_OUTPUT", True),
|
|
patch.object(AgentCapabilityManager, "get_audio_provider", return_value=provider),
|
|
patch.object(capability_module.logger, "info") as log_info,
|
|
):
|
|
result = AgentCapabilityManager.synthesize_speech("你好")
|
|
|
|
assert result == Path("/tmp/reply.opus")
|
|
provider.synthesize_speech.assert_called_once_with(text="你好")
|
|
assert any(("文字转语音开始" in call.args[0] for call in log_info.call_args_list))
|
|
assert any(("文字转语音完成" in call.args[0] for call in log_info.call_args_list))
|
|
|
|
|
|
def test_native_voice_reply_supports_channels_with_audio_output() -> None:
|
|
"""
|
|
校验 Agent 语音回复渠道支持判断覆盖常见渠道写法。
|
|
"""
|
|
assert AgentCapabilityManager.supports_native_voice_reply("telegram", None)
|
|
assert AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.Telegram.value, None)
|
|
assert AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.Feishu.value, None)
|
|
assert AgentCapabilityManager.supports_native_voice_reply("Feishu", None)
|
|
assert AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.WebAgent.value, None)
|
|
assert not AgentCapabilityManager.supports_native_voice_reply("Slack", None)
|
|
|
|
|
|
def test_native_voice_reply_respects_wechat_mode() -> None:
|
|
"""
|
|
校验企业微信只有自建应用模式允许 Agent 语音回复。
|
|
"""
|
|
configs = [
|
|
SimpleNamespace(name="wechat-app", config={"WECHAT_MODE": "app"}),
|
|
SimpleNamespace(name="wechat-bot", config={"WECHAT_MODE": "bot"}),
|
|
]
|
|
|
|
with patch(
|
|
"app.agent.llm.capability.get_notification_configs",
|
|
return_value=configs,
|
|
):
|
|
assert AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.Wechat.value, "wechat-app")
|
|
assert not AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.Wechat.value, "wechat-bot")
|
|
assert not AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.Wechat.value, "missing")
|
|
|
|
|
|
def test_channel_capability_marks_voice_output_channels() -> None:
|
|
"""
|
|
校验消息渠道能力显式声明原生语音输出支持。
|
|
"""
|
|
for channel in (
|
|
NotificationChannel.Telegram,
|
|
NotificationChannel.Feishu,
|
|
NotificationChannel.Wechat,
|
|
NotificationChannel.WebAgent,
|
|
):
|
|
assert ChannelCapabilityManager.supports_capability(channel, ChannelCapability.AUDIO_OUTPUT)
|
|
assert not ChannelCapabilityManager.supports_capability(NotificationChannel.Slack, ChannelCapability.AUDIO_OUTPUT)
|
|
|
|
|
|
def test_mimo_tts_uses_chat_completions_audio_payload() -> None:
|
|
"""
|
|
MiMo 语音合成使用聊天补全接口的音频载荷。
|
|
"""
|
|
provider = MiMoAudioProvider()
|
|
fake_client = Mock()
|
|
audio_data = b64encode(b"wav-bytes").decode("utf-8")
|
|
fake_client.chat.completions.create.return_value = SimpleNamespace(
|
|
choices=[SimpleNamespace(message=SimpleNamespace(audio={"data": audio_data}))]
|
|
)
|
|
|
|
with (
|
|
TemporaryDirectory() as temp_dir,
|
|
patch.object(provider, "_build_client", return_value=fake_client),
|
|
patch.object(
|
|
capability_module,
|
|
"get_runtime_setting",
|
|
side_effect=lambda key, default=None: getattr(
|
|
SimpleNamespace(
|
|
TEMP_PATH=Path(temp_dir),
|
|
AUDIO_OUTPUT_MODEL="mimo-v2.5-tts",
|
|
AUDIO_OUTPUT_VOICE="冰糖",
|
|
AUDIO_OUTPUT_API_KEY="sk-test",
|
|
AUDIO_OUTPUT_BASE_URL="https://api.xiaomimimo.com/v1",
|
|
),
|
|
key,
|
|
default,
|
|
),
|
|
),
|
|
patch.object(provider, "_convert_wav_to_opus", return_value=None),
|
|
):
|
|
output_path = provider.synthesize_speech("你好")
|
|
output_bytes = output_path.read_bytes() if output_path else None
|
|
|
|
assert output_path is not None
|
|
assert output_bytes == b"wav-bytes"
|
|
fake_client.chat.completions.create.assert_called_once()
|
|
request = fake_client.chat.completions.create.call_args.kwargs
|
|
assert request["model"] == "mimo-v2.5-tts"
|
|
assert request["messages"][0]["role"] == "assistant"
|
|
assert request["messages"][0]["content"] == "你好"
|
|
assert request["audio"] == {"format": "wav", "voice": "冰糖"}
|
|
|
|
|
|
def test_mimo_tts_rejects_voice_design_and_clone_models() -> None:
|
|
"""
|
|
MiMo 语音合成拒绝声音设计和克隆专用模型。
|
|
"""
|
|
provider = MiMoAudioProvider()
|
|
|
|
with (
|
|
patch.object(settings, "AUDIO_OUTPUT_MODEL", "mimo-v2.5-tts-voiceclone"),
|
|
patch.object(provider, "_build_client") as build_client,
|
|
):
|
|
result = provider.synthesize_speech("你好")
|
|
|
|
assert result is None
|
|
build_client.assert_not_called()
|
|
|
|
|
|
def test_mimo_stt_rejects_non_audio_mimo_models_by_falling_back() -> None:
|
|
"""
|
|
MiMo 转写配置为非音频模型时回退到受支持模型。
|
|
"""
|
|
provider = MiMoAudioProvider()
|
|
|
|
with patch.object(settings, "AUDIO_INPUT_MODEL", "mimo-v2.5-pro"):
|
|
assert provider._normalize_stt_model() == "mimo-v2.5"
|
|
|
|
|
|
def test_mimo_stt_uses_base64_audio_input() -> None:
|
|
"""
|
|
MiMo 转写以 Base64 内容提交输入音频。
|
|
"""
|
|
provider = MiMoAudioProvider()
|
|
fake_client = Mock()
|
|
fake_client.chat.completions.create.return_value = SimpleNamespace(
|
|
choices=[SimpleNamespace(message=SimpleNamespace(content="你好"))]
|
|
)
|
|
|
|
with (
|
|
patch.object(provider, "_build_client", return_value=fake_client),
|
|
patch.object(settings, "AUDIO_INPUT_MODEL", "mimo-v2.5"),
|
|
patch.object(settings, "AUDIO_INPUT_LANGUAGE", "zh"),
|
|
patch.object(settings, "AUDIO_INPUT_API_KEY", "sk-test"),
|
|
patch.object(settings, "AUDIO_INPUT_BASE_URL", "https://api.xiaomimimo.com/v1"),
|
|
):
|
|
result = provider.transcribe_audio(b"audio-bytes", filename="input.wav")
|
|
|
|
assert result == "你好"
|
|
request = fake_client.chat.completions.create.call_args.kwargs
|
|
content = request["messages"][0]["content"]
|
|
assert request["model"] == "mimo-v2.5"
|
|
assert content[0]["input_audio"]["data"].startswith("data:audio/wav;base64,")
|
|
assert "只输出转写结果" in content[1]["text"]
|
|
|
|
|
|
def test_mimo_stt_transcodes_amr_before_payload() -> None:
|
|
"""
|
|
校验 MiMo 音频输入会先将企业微信 AMR 转为受支持的 WAV。
|
|
"""
|
|
provider = MiMoAudioProvider()
|
|
fake_client = Mock()
|
|
fake_client.chat.completions.create.return_value = SimpleNamespace(
|
|
choices=[SimpleNamespace(message=SimpleNamespace(content="你好"))]
|
|
)
|
|
|
|
with (
|
|
patch.object(provider, "_build_client", return_value=fake_client),
|
|
patch.object(
|
|
provider,
|
|
"_convert_audio_for_transcription",
|
|
return_value=(b"wav-bytes", "input.wav"),
|
|
) as convert_audio,
|
|
patch.object(settings, "AUDIO_INPUT_MODEL", "mimo-v2.5"),
|
|
patch.object(settings, "AUDIO_INPUT_LANGUAGE", "zh"),
|
|
patch.object(settings, "AUDIO_INPUT_API_KEY", "sk-test"),
|
|
patch.object(settings, "AUDIO_INPUT_BASE_URL", "https://api.xiaomimimo.com/v1"),
|
|
):
|
|
result = provider.transcribe_audio(b"amr-bytes", filename="input.amr")
|
|
|
|
assert result == "你好"
|
|
convert_audio.assert_called_once_with(content=b"amr-bytes", filename="input.amr")
|
|
request = fake_client.chat.completions.create.call_args.kwargs
|
|
content = request["messages"][0]["content"]
|
|
assert content[0]["input_audio"]["data"] == f"data:audio/wav;base64,{b64encode(b'wav-bytes').decode('utf-8')}"
|
|
|
|
|
|
def test_minimax_stt_normalizes_openai_default_model() -> None:
|
|
"""
|
|
校验 MiniMax 音频输入会把 OpenAI 默认模型兜底为 MiniMax 模型。
|
|
"""
|
|
provider = MiniMaxAudioProvider()
|
|
|
|
with patch.object(settings, "AUDIO_INPUT_MODEL", "gpt-4o-mini-transcribe"):
|
|
assert provider._normalize_stt_model() == "MiniMax-M2.7"
|
|
|
|
|
|
def test_minimax_tts_uses_t2a_http_payload() -> None:
|
|
"""
|
|
校验 MiniMax 音频输出会调用官方 T2A HTTP 接口并写入音频文件。
|
|
"""
|
|
provider = MiniMaxAudioProvider()
|
|
fake_response = SimpleNamespace(
|
|
status_code=200,
|
|
json=Mock(
|
|
return_value={
|
|
"data": {"audio": b"opus-bytes".hex(), "status": 2},
|
|
"base_resp": {"status_code": 0, "status_msg": "success"},
|
|
}
|
|
),
|
|
)
|
|
request_utils = Mock()
|
|
request_utils.post_res.return_value = fake_response
|
|
|
|
with (
|
|
TemporaryDirectory() as temp_dir,
|
|
patch.object(capability_module, "RequestUtils", return_value=request_utils) as request_utils_cls,
|
|
patch.object(
|
|
capability_module,
|
|
"get_runtime_setting",
|
|
side_effect=lambda key, default=None: getattr(
|
|
SimpleNamespace(
|
|
TEMP_PATH=Path(temp_dir),
|
|
PROXY={},
|
|
AUDIO_OUTPUT_MODEL="gpt-4o-mini-tts",
|
|
AUDIO_OUTPUT_VOICE="alloy",
|
|
AUDIO_OUTPUT_API_KEY="sk-test",
|
|
AUDIO_OUTPUT_BASE_URL="https://api.minimaxi.com/anthropic/v1",
|
|
),
|
|
key,
|
|
default,
|
|
),
|
|
),
|
|
):
|
|
output_path = provider.synthesize_speech("你好")
|
|
output_bytes = output_path.read_bytes() if output_path else None
|
|
|
|
assert output_path is not None
|
|
assert output_bytes == b"opus-bytes"
|
|
request_utils_cls.assert_called_once()
|
|
request = request_utils.post_res.call_args.kwargs
|
|
assert request["url"] == "https://api.minimaxi.com/v1/t2a_v2"
|
|
assert request["json"]["model"] == "speech-2.8-turbo"
|
|
assert request["json"]["voice_setting"]["voice_id"] == "Chinese (Mandarin)_Lyrical_Voice"
|
|
assert request["json"]["audio_setting"]["format"] == "opus"
|
|
|
|
|
|
def test_minimax_tts_accepts_base64_audio_payload() -> None:
|
|
"""
|
|
校验 MiniMax 音频解析兼容部分代理返回的 base64 音频数据。
|
|
"""
|
|
provider = MiniMaxAudioProvider()
|
|
|
|
assert provider._decode_audio_payload(b64encode(b"opus-bytes").decode("utf-8")) == b"opus-bytes"
|