From 62403d485bc4e224a26be9b2cf556d0502749fbc Mon Sep 17 00:00:00 2001 From: StuBehan Date: Fri, 10 Jul 2026 10:56:27 +0100 Subject: [PATCH] feat: spell out dev acronyms espeak mispronounces in normalize --- docs/speech-normalization.md | 4 ++-- stackvox/cli.py | 7 +++++++ stackvox/text.py | 30 +++++++++++++++++++++++++++++- tests/test_cli.py | 1 + tests/test_text.py | 25 +++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/docs/speech-normalization.md b/docs/speech-normalization.md index 5cf9a6b..0808c0a 100644 --- a/docs/speech-normalization.md +++ b/docs/speech-normalization.md @@ -31,7 +31,7 @@ the mechanism now and let each caller keep evolving its own dictionary. - Backward compatible — existing `speak`/`synthesize` behaviour unchanged unless opted in. **Non-goals** -- Baking domain pronunciations (agy, StackOne, Redis…) into StackVox. +- Baking **domain/org** pronunciations (agy, StackOne, Redis…) into StackVox — those stay per-consumer. Generic fixes for dev acronyms espeak plain *mispronounces* (CLI→"kligh", AWS→"awz", URI→"yuri", …) **are** shipped in core, on by default via `dev_terms` / `_DEV_PRONUNCIATIONS`. - Owning the blog's `say:` authoring directive (a Markdown-authoring convention; Claude responses never contain it). - Changing default synthesis behaviour or the daemon protocol. @@ -94,7 +94,7 @@ stackvox normalize --file resp.md # print normalized te ### Illustrative dictionaries (stay per-consumer) - **Blog:** `agy, 1M, 175K, xhigh, SessionStart, PermissionRequest, StackOne, OAuth, Behan, Redis` -- **speaklast (to tune by listening):** `StackOne, Redis, OAuth, CLI, npm, async, repo, MCP, …` — Claude leans on tool/lib/identifier names, so this dict will grow differently. +- **speaklast (to tune by listening):** `StackOne, Redis, repo, …` — domain/product names Claude leans on. Generic dev acronyms (CLI, CI, AWS, URI, IAM…) now live in core's default `dev_terms` map, so they don't need repeating per-consumer. ## 6. Backward compatibility & rollout diff --git a/stackvox/cli.py b/stackvox/cli.py index 385f698..d451539 100644 --- a/stackvox/cli.py +++ b/stackvox/cli.py @@ -208,6 +208,12 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) - type=Path, help="JSON file mapping written -> spoken forms (whole-word, case-insensitive)", ) + parser.add_argument( + "--no-dev-terms", + dest="dev_terms", + action="store_false", + help="Do not spell out dev acronyms espeak mispronounces (CLI, CI, IDE, AWS, URI, IAM, ...)", + ) parser.add_argument( "--no-expand-units", dest="expand_units", @@ -268,6 +274,7 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict: return { "markdown": args.markdown, "pronunciations": _load_pronunciations(args.pronunciations), + "dev_terms": args.dev_terms, "expand_units": args.expand_units, "expand_numbers": args.expand_numbers, "pauses": args.pauses, diff --git a/stackvox/text.py b/stackvox/text.py index c0797c3..8daffb7 100644 --- a/stackvox/text.py +++ b/stackvox/text.py @@ -106,6 +106,28 @@ def apply_pronunciations(text: str, mapping: dict[str, str] | None) -> str: return text +# Dev acronyms/terms espeak mispronounces — it reads them as a word ("CLI" -> +# "kligh", "AWS" -> "awz", "URI" -> "yuri") instead of spelling them out. +# Applied by default (dev_terms=True), whole-word and case-insensitive, so +# lowercase "cli" is fixed too. Compound keys (ci/cd) precede their parts (ci) +# so the specific form wins. Only terms espeak gets WRONG are here — API, URL, +# JSON, YAML, HTTP, CRUD, nginx, etc. already voice correctly and are left alone. +_DEV_PRONUNCIATIONS: dict[str, str] = { + "ci/cd": "C I C D", + "cli": "C L I", + "ci": "C I", + "ide": "I D E", + "aws": "A W S", + "uri": "U R I", + "iam": "I A M", + "saas": "sass", + "paas": "pass", + "tui": "T U I", + "postgresql": "postgres", + "kubectl": "kube control", +} + + # --------------------------------------------------------------------------- # # Pauses # # --------------------------------------------------------------------------- # @@ -274,6 +296,7 @@ def normalize_for_speech( *, markdown: bool = True, pronunciations: dict[str, str] | None = None, + dev_terms: bool = True, expand_units: bool = True, expand_numbers: bool = True, pauses: bool = True, @@ -285,6 +308,11 @@ def normalize_for_speech( """Normalize ``text`` into speakable prose. Returns paragraphs joined by newlines. See ``docs/speech-normalization.md`` for the full contract.""" expand_units_flag, expand_numbers_flag = expand_units, expand_numbers + # Built-in dev-term fixes first; caller-supplied pronunciations override them + # (keyed case-insensitively, so a caller's "CLI" beats the default "cli"). + effective_pronunciations: dict[str, str] = dict(_DEV_PRONUNCIATIONS) if dev_terms else {} + for written, spoken in (pronunciations or {}).items(): + effective_pronunciations[written.lower()] = spoken if markdown: paragraphs = markdown_to_paragraphs(text, tables=tables, strip_emoji_flag=strip_emoji) @@ -298,7 +326,7 @@ def normalize_for_speech( for para in paragraphs: shaped = _shape_paragraph( para, - pronunciations=pronunciations, + pronunciations=effective_pronunciations, expand_units_flag=expand_units_flag, expand_numbers_flag=expand_numbers_flag, pauses_flag=pauses, diff --git a/tests/test_cli.py b/tests/test_cli.py index b843a1f..cb7845a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -128,6 +128,7 @@ def _norm_ns(text=None, file=None, **overrides): file=file, markdown=True, pronunciations=None, + dev_terms=True, expand_units=True, expand_numbers=True, pauses=True, diff --git a/tests/test_text.py b/tests/test_text.py index e7bf735..5538445 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -195,3 +195,28 @@ def test_speaklast_style_normalization(): def test_plain_text_mode_splits_paragraphs(): out = normalize_for_speech("First para.\n\nSecond para.", markdown=False) assert out == "First para.\nSecond para." + + +def test_dev_acronyms_are_spelled_out(): + assert normalize_for_speech("Use the CLI.", markdown=False) == "Use the C L I." + assert "C L I" in normalize_for_speech("the cli tool", markdown=False) # lowercase too + assert "A W S" in normalize_for_speech("deploy to AWS", markdown=False) + assert "U R I" in normalize_for_speech("parse the URI", markdown=False) + assert "C I C D" in normalize_for_speech("the CI/CD pipeline", markdown=False) + + +def test_dev_terms_leave_correctly_voiced_acronyms_alone(): + out = normalize_for_speech("Send JSON over HTTP to the API.", markdown=False) + assert "JSON" in out + assert "HTTP" in out + assert "API" in out + + +def test_dev_terms_can_be_disabled(): + assert normalize_for_speech("Use the CLI.", markdown=False, dev_terms=False) == "Use the CLI." + + +def test_caller_pronunciations_override_dev_terms(): + out = normalize_for_speech("Use the CLI.", markdown=False, pronunciations={"CLI": "command line"}) + assert "command line" in out + assert "C L I" not in out