From dfbb8083c741fc3037aaf23c30753a5e59dcbbbd Mon Sep 17 00:00:00 2001 From: c020627 <1326713348@qq.com> Date: Mon, 28 Sep 2026 12:07:00 +0800 Subject: [PATCH] fix(linkai_voice): return an ERROR reply on ASR/TTS failure instead of None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LinkAI was the only voice provider that returned None on a failed transcription or synthesis (voice/linkai/linkai_voice.py, 4 sites). Every sibling (ali, openai, mimo) returns Reply(ReplyType.ERROR, ...), so the user hears/sees a failure message. A None bubbles up through bridge.fetch_voice_to_text with no Reply at all and the turn goes silent (or the caller dereferences a None reply). Replace the four `return None` with ReplyType.ERROR replies, reusing the same Chinese strings the other providers use ("抱歉,语音识别失败" / "抱歉,语音合成失败"). Tests: the four failure exits (ASR/TTS, HTTP error / exception) now return an ERROR reply instead of None. --- tests/test_linkai_voice_error_reply.py | 79 ++++++++++++++++++++++++++ voice/linkai/linkai_voice.py | 8 +-- 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 tests/test_linkai_voice_error_reply.py diff --git a/tests/test_linkai_voice_error_reply.py b/tests/test_linkai_voice_error_reply.py new file mode 100644 index 00000000..8100fbdb --- /dev/null +++ b/tests/test_linkai_voice_error_reply.py @@ -0,0 +1,79 @@ +# encoding:utf-8 +""" +Regression tests for ``voice/linkai/linkai_voice.py``. + +When LinkAI ASR/TTS fails, the old code returned ``None``. Every sibling +provider (ali, openai, mimo) returns a ``Reply(ReplyType.ERROR, ...)`` +instead, so the user gets a failure message; LinkAI's ``None`` means the +bridge returns no Reply at all (``bridge/bridge.py::fetch_voice_to_text``) +and the turn goes silent. These tests assert the four failure exits now +produce an ERROR reply. +""" +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from bridge.reply import ReplyType +from voice.linkai.linkai_voice import LinkAIVoice + + +class TestLinkAIVoiceErrorReply(unittest.TestCase): + def _voice(self): + return LinkAIVoice() + + def _audio(self): + p = Path(__file__).with_suffix(".mp3") + p.write_bytes(b"fake-audio-bytes") + return p + + def test_voiceToText_http_error_returns_error_reply(self): + v = self._voice() + resp = MagicMock() + resp.status_code = 500 + resp.json.return_value = {"message": "boom"} + audio = self._audio() + with patch("voice.linkai.linkai_voice.requests.post", return_value=resp): + reply = v.voiceToText(str(audio)) + audio.unlink() + self.assertIsNotNone(reply) + self.assertEqual(reply.type, ReplyType.ERROR) + self.assertIn("识别", reply.content) + + def test_voiceToText_exception_returns_error_reply(self): + v = self._voice() + audio = self._audio() + with patch( + "voice.linkai.linkai_voice.requests.post", side_effect=RuntimeError("net down") + ): + reply = v.voiceToText(str(audio)) + audio.unlink() + self.assertIsNotNone(reply) + self.assertEqual(reply.type, ReplyType.ERROR) + + def test_textToVoice_http_error_returns_error_reply(self): + v = self._voice() + resp = MagicMock() + resp.status_code = 500 + resp.json.return_value = {"message": "boom"} + with patch("voice.linkai.linkai_voice.requests.post", return_value=resp): + reply = v.textToVoice("hello") + self.assertIsNotNone(reply) + self.assertEqual(reply.type, ReplyType.ERROR) + self.assertIn("合成", reply.content) + + def test_textToVoice_exception_returns_error_reply(self): + v = self._voice() + with patch( + "voice.linkai.linkai_voice.requests.post", side_effect=RuntimeError("net down") + ): + reply = v.textToVoice("hello") + self.assertIsNotNone(reply) + self.assertEqual(reply.type, ReplyType.ERROR) + + +if __name__ == "__main__": + unittest.main() diff --git a/voice/linkai/linkai_voice.py b/voice/linkai/linkai_voice.py index 7bd535ba..0786b7dd 100644 --- a/voice/linkai/linkai_voice.py +++ b/voice/linkai/linkai_voice.py @@ -56,13 +56,13 @@ class LinkAIVoice(Voice): except Exception: pass logger.error(f"[LinkVoice] voiceToText error, status_code={res.status_code}, msg={msg}") - return None + return Reply(ReplyType.ERROR, "抱歉,语音识别失败") text = res.json().get("text") logger.info(f"[LinkVoice] voiceToText success, text={text}, file name={voice_file}") return Reply(ReplyType.TEXT, text) except Exception as e: logger.error(e) - return None + return Reply(ReplyType.ERROR, "抱歉,语音识别失败") def textToVoice(self, text): try: @@ -88,7 +88,7 @@ class LinkAIVoice(Voice): except Exception: pass logger.error(f"[LinkVoice] textToVoice error, status_code={res.status_code}, msg={msg}") - return None + return Reply(ReplyType.ERROR, "抱歉,语音合成失败") tmp_file_name = TmpDir().path() + datetime.datetime.now().strftime('%Y%m%d%H%M%S') + str(random.randint(0, 1000)) + ".mp3" with open(tmp_file_name, 'wb') as f: f.write(res.content) @@ -96,4 +96,4 @@ class LinkAIVoice(Voice): return Reply(ReplyType.VOICE, tmp_file_name) except Exception as e: logger.error(e) - return None + return Reply(ReplyType.ERROR, "抱歉,语音合成失败")