Skip to content

dictation_dictionary

dictation_dictionary

User dictation dictionary — STT replacement words (Diapason-style).

Stored as JSON (default ~/.diapason/dictation_dictionary.json). Not a filler lexicon — fillers live in dictate_polish.py.

Functions:

apply_dictionary

apply_dictionary(
    text: str,
    entries: Optional[list[DictionaryEntry]] = None,
    *,
    path: str | Path | None = None,
    bump_usage: bool = True,
) -> str

Replace STT mistakes using case-insensitive, word-boundary matches.

Source code in src/diapason/speech/dictation_dictionary.py
def apply_dictionary(
    text: str,
    entries: Optional[list[DictionaryEntry]] = None,
    *,
    path: str | Path | None = None,
    bump_usage: bool = True,
) -> str:
    """Replace STT mistakes using case-insensitive, word-boundary matches."""
    raw = text or ""
    if not raw.strip():
        return raw
    items = entries if entries is not None else load_dictionary(path)
    if not items:
        return raw

    # Longer keys first so "open ai" wins over "ai"
    # Map key_lower → entry id for usage bumps
    key_to_id: dict[str, str] = {}
    pairs: list[tuple[str, str]] = []
    for entry in sorted(items, key=lambda e: (-e.usage_count, -len(e.word))):
        for key in _replacement_keys(entry):
            pairs.append((key, entry.word))
            key_to_id[key.lower()] = entry.id
    pairs.sort(key=lambda kv: len(kv[0]), reverse=True)

    result = raw
    bumped: set[str] = set()
    for key, word in pairs:
        # Allow multi-word keys with flexible whitespace
        parts = re.split(r"\s+", key.strip())
        if not parts:
            continue
        pattern = r"\b" + r"\s+".join(re.escape(p) for p in parts) + r"\b"
        new_result, n = re.subn(pattern, word, result, flags=re.IGNORECASE)
        if n and bump_usage:
            eid = key_to_id.get(key.lower())
            if eid:
                bumped.add(eid)
        result = new_result

    if bumped:
        by_id = {e.id: e for e in items}
        changed = False
        for eid in bumped:
            e = by_id.get(eid)
            if e is None:
                continue
            e.usage_count = int(e.usage_count or 0) + 1
            changed = True
        if changed:
            try:
                save_dictionary(items, path)
            except OSError:
                logger.debug("could not persist dictionary usage", exc_info=True)
    return result

learn_from_correction

learn_from_correction(
    original: str,
    corrected: str,
    *,
    path: str | Path | None = None,
    locale: str = "",
) -> list[DictionaryEntry]

Infer dictionary entries from an STT→user-corrected pair.

Uses difflib opcodes to find short replace spans (1–3 tokens). Skips filler-only and tiny tokens. Upserts into the dictionary file.

Source code in src/diapason/speech/dictation_dictionary.py
def learn_from_correction(
    original: str,
    corrected: str,
    *,
    path: str | Path | None = None,
    locale: str = "",
) -> list[DictionaryEntry]:
    """Infer dictionary entries from an STT→user-corrected pair.

    Uses difflib opcodes to find short replace spans (1–3 tokens).
    Skips filler-only and tiny tokens. Upserts into the dictionary file.
    """
    import difflib

    before = (original or "").strip()
    after = (corrected or "").strip()
    if not before or not after or before == after:
        return []

    a = before.split()
    b = after.split()
    if not a or not b:
        return []

    learned: list[DictionaryEntry] = []
    matcher = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
    for tag, i1, i2, j1, j2 in matcher.get_opcodes():
        if tag != "replace":
            continue
        if (i2 - i1) > 3 or (j2 - j1) > 3:
            continue
        from_phrase = " ".join(a[i1:i2]).strip()
        to_phrase = " ".join(b[j1:j2]).strip()
        if not from_phrase or not to_phrase:
            continue
        if from_phrase.lower() == to_phrase.lower():
            continue
        if not _learnable_phrase(from_phrase) or not _learnable_phrase(to_phrase):
            continue

        items = load_dictionary(path)
        existing = None
        for e in items:
            if e.word.lower() == to_phrase.lower():
                existing = e
                break
        if existing is None:
            entry = DictionaryEntry(
                word=to_phrase,
                original_word=from_phrase,
                replacements=[from_phrase],
                locale=locale,
                usage_count=1,
                context="auto-learn",
            )
        else:
            reps = list(existing.replacements or [])
            if from_phrase not in reps and from_phrase.lower() != existing.word.lower():
                reps.append(from_phrase)
            if not existing.original_word:
                existing.original_word = from_phrase
            existing.replacements = reps
            existing.usage_count = int(existing.usage_count or 0) + 1
            if not existing.context:
                existing.context = "auto-learn"
            entry = existing
        learned.append(upsert_entry(entry, path=path))
    return learned

transcription_hints

transcription_hints(
    entries: Optional[list[DictionaryEntry]] = None,
    *,
    path: str | Path | None = None,
    limit: int = 40,
) -> list[str]

Canonical words for STT boosting prompts.

Source code in src/diapason/speech/dictation_dictionary.py
def transcription_hints(
    entries: Optional[list[DictionaryEntry]] = None,
    *,
    path: str | Path | None = None,
    limit: int = 40,
) -> list[str]:
    """Canonical words for STT boosting prompts."""
    items = entries if entries is not None else load_dictionary(path)
    ranked = sorted(items, key=lambda e: -e.usage_count)
    words: list[str] = []
    seen: set[str] = set()
    for e in ranked:
        w = e.word.strip()
        if not w or w.lower() in seen:
            continue
        # Defensive: a hint is a word, and this list is fed straight to the
        # recogniser as a prompt. An oversized entry — from a dictionary
        # poisoned before the learn-time caps existed — must not be sent.
        if len(w) > _MAX_LEARN_CHARS:
            continue
        seen.add(w.lower())
        words.append(w)
        if len(words) >= limit:
            break
    return words