From 105d9c3feb5347551227d4be901d72d22fb4bfa6 Mon Sep 17 00:00:00 2001 From: qian2023qian <3472492003@qq.com> Date: Fri, 9 Oct 2026 23:08:20 +0800 Subject: [PATCH] Strip markdown from the speech handed to Assist/TTS Assist passes `intent_response.speech` straight to text-to-speech, so any markdown left in a reply is read out literally ("asterisk asterisk bold text asterisk asterisk"). OpenClaw currently sets the speech to the raw response text. Add `strip_markdown_for_speech()` to the dependency-free helpers and use it for the speech only: headings, list markers, quotes, code fences and inline code, emphasis, strikethrough and link syntax are removed, while words, numbers, punctuation and emoji are preserved. The chat card and the `openclaw_message_received` event keep receiving the raw response. Regression tests cover CJK bold text, a full reported response, headings and lists, links/inline code, arithmetic such as `3*4=12`, plain text and an unbalanced emphasis run. Refs #6 (the broader voice-mode request). This is the integration-side safeguard that stays correct even when a voice-optimised agent is in use. --- custom_components/openclaw/conversation.py | 11 +++- custom_components/openclaw/helpers.py | 62 ++++++++++++++++++++++ tests/test_helpers.py | 47 ++++++++++++++++ 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 701134c..3683f6d 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -40,7 +40,11 @@ ) from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context -from .helpers import extract_text_recursive, scope_agent_session_id +from .helpers import ( + extract_text_recursive, + scope_agent_session_id, + strip_markdown_for_speech, +) _LOGGER = logging.getLogger(__name__) @@ -223,7 +227,10 @@ async def async_process( coordinator.update_last_activity() intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(full_response) + # Assist sends this straight to TTS, so markdown markup must not survive + # here (otherwise "**bold**" is spoken as "asterisk asterisk bold ..."). + # The event above and the chat card keep the original response text. + intent_response.async_set_speech(strip_markdown_for_speech(full_response)) return conversation.ConversationResult( response=intent_response, diff --git a/custom_components/openclaw/helpers.py b/custom_components/openclaw/helpers.py index 0edd733..39e4741 100644 --- a/custom_components/openclaw/helpers.py +++ b/custom_components/openclaw/helpers.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import Any @@ -79,3 +80,64 @@ def extract_text_recursive(value: Any, depth: int = 0) -> str | None: return extracted return None + + +# --- Speech (TTS) text normalisation ----------------------------------------- +# +# Assist passes ``intent_response.speech`` straight to text-to-speech, so any +# markdown left in an assistant reply is read out literally +# ("asterisk asterisk bold text asterisk asterisk"). The helpers below remove +# the markup but keep the words, numbers and emoji. The chat card and the +# ``openclaw_message_received`` event keep receiving the raw response. + +_MARKDOWN_LINE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + # ``` / ~~~ code fence lines + (re.compile(r"^[ \t]{0,3}(?:```|~~~)[^\n]*$", re.MULTILINE), ""), + # # headings + (re.compile(r"^[ \t]{0,3}#{1,6}[ \t]+", re.MULTILINE), ""), + # > block quotes + (re.compile(r"^[ \t]{0,3}>[ \t]?", re.MULTILINE), ""), + # bullets (-, *, +) and numbered lists (1. / 1)) + (re.compile(r"^[ \t]{0,3}(?:[-*+]|\d{1,3}[.)])[ \t]+", re.MULTILINE), ""), +) + +_MARKDOWN_INLINE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + # [text](url) and ![alt](url) -> keep the visible text + (re.compile(r"!?\[([^\[\]]*)\]\([^()]*\)"), r"\1"), + # `code` / ``code`` -> keep the content + (re.compile(r"`{1,2}([^`]+)`{1,2}"), r"\1"), + # **bold** (may span line breaks in streamed replies) + (re.compile(r"(? str: + """Return ``text`` with markdown markup removed, ready for text-to-speech. + + Only formatting is dropped: words, punctuation, numbers and emoji survive. + """ + if not text: + return text + + cleaned = text + for pattern, replacement in _MARKDOWN_LINE_PATTERNS: + cleaned = pattern.sub(replacement, cleaned) + for pattern, replacement in _MARKDOWN_INLINE_PATTERNS: + cleaned = pattern.sub(replacement, cleaned) + cleaned = _MARKDOWN_LEFTOVER_RE.sub("", cleaned) + + # Removing markup can leave trailing spaces and empty lines behind. + cleaned = re.sub(r"[ \t]+\n", "\n", cleaned) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned.strip() or text.strip() diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f012724..c9fec35 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -71,5 +71,52 @@ def test_does_not_set_an_ssl_override_for_plain_http(self) -> None: self.assertIsNone(helpers.ssl_request_parameter(False, False)) +class StripMarkdownForSpeechTests(unittest.TestCase): + """Protect the speech text that Assist hands to text-to-speech.""" + + def test_removes_bold_markup_around_cjk_text(self) -> None: + self.assertEqual( + helpers.strip_markdown_for_speech("**卧室三**:23.3℃ / 湿度 61%"), + "卧室三:23.3℃ / 湿度 61%", + ) + + def test_removes_a_full_reported_response(self) -> None: + response = ( + "🔹**有完整数据的区域**-**车库**:26.7℃ / 湿度 71%" + " 两点提醒:1. 阳台湿度报 100%,建议检查探头。" + ) + cleaned = helpers.strip_markdown_for_speech(response) + + self.assertNotIn("**", cleaned) + self.assertIn("车库", cleaned) + self.assertIn("🔹", cleaned) + self.assertIn("26.7℃", cleaned) + + def test_removes_headings_bullets_and_quotes_at_line_start(self) -> None: + cleaned = helpers.strip_markdown_for_speech( + "# 报告\n- 温度 23℃\n* 湿度 61%\n1. 正常\n> 备注" + ) + self.assertEqual(cleaned, "报告\n温度 23℃\n湿度 61%\n正常\n备注") + + def test_keeps_link_text_and_inline_code_content(self) -> None: + self.assertEqual( + helpers.strip_markdown_for_speech("详见 [说明书](https://example.com) 的 `open` 参数"), + "详见 说明书 的 open 参数", + ) + + def test_leaves_arithmetic_untouched(self) -> None: + self.assertEqual(helpers.strip_markdown_for_speech("3*4=12"), "3*4=12") + + def test_leaves_plain_text_untouched(self) -> None: + plain = "车库 26.7 度,湿度 71%。" + self.assertEqual(helpers.strip_markdown_for_speech(plain), plain) + + def test_handles_empty_input(self) -> None: + self.assertEqual(helpers.strip_markdown_for_speech(""), "") + + def test_removes_unbalanced_emphasis_runs(self) -> None: + self.assertEqual(helpers.strip_markdown_for_speech("**未闭合的加粗"), "未闭合的加粗") + + if __name__ == "__main__": unittest.main()