blitztext-app-linux/linux/blitztext/routing.py
mARTin-B78 34e4736080 Add voice-keyword routing
One hotkey (Ctrl+Alt+Space) records; the spoken keyword at the start or end of
speech selects the preset, which is then stripped and the rest applied. New
routing.py does ASR-tolerant matching (normalize + fuzzy + edge-window scan +
token-drift slack); config gains a [routing] section and per-preset `keywords`;
the daemon adds a "route" mode and biases Whisper with the keywords as hotwords.
Falls back to a default preset when no keyword is recognised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:47:12 +02:00

129 lines
4.4 KiB
Python

"""Voice-keyword routing.
Given a transcript, find a trigger keyword at the start or end that selects a
preset, strip it, and return the cleaned text. ASR-tolerant: normalizes text and
fuzzy-matches so "nicer e-mail." still routes to the "nicer email" preset.
Only the leading/trailing word-windows are scanned, so a keyword spoken inside
the body doesn't misfire. Returns the best match across all presets.
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass
from difflib import SequenceMatcher
# How similar a window has to be to a keyword phrase to count as a match.
DEFAULT_THRESHOLD = 0.82
# A keyword phrase of N words is matched against the first/last N words (+ a
# little slack) of the transcript.
EDGE_SLACK = 1
@dataclass
class RouteResult:
preset_name: str | None # None -> no keyword matched (use default)
text: str # transcript with the keyword stripped
keyword: str | None # the configured keyword that matched
position: str | None # "start" | "end"
score: float
def _strip_accents(s: str) -> str:
return "".join(c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c))
def normalize(text: str) -> list[str]:
"""Lowercase, drop accents/punctuation, return word tokens."""
text = _strip_accents(text.lower())
text = re.sub(r"[^\w\s]", " ", text, flags=re.UNICODE)
return text.split()
def _similar(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio()
def _match_window(tokens: list[str], kw_tokens: list[str], threshold: float):
"""Try to match kw_tokens against the start and end of tokens.
Tries span lengths of n-1..n+1 words at each edge so ASR token drift
(e.g. "e-mail" -> "e mail") still matches. Returns (position, score,
span_len) for the best edge match, or None.
"""
n = len(kw_tokens)
if not n or not tokens:
return None
kw = " ".join(kw_tokens)
best = None
for span in range(max(1, n - EDGE_SLACK), n + EDGE_SLACK + 1):
if span > len(tokens):
continue
for pos, window in (("start", tokens[:span]), ("end", tokens[-span:])):
score = _similar(" ".join(window), kw)
if score >= threshold and (best is None or score > best[1]):
best = (pos, score, span)
return best
def route(
transcript: str,
presets,
*,
threshold: float = DEFAULT_THRESHOLD,
) -> RouteResult:
"""Pick the preset whose keyword best matches an edge of the transcript.
`presets` is any iterable of objects with `.name` and `.keywords` (list[str]).
"""
tokens = normalize(transcript)
if not tokens:
return RouteResult(None, transcript.strip(), None, None, 0.0)
best = None # (score, span_len, position, preset_name, keyword)
for preset in presets:
for keyword in getattr(preset, "keywords", None) or []:
kw_tokens = normalize(keyword)
m = _match_window(tokens, kw_tokens, threshold)
if m is None:
continue
position, score, span = m
# Prefer higher score, then longer (more specific) keyword phrase.
key = (score, span)
if best is None or key > (best[0], best[1]):
best = (score, span, position, preset.name, keyword)
if best is None:
return RouteResult(None, transcript.strip(), None, None, 0.0)
score, span, position, name, keyword = best
cleaned = _strip_span(transcript, span, position)
return RouteResult(name, cleaned, keyword, position, score)
def _strip_span(transcript: str, span_words: int, position: str) -> str:
"""Remove the matched keyword from the given edge of the original transcript.
`span_words` counts *normalized* tokens; a raw word like "e-mail." may
normalize to two tokens, so we consume raw words until their cumulative
normalized-token count reaches the span. Original casing/spacing is kept.
"""
words = transcript.split()
seq = words if position == "start" else list(reversed(words))
consumed = 0
ntok = 0
for w in seq:
ntok += len(normalize(w))
consumed += 1
if ntok >= span_words:
break
remaining = words[consumed:] if position == "start" else words[: len(words) - consumed]
out = " ".join(remaining)
# Trim leftover separators left where the keyword was removed.
return out.strip(" \t\r\n,.;:!?-–—\"'").strip()