"""Tell the recognizer which words to expect, and fix the ones it still gets wrong. Two mechanisms, deliberately separate, because they fail differently: - **Biasing.** `SFSpeechRecognitionRequest.contextualStrings` nudges the decoder towards terms it would otherwise never produce. Measured at 23.5% -> 16.5% word error rate on technical speech. It is a hint, so it fails softly. - **Repair.** Deterministic substitutions applied after recognition, for mistakes that recur identically ("the coral voice" is always Kokoro). Exact and testable, but blind to context, so the rules must be specific. Relevance beats coverage, and by more than expected. Eighteen apt terms measured 16.5% WER; padding them out to 100 with names harvested from the project put it back to 23.5%, i.e. no better than no biasing at all. Padding with a thousand *random dictionary* words cost only 1.1 points, so the damage is not volume — it is that filenames and identifiers ("bot", "plan", "hack") are ordinary words the recognizer would happily produce anyway, and biasing towards them drags real speech onto them. So auto-discovered terms are filtered to ones that are *not* ordinary English, capped tightly, and always ranked behind the hand-written list. The curated file is what carries the benefit; discovery is a small bonus with a real downside. """ import re import subprocess from collections import OrderedDict from pathlib import Path from loguru import logger # Apple documents a ceiling of 100, but measurement says stay well under it: # quality decays fast once ordinary words get in. MAX_TERMS = 40 # Discovered terms are the risky kind, so they get a small share of the budget. MAX_DISCOVERED = 12 _SYSTEM_DICTIONARY = Path("/usr/share/dict/words") # Words that are never worth biasing towards and only crowd out real terms. _STOPWORDS = { "and", "are", "but", "for", "from", "has", "have", "into", "not", "our", "out", "the", "that", "this", "was", "were", "will", "with", "you", "your", "main", "init", "self", "test", "tests", "src", "lib", "util", "utils", "readme", "true", "false", "none", "null", "class", "def", } _IDENTIFIER = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]*$") _CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") # Things worth learning from Claude's replies: CamelCase, dotted paths, # snake_case, and words carrying digits. Ordinary prose is skipped. _TECHNICAL = re.compile(r"\b(?:[A-Za-z]+[._][A-Za-z0-9._-]+|[a-z]+[A-Z][A-Za-z]*|[A-Za-z]*\d[A-Za-z0-9]*)\b") _SKIP_DIRS = {".git", ".venv", "__pycache__", "node_modules", ".cache", "dist", "build"} def _speakable(term: str) -> str: """Turn an identifier into something a person could say. Apple's guidance is one or two words per phrase, spoken without a pause, so `sounddevice_transport` is useless as-is but `sounddevice transport` is not. """ term = term.replace("_", " ").replace("-", " ").replace(".", " ") term = _CAMEL_BOUNDARY.sub(" ", term) return " ".join(term.split()) class Vocabulary: """Ranked terms for the recognizer, plus repair rules for its known mistakes. Terms are ordered by how likely they are to matter: words the user wrote down, then the project around them, then whatever Claude has been talking about lately. Only the first ``limit`` survive. """ def __init__( self, *, project_dir: Path | None = None, vocabulary_file: Path | None = None, corrections_file: Path | None = None, limit: int = MAX_TERMS, ): app_dir = Path(__file__).parent project_dir = project_dir or app_dir vocabulary_file = vocabulary_file or (app_dir / "vocabulary.txt") corrections_file = corrections_file or (app_dir / "corrections.txt") self._limit = limit self._user: list[str] = [] self._project: list[str] = [] self._observed: OrderedDict[str, None] = OrderedDict() self._corrections: list[tuple[re.Pattern, str]] = [] if vocabulary_file and vocabulary_file.exists(): self._user = _read_terms(vocabulary_file) logger.debug(f"Vocabulary: {len(self._user)} terms from {vocabulary_file.name}") if corrections_file and corrections_file.exists(): self._corrections = _read_corrections(corrections_file) logger.debug(f"Vocabulary: {len(self._corrections)} repair rules") if project_dir and project_dir.exists(): self._project = _terms_from_project(project_dir) def add_terms(self, terms: list[str]): """Add terms that rank alongside the hand-written ones. For names the user certainly says — project names out of the brain — which are as authoritative as anything typed into vocabulary.txt. """ for term in terms: speakable = _speakable(term) if _worth_keeping(speakable) and speakable not in self._user: self._user.append(speakable) def observe(self, text: str): """Learn technical words from something Claude just said. What the assistant is discussing is a good predictor of what the user is about to say back, so its replies feed the next recognition. """ for match in _TECHNICAL.findall(text or ""): term = _speakable(match) if _worth_keeping(term) and _is_distinctive(term): self._observed.pop(term, None) # move to most-recent self._observed[term] = None # Keep the tail bounded; only the newest can reach the term list anyway. while len(self._observed) > self._limit * 2: self._observed.popitem(last=False) def terms(self) -> list[str]: """The ranked, de-duplicated, capped term list. What Claude said a moment ago predicts the next utterance better than an arbitrary filename does, so recent observations get a reserved share of the budget rather than queueing behind the whole project. """ budget = max(0, self._limit - len(self._user)) discovered = min(budget, MAX_DISCOVERED) recent = list(reversed(self._observed))[: (discovered + 1) // 2] project = self._project[: discovered - len(recent)] ranked = [*self._user, *recent, *project] seen: dict[str, str] = {} for term in ranked: key = term.lower() if key not in seen: seen[key] = term if len(seen) >= self._limit: break return list(seen.values()) def prompt_block(self, limit: int = 60) -> str: """The stable terms, for Claude's system prompt. Only the written-down and project-derived terms go here: the prompt is fixed for the session, so terms learned mid-conversation could not appear anyway. """ stable = list(dict.fromkeys([*self._user, *self._project]))[:limit] return ", ".join(stable) def repair(self, text: str) -> str: """Apply the substitution rules to a transcript.""" for pattern, replacement in self._corrections: text = pattern.sub(replacement, text) return text def _worth_keeping(term: str) -> bool: return ( len(term) >= 3 and term.lower() not in _STOPWORDS and not term.isdigit() and len(term) <= 40 ) def _load_dictionary() -> frozenset[str]: try: return frozenset(w.strip().lower() for w in _SYSTEM_DICTIONARY.read_text().splitlines()) except OSError: return frozenset() _ORDINARY_WORDS = _load_dictionary() def _is_distinctive(term: str) -> bool: """Whether biasing towards this term could actually change an outcome. Only words the recognizer would not already produce are worth boosting. Biasing towards ordinary English measurably drags correct speech onto the wrong word, which is how a harvested term list erased the entire benefit. """ words = term.lower().split() if len(words) > 1: return True # multi-word phrases are specific enough to be safe return words[0] not in _ORDINARY_WORDS def _read_terms(path: Path) -> list[str]: terms = [] for line in path.read_text().splitlines(): line = line.split("#", 1)[0].strip() if line and _worth_keeping(line): terms.append(line) return terms def _read_corrections(path: Path) -> list[tuple[re.Pattern, str]]: rules = [] for lineno, raw in enumerate(path.read_text().splitlines(), 1): line = raw.split("#", 1)[0].strip() if not line: continue if "=>" not in line: logger.warning(f"{path.name}:{lineno}: expected 'heard => replacement', got {raw!r}") continue heard, replacement = (part.strip() for part in line.split("=>", 1)) if not heard: continue # Word-bounded and case-insensitive so "coral voice" matches mid-sentence # but "chorale" never does. rules.append((re.compile(rf"\b{re.escape(heard)}\b", re.IGNORECASE), replacement)) return rules def _terms_from_project(project_dir: Path) -> list[str]: """Names from the code the user is most likely to talk about.""" terms: list[str] = [] for path in sorted(project_dir.rglob("*")): if any(part in _SKIP_DIRS for part in path.parts): continue if path.is_file(): term = _speakable(path.stem) if _worth_keeping(term) and _is_distinctive(term): terms.append(term) terms.extend(_git_terms(project_dir)) for path in sorted(project_dir.glob("*.py")): try: source = path.read_text(errors="ignore") except OSError: continue for name in re.findall(r"^\s*(?:class|def)\s+([A-Za-z_][A-Za-z0-9_]*)", source, re.M): term = _speakable(name) if _worth_keeping(term) and _is_distinctive(term): terms.append(term) return list(dict.fromkeys(terms)) def _git_terms(project_dir: Path) -> list[str]: """Branch and author names, which are spoken far more often than they're typed.""" terms: list[str] = [] commands = ( ["git", "branch", "--format=%(refname:short)"], ["git", "log", "-40", "--format=%an"], ) for command in commands: try: result = subprocess.run( command, cwd=project_dir, capture_output=True, text=True, timeout=5 ) except (OSError, subprocess.SubprocessError): continue if result.returncode != 0: continue for line in result.stdout.splitlines(): for piece in line.split(): term = _speakable(piece) if _worth_keeping(term) and _is_distinctive(term): terms.append(term) return terms