Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions custom_components/openclaw/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions custom_components/openclaw/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
from typing import Any


Expand Down Expand Up @@ -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"(?<!\*)\*\*(?!\s)(.+?)(?<!\s)\*\*(?!\*)", re.DOTALL), r"\1"),
# __bold__
(re.compile(r"(?<![\w_])__(?![\s_])(.+?)(?<![\s_])__(?![\w_])", re.DOTALL), r"\1"),
# *italic* / _italic_, guarded so maths such as 3*4 is left intact
(re.compile(r"(?<![\w*])\*(?![\s*])(.+?)(?<![\s*])\*(?![\w*])", re.DOTALL), r"\1"),
(re.compile(r"(?<![\w_])_(?![\s_])(.+?)(?<![\s_])_(?![\w_])", re.DOTALL), r"\1"),
# ~~strikethrough~~
(re.compile(r"~~(?!\s)(.+?)(?<!\s)~~", re.DOTALL), r"\1"),
)

# A leftover emphasis run (markup without a closing pair) would still be spoken
# as "asterisk asterisk". Single * and _ are deliberately left alone.
_MARKDOWN_LEFTOVER_RE = re.compile(r"\*{2,}")


def strip_markdown_for_speech(text: str) -> 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()
47 changes: 47 additions & 0 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()