3ee67cb4d6
- Refactor `run_local_command` to manage subprocesses more effectively, ensuring child processes are terminated on timeout. - Introduce `_terminate` function to handle process group termination and capture output. - Implement `_command_output` to format command results with a character limit. - Add local intent recognition in `intents.py` to handle commands like "stop", "go to sleep", and "come here" without server interaction. - Normalize user input to match local intents while stripping filler words. - Update tests to cover new local intent functionality and ensure proper command handling. - Enhance speech processing to handle abbreviations and improve spoken output clarity.
107 lines
4.1 KiB
Python
107 lines
4.1 KiB
Python
"""Sanitizing chat-formatted replies into speakable prose. Pure string logic
|
|
— no audio hardware, no Qt (see the testing conventions in CLAUDE.md)."""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from bolt_pet.speech_text import for_display, for_speech, is_question
|
|
|
|
|
|
def test_bold_markers_are_not_spoken():
|
|
assert "*" not in for_speech("Here's the **maji-desktop** snapshot")
|
|
assert for_speech("Here's the **maji-desktop** snapshot") == "Here's the maji-desktop snapshot"
|
|
|
|
|
|
def test_italics_and_underscores_dropped():
|
|
assert for_speech("that is _really_ odd") == "that is really odd"
|
|
assert for_speech("***everything*** is fine") == "everything is fine"
|
|
|
|
|
|
def test_bullet_list_becomes_sentences():
|
|
spoken = for_speech(
|
|
"System status:\n"
|
|
"* **OS:** Linux 7.0.0\n"
|
|
"* **Uptime:** 1 day\n"
|
|
)
|
|
assert "*" not in spoken
|
|
assert spoken == "System status: OS: Linux 7.0.0. Uptime: 1 day."
|
|
|
|
|
|
def test_emoji_and_symbols_removed():
|
|
assert for_speech("✅ Done 🚀 — all good") == "Done all good"
|
|
assert for_speech("→ next step") == "to next step"
|
|
|
|
|
|
def test_urls_and_code_are_not_read_out_character_by_character():
|
|
assert for_speech("see https://example.com/x?y=1 for docs") == "see link for docs"
|
|
assert for_speech("run `sudo reboot` now") == "run sudo reboot now"
|
|
assert for_speech("here:\n```\nls -la\n```\n") == "here: (code)."
|
|
|
|
|
|
def test_headings_quotes_and_rules_stripped():
|
|
assert for_speech("## Summary\n---\n> quoted bit") == "Summary. quoted bit."
|
|
|
|
|
|
def test_ampersand_and_percent_are_spoken_as_words():
|
|
assert for_speech("R&D at 50% capacity") == "R and D at 50 percent capacity"
|
|
|
|
|
|
def test_blank_and_symbol_only_input():
|
|
assert for_speech("") == ""
|
|
assert for_speech(None) == ""
|
|
assert for_speech("***") == ""
|
|
|
|
|
|
def test_display_keeps_emoji_but_drops_markdown():
|
|
assert for_display("**Done** ✅") == "Done ✅"
|
|
assert for_display("* one\n* two") == "• one • two"
|
|
|
|
|
|
def test_is_question_fires_when_a_question_mark_appears_anywhere():
|
|
assert is_question("Ready to run a command or start a project?")
|
|
assert is_question("It's 7:15 AM. Want me to set a timer?")
|
|
assert is_question("What time is it? It's 7:15 AM.")
|
|
assert is_question("Can you help me with this? I need a quick answer.")
|
|
assert not is_question("It's 7:15 AM on July 23, 2026.")
|
|
|
|
|
|
def test_is_question_ignores_trailing_decoration():
|
|
assert is_question("Ready to go? 🚀")
|
|
assert is_question('Shall I continue?"')
|
|
assert is_question("Want me to fix it? **")
|
|
|
|
|
|
def test_is_question_ignores_question_marks_that_are_not_spoken():
|
|
# The '?' here is inside a URL query string, which for_speech strips.
|
|
assert not is_question("Docs are at https://example.com/x?y=1")
|
|
assert not is_question("")
|
|
assert not is_question(None)
|
|
|
|
|
|
def test_abbreviations_are_worded_instead_of_spelled_out():
|
|
"""The periods make these look like sentence boundaries, so the voice reads
|
|
them letter by letter ("eee gee")."""
|
|
assert for_speech("Use a flag, e.g. --force") == "Use a flag, for example force"
|
|
assert for_speech("i.e. the config file") == "that is the config file"
|
|
assert for_speech("logs, configs, etc.") == "logs, configs, and so on"
|
|
assert for_speech("docker vs. podman") == "docker versus podman"
|
|
assert for_speech("Fixed in PR #42") == "Fixed in PR number 42"
|
|
|
|
|
|
def test_abbreviation_wording_is_word_bounded():
|
|
""""vs" inside a word or filename isn't an abbreviation."""
|
|
assert "versus" not in for_speech("the vscode window")
|
|
assert "versus" not in for_speech("revs per minute")
|
|
# A markdown heading has no digit after the hashes, so it's still a heading.
|
|
assert for_speech("## Results") == "Results"
|
|
|
|
|
|
def test_long_option_dashes_are_dropped_but_hyphens_survive():
|
|
assert for_speech("run it with --force") == "run it with force"
|
|
assert for_speech("check bolt-pet is up-to-date") == "check bolt-pet is up-to-date"
|
|
# The rule line is gone; the full stops are _bullets_to_sentences giving the
|
|
# voice a pause where the eye saw a line break.
|
|
assert for_speech("one\n---\ntwo") == "one. two."
|