feat(commands): localize punctuation voice commands (#640) (#642)

* feat(commands): localize punctuation voice commands by language

Voice command phrases were English-only even when recognition language
was set to Italian, French, etc. Merge locale punctuation/line-break
aliases from the recognition language into CommandProcessor, keep
English phrases available, and document the behavior.

Fixes #640

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

* fix(commands): keep line breaks and French apostrophe aliases

Attach-left punctuation now eats only horizontal whitespace, so a
localized "new line" followed by a period or comma still inserts a
newline. French aliases include ASCII, U+2019, and backtick apostrophes,
and longest-match still wins so "point" cannot fire inside
"point d'interrogation".

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
Jatin K Malik
2026-09-07 20:47:12 -07:00
committed by GitHub
co-authored by Cursor Agent Jatin K Malik
parent f90e4d5765
commit 7664266823
9 changed files with 508 additions and 54 deletions
+169
View File
@@ -343,3 +343,172 @@ class TestCommandProcessorFallback(unittest.TestCase):
"""Test paste action through generic path."""
result, actions = self.processor.process_text("paste content")
self.assertIn("paste", actions)
class TestLocalizedTextCommands(unittest.TestCase):
"""Localized punctuation / line-break voice commands (issue #640)."""
def test_english_dot_alias(self):
"""English 'dot' maps to a period."""
processor = CommandProcessor(language="en-us")
result, actions = processor.process_text("end of sentence dot")
self.assertEqual(result, "end of sentence.")
self.assertEqual(actions, [])
def test_italian_punctuation(self):
"""Italian spoken punctuation replaces with symbols."""
processor = CommandProcessor(language="it")
cases = [
("ciao virgola mondo", "ciao,mondo"),
("fine punto", "fine."),
("davvero punto interrogativo", "davvero?"),
("bravo punto esclamativo", "bravo!"),
("lista punto e virgola", "lista;"),
("nota due punti", "nota:"),
("prima nuova riga dopo", "prima \n dopo"),
]
for input_text, expected in cases:
result, actions = processor.process_text(input_text)
self.assertEqual(result, expected, msg=input_text)
self.assertEqual(actions, [])
def test_italian_long_phrase_before_punto(self):
"""'punto interrogativo' must not collapse to '.' + leftover words."""
processor = CommandProcessor(language="it")
result, _ = processor.process_text("sei sicuro punto interrogativo")
self.assertEqual(result, "sei sicuro?")
self.assertNotIn("interrogativo", result)
def test_french_and_german(self):
"""French and German punctuation aliases work."""
fr = CommandProcessor(language="fr")
result, _ = fr.process_text("bonjour virgule monde point")
self.assertEqual(result, "bonjour,monde.")
de = CommandProcessor(language="de")
result, _ = de.process_text("hallo komma welt punkt")
self.assertEqual(result, "hallo,welt.")
def test_spanish_portuguese(self):
"""Spanish and Portuguese punctuation aliases work."""
es = CommandProcessor(language="es")
result, _ = es.process_text("hola coma mundo punto")
self.assertEqual(result, "hola,mundo.")
pt = CommandProcessor(language="pt")
result, _ = pt.process_text("ola virgula mundo ponto")
self.assertEqual(result, "ola,mundo.")
def test_english_phrases_still_work_in_italian(self):
"""English command phrases remain available when language is Italian."""
processor = CommandProcessor(language="it")
result, _ = processor.process_text("hello comma world period")
self.assertEqual(result, "hello,world.")
def test_auto_language_keeps_english_only_aliases(self):
"""auto-detect does not load non-English phrase aliases."""
processor = CommandProcessor(language="auto")
self.assertNotIn("virgola", processor.text_commands)
self.assertIn("dot", processor.text_commands)
result, _ = processor.process_text("ciao virgola")
self.assertEqual(result, "ciao virgola")
def test_set_language_rebuilds_aliases(self):
"""set_language swaps localized aliases without recreating the processor."""
processor = CommandProcessor(language="en-us")
self.assertNotIn("virgola", processor.text_commands)
processor.set_language("it")
self.assertIn("virgola", processor.text_commands)
result, _ = processor.process_text("testo virgola")
self.assertEqual(result, "testo,")
processor.set_language("auto")
self.assertNotIn("virgola", processor.text_commands)
def test_localized_newline_then_punctuation_keeps_break(self):
"""Attach-left punctuation must not eat a preceding command-inserted newline."""
fr = CommandProcessor(language="fr")
result, _ = fr.process_text("bonjour nouvelle ligne point")
self.assertEqual(result, "bonjour \n.")
result, _ = fr.process_text("bonjour nouvelle ligne virgule")
self.assertEqual(result, "bonjour \n,")
it = CommandProcessor(language="it")
result, _ = it.process_text("ciao nuova riga punto")
self.assertEqual(result, "ciao \n.")
def test_localized_punctuation_then_newline_keeps_break(self):
"""Attach-left trailing whitespace must not eat a following newline."""
fr = CommandProcessor(language="fr")
result, _ = fr.process_text("bonjour point nouvelle ligne")
self.assertEqual(result, "bonjour.\n")
def test_french_apostrophe_forms_map_to_question_mark(self):
"""ASCII and typographic apostrophes in 'point d'interrogation' both yield '?'."""
fr = CommandProcessor(language="fr")
ascii_result, _ = fr.process_text("vrai point d'interrogation")
self.assertEqual(ascii_result, "vrai?")
self.assertNotIn("interrogation", ascii_result)
typographic_result, _ = fr.process_text("vrai point d\u2019interrogation")
self.assertEqual(typographic_result, "vrai?")
self.assertNotIn("interrogation", typographic_result)
self.assertNotIn(".", typographic_result)
def test_french_apostrophe_forms_map_to_exclamation(self):
"""ASCII and typographic apostrophes in 'point d'exclamation' both yield '!'."""
fr = CommandProcessor(language="fr")
ascii_result, _ = fr.process_text("bravo point d'exclamation")
self.assertEqual(ascii_result, "bravo!")
typographic_result, _ = fr.process_text("bravo point d\u2019exclamation")
self.assertEqual(typographic_result, "bravo!")
self.assertNotIn("exclamation", typographic_result)
def test_locale_phrases_are_not_partially_replaced(self):
"""Longer aliases must win over embedded shorter ones in every locale."""
from vocalinux.speech_recognition.command_phrases import (
_TEXT_COMMAND_ALIASES,
text_command_aliases_for,
)
for lang, phrases in _TEXT_COMMAND_ALIASES.items():
processor = CommandProcessor(language=lang)
for phrase, replacement in phrases.items():
result, _ = processor.process_text(phrase)
self.assertEqual(
result,
replacement,
msg=f"{lang}: {phrase!r} -> {result!r}, expected {replacement!r}",
)
aliases = text_command_aliases_for(lang)
for apostrophe in ("'", "\u2019", "`"):
question = f"point d{apostrophe}interrogation"
if question in aliases:
result, _ = processor.process_text(f"vrai {question}")
self.assertEqual(result, "vrai?", msg=f"{lang}: {question!r}")
class TestCommandPhrases(unittest.TestCase):
"""Catalog id mapping for localized command aliases."""
def test_normalize_command_language(self):
"""Catalog keys and auto-detect resolve to phrase-table codes."""
from vocalinux.speech_recognition.command_phrases import normalize_command_language
self.assertEqual(normalize_command_language("en-us"), "en")
self.assertEqual(normalize_command_language("en-in"), "en")
self.assertEqual(normalize_command_language("it"), "it")
self.assertEqual(normalize_command_language("auto"), "en")
self.assertEqual(normalize_command_language(None), "en")
self.assertEqual(normalize_command_language("fr"), "fr")
def test_french_aliases_include_apostrophe_variants(self):
"""French question/exclamation phrases exist for ASCII, U+2019, and backtick."""
from vocalinux.speech_recognition.command_phrases import text_command_aliases_for
aliases = text_command_aliases_for("fr")
for apostrophe in ("'", "\u2019", "`"):
self.assertEqual(aliases[f"point d{apostrophe}interrogation"], "?")
self.assertEqual(aliases[f"point d{apostrophe}exclamation"], "!")
self.assertEqual(aliases["point"], ".")