-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvocabulary.py
More file actions
136 lines (113 loc) · 4.97 KB
/
Copy pathvocabulary.py
File metadata and controls
136 lines (113 loc) · 4.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
"""Custom-vocabulary store for biasing Whisper toward user-specific names and terms.
Whisper accepts a free-form `initial_prompt` (≤224 BPE tokens) that biases the
decoder. Wrapping a comma-separated list of words in a natural-language sentence
(e.g. "В разговоре упоминаются: X, Y, Z.") works noticeably better than raw
keyword dumps.
References:
- OpenAI cookbook: https://cookbook.openai.com/examples/whisper_prompting_guide
- Contextual biasing arxiv 2410.18363 — −40-60% WER on domain vocab without
fine-tuning.
"""
from __future__ import annotations
import logging
from typing import Iterable
logger = logging.getLogger(__name__)
# Rough cap to stay under Whisper's 224-token limit. Russian words tokenize
# heavier than English (2–3 BPE per word), so cap below the token limit.
_MAX_WORDS_PROMPT = 60
def build_initial_prompt(words: list[str], language: str | None,
context: str = "") -> str:
"""Compose an initial_prompt string from vocabulary words and (optionally)
on-screen context (concept 25).
Returns an empty string when there's nothing to bias on — callers can pass
the result to faster-whisper unconditionally.
"""
base = _words_clause(words, language)
ctx = (context or "").strip()
if ctx:
# Tail nearest the caret is the most relevant; cap to protect the
# 224-token budget (vocabulary takes priority over context).
ctx = ctx[-400:].strip()
return (ctx + " " + base).strip() if base else ctx
return base
def _words_clause(words: list[str], language: str | None) -> str:
if not words:
return ""
# Newest / most-used first (caller sorts), then cap to avoid blowing the
# 224-token prompt budget.
clean = [w.strip() for w in words if w and w.strip()]
if len(clean) > _MAX_WORDS_PROMPT:
logger.info(
f"Vocabulary: truncating from {len(clean)} to {_MAX_WORDS_PROMPT} words "
"for prompt budget"
)
clean = clean[:_MAX_WORDS_PROMPT]
sample = ", ".join(clean)
if (language or "").lower().startswith("ru"):
return f"В разговоре упоминаются: {sample}."
return f"The discussion mentions: {sample}."
def normalize_words(raw: Iterable[str]) -> list[str]:
"""Dedupe (case-insensitive) and strip — used by Settings save path."""
seen: dict[str, str] = {}
for w in raw:
if not w:
continue
w = w.strip()
if not w:
continue
key = w.lower()
if key not in seen:
seen[key] = w
return list(seen.values())
# ── Auto-learning from user corrections (concept 06) ──────────────────────────
# Words too short / too common to be worth learning. Russian + English baseline.
_STOPWORDS = frozenset({
# ru
"и", "в", "не", "на", "что", "я", "с", "он", "она", "это", "как", "по",
"из", "за", "у", "так", "о", "но", "к", "до", "же", "то", "от", "для",
"вот", "был", "была", "было", "быть", "есть", "его", "её", "их", "мы",
"вы", "ты", "там", "тут", "если", "или", "ли", "бы", "уже", "ещё", "еще",
# en
"the", "a", "an", "and", "or", "is", "are", "was", "were", "be", "to",
"of", "in", "on", "at", "by", "for", "from", "with", "as", "it", "this",
"that", "i", "you", "he", "she", "we", "they",
})
def extract_learnable(original: str, corrected: str) -> list[str]:
"""Diff two strings word-level; return words from `corrected` that look
like proper nouns or technical terms (capitalized, latin scripts, mixed
case, 3+ chars, non-stopword). Used to feed the auto-learning dictionary.
"""
from difflib import SequenceMatcher
orig_words = original.split()
corr_words = corrected.split()
sm = SequenceMatcher(a=orig_words, b=corr_words)
candidates: list[str] = []
for tag, _i1, _i2, j1, j2 in sm.get_opcodes():
if tag in ("replace", "insert"):
for w in corr_words[j1:j2]:
cleaned = w.strip(" .,;:!?\"'«»()[]{}…")
if _looks_like_name(cleaned):
candidates.append(cleaned)
# Dedupe preserving order
seen: dict[str, None] = {}
for w in candidates:
seen.setdefault(w.lower(), w)
out = []
for key, _ in seen.items():
for c in candidates:
if c.lower() == key:
out.append(c)
break
return normalize_words(out)
def _looks_like_name(w: str) -> bool:
if not w or len(w) < 3:
return False
if w.lower() in _STOPWORDS:
return False
if any(ch.isdigit() for ch in w):
return False
# Names / brands / acronyms typically have either a capital letter (any
# position) or are camelCase / contain a dot.
has_capital = any(c.isupper() for c in w[1:]) or w[0].isupper()
has_dot = "." in w
return has_capital or has_dot