-
Notifications
You must be signed in to change notification settings - Fork 2k
Expand file tree
/
Copy pathpostprocess_hotwords.py
More file actions
398 lines (331 loc) · 13.6 KB
/
Copy pathpostprocess_hotwords.py
File metadata and controls
398 lines (331 loc) · 13.6 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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# Copyright FunASR (https://github.com/modelscope/FunASR). All Rights Reserved.
# MIT License (https://opensource.org/licenses/MIT)
"""Text-level hotword correction after ASR decoding.
This module is intentionally separate from model-level ``hotword`` / ``hotwords``
prompting. It runs after ASR (and punctuation / ITN when configured) and only
updates top-level ``text`` plus sentence-level ``text`` / ``sentence`` fields.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
HotwordInput = Union[str, Sequence[str], Mapping[str, str], None]
_EXPLICIT_SEPARATORS = ("=>", "->", "→")
_TOKEN_PATTERN = re.compile(r"[\u4e00-\u9fff]|[a-zA-Z]+|[0-9]+")
_LAZY_PINYIN = None
_PINYIN_STYLE = None
_RAPIDFUZZ_FUZZ = None
@dataclass(frozen=True)
class HotwordMatch:
"""A single postprocess hotword replacement."""
original: str
replacement: str
score: float
start: int
end: int
def as_dict(self) -> Dict[str, Any]:
return {
"original": self.original,
"replacement": self.replacement,
"score": self.score,
"start": self.start,
"end": self.end,
}
def _require_pypinyin():
global _LAZY_PINYIN, _PINYIN_STYLE
if _LAZY_PINYIN is None:
try:
from pypinyin import Style, lazy_pinyin
except ImportError as exc:
raise ImportError(
"postprocess hotword fuzzy matching requires pypinyin. "
"Install it with: pip install pypinyin"
) from exc
_LAZY_PINYIN = lazy_pinyin
_PINYIN_STYLE = Style
return _LAZY_PINYIN, _PINYIN_STYLE
def _require_rapidfuzz():
global _RAPIDFUZZ_FUZZ
if _RAPIDFUZZ_FUZZ is None:
try:
from rapidfuzz import fuzz
except ImportError as exc:
raise ImportError(
"postprocess hotword fuzzy matching requires rapidfuzz. "
"Install it with: pip install rapidfuzz"
) from exc
_RAPIDFUZZ_FUZZ = fuzz
return _RAPIDFUZZ_FUZZ
def _to_pinyin_key(text: str) -> str:
lazy_pinyin, style = _require_pypinyin()
return "".join(lazy_pinyin(text, style=style.NORMAL, errors="ignore")).lower()
def _parse_line(line: str) -> Tuple[Optional[str], Optional[str], bool]:
"""Parse one hotword file line.
Returns:
(wrong, right, is_explicit)
For fuzzy-only targets, wrong is None and right is the target word.
"""
stripped = line.strip()
if not stripped or stripped.startswith("#"):
return None, None, False
for sep in _EXPLICIT_SEPARATORS:
if sep in stripped:
wrong, right = stripped.split(sep, 1)
wrong = wrong.strip()
right = right.strip()
if wrong and right:
return wrong, right, True
return None, None, False
return None, stripped, False
def parse_hotword_file(path: str) -> Tuple[Dict[str, str], List[str]]:
if not os.path.isfile(path):
raise FileNotFoundError(f"postprocess_hotword_file not found: {path}")
explicit: Dict[str, str] = {}
fuzzy_targets: List[str] = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
wrong, right, is_explicit = _parse_line(line)
if not right:
continue
if is_explicit and wrong is not None:
explicit[wrong] = right
else:
fuzzy_targets.append(right)
return explicit, fuzzy_targets
def parse_postprocess_hotwords(
postprocess_hotwords: HotwordInput,
) -> Tuple[Dict[str, str], List[str]]:
"""Parse in-memory hotword config into explicit and fuzzy buckets."""
explicit: Dict[str, str] = {}
fuzzy_targets: List[str] = []
if postprocess_hotwords is None:
return explicit, fuzzy_targets
if isinstance(postprocess_hotwords, str):
for line in postprocess_hotwords.splitlines():
wrong, right, is_explicit = _parse_line(line)
if not right:
continue
if is_explicit and wrong is not None:
explicit[wrong] = right
else:
fuzzy_targets.append(right)
return explicit, fuzzy_targets
if isinstance(postprocess_hotwords, Mapping):
for wrong, right in postprocess_hotwords.items():
wrong_s = str(wrong).strip()
right_s = str(right).strip()
if not right_s:
continue
if wrong_s and wrong_s != right_s:
explicit[wrong_s] = right_s
else:
fuzzy_targets.append(right_s)
return explicit, fuzzy_targets
if isinstance(postprocess_hotwords, Sequence) and not isinstance(postprocess_hotwords, (str, bytes)):
for item in postprocess_hotwords:
if item is None:
continue
item_s = str(item).strip()
if not item_s:
continue
wrong, right, is_explicit = _parse_line(item_s)
if is_explicit and wrong is not None:
explicit[wrong] = right
elif right:
fuzzy_targets.append(right)
return explicit, fuzzy_targets
raise TypeError(
"postprocess_hotwords must be None, str, list, or dict; "
f"got {type(postprocess_hotwords)!r}"
)
def build_postprocess_hotword_matcher(
postprocess_hotwords: HotwordInput = None,
postprocess_hotword_file: Optional[str] = None,
postprocess_hotword_threshold: float = 0.85,
enable_fuzzy: bool = True,
) -> Optional["PostprocessHotwordMatcher"]:
"""Compile a matcher once per ``generate()`` call."""
explicit: Dict[str, str] = {}
fuzzy_targets: List[str] = []
if postprocess_hotwords is not None:
e, f = parse_postprocess_hotwords(postprocess_hotwords)
explicit.update(e)
fuzzy_targets.extend(f)
if postprocess_hotword_file:
e, f = parse_hotword_file(postprocess_hotword_file)
explicit.update(e)
fuzzy_targets.extend(f)
if not explicit and not fuzzy_targets:
return None
return PostprocessHotwordMatcher(
explicit_map=explicit,
fuzzy_targets=fuzzy_targets,
threshold=postprocess_hotword_threshold,
enable_fuzzy=enable_fuzzy,
)
class PostprocessHotwordMatcher:
"""Compiled matcher reused across all results in one generate() call."""
def __init__(
self,
explicit_map: Optional[Dict[str, str]] = None,
fuzzy_targets: Optional[Iterable[str]] = None,
threshold: float = 0.85,
enable_fuzzy: bool = True,
):
self.explicit_map = dict(explicit_map or {})
self.threshold = float(threshold)
if not 0.0 <= self.threshold <= 1.0:
raise ValueError(
f"postprocess_hotword_threshold must be between 0.0 and 1.0, got {threshold}"
)
self.enable_fuzzy = bool(enable_fuzzy)
seen = set()
self.fuzzy_targets: List[str] = []
for target in fuzzy_targets or []:
target_s = str(target).strip()
if target_s and target_s not in seen:
seen.add(target_s)
self.fuzzy_targets.append(target_s)
self._length_buckets: Dict[int, List[Tuple[str, str]]] = {}
self._fuzz = None
if self.fuzzy_targets and self.enable_fuzzy:
self._fuzz = _require_rapidfuzz()
_require_pypinyin()
for target in self.fuzzy_targets:
bucket = self._length_buckets.setdefault(len(target), [])
bucket.append((target, _to_pinyin_key(target)))
def apply_text(self, text: str) -> Tuple[str, List[HotwordMatch]]:
if not text:
return text, []
matches: List[HotwordMatch] = []
updated = self._apply_explicit(text, matches)
if self.fuzzy_targets and self.enable_fuzzy:
updated, fuzzy_matches = self._apply_fuzzy(updated)
matches.extend(fuzzy_matches)
return updated, matches
def apply_result(self, result: Dict[str, Any], return_matches: bool = False) -> Dict[str, Any]:
text = result.get("text", "")
if not isinstance(text, str) or not text:
if return_matches:
result["postprocess_hotword_matches"] = []
return result
original_timestamp = result.get("timestamp")
new_text, matches = self.apply_text(text)
result["text"] = new_text
sentence_info = result.get("sentence_info")
if isinstance(sentence_info, list):
for sentence in sentence_info:
if not isinstance(sentence, dict):
continue
for field in ("text", "sentence"):
if field in sentence and isinstance(sentence[field], str):
corrected, _ = self.apply_text(sentence[field])
sentence[field] = corrected
if return_matches:
result["postprocess_hotword_matches"] = [m.as_dict() for m in matches]
# Timestamps intentionally remain aligned to the original recognition.
if original_timestamp is not None:
result["timestamp"] = original_timestamp
return result
def _apply_explicit(self, text: str, matches: List[HotwordMatch]) -> str:
if not self.explicit_map:
return text
updated = text
for wrong in sorted(self.explicit_map, key=len, reverse=True):
right = self.explicit_map[wrong]
start = 0
while True:
idx = updated.find(wrong, start)
if idx < 0:
break
end = idx + len(wrong)
matches.append(
HotwordMatch(
original=wrong,
replacement=right,
score=1.0,
start=idx,
end=end,
)
)
updated = updated[:idx] + right + updated[end:]
start = idx + len(right)
return updated
def _apply_fuzzy(self, text: str) -> Tuple[str, List[HotwordMatch]]:
assert self._fuzz is not None
candidates: List[HotwordMatch] = []
if not self._length_buckets:
return text, []
min_len = min(self._length_buckets)
max_len = max(self._length_buckets)
text_len = len(text)
for win_len in range(max(1, min_len - 1), max_len + 2):
bucket_keys = [
length
for length in (win_len - 1, win_len, win_len + 1)
if length in self._length_buckets
]
if not bucket_keys:
continue
for start in range(0, text_len - win_len + 1):
end = start + win_len
segment = text[start:end]
if not segment or not _TOKEN_PATTERN.search(segment):
continue
segment_py = _to_pinyin_key(segment)
for length in bucket_keys:
for target, target_py in self._length_buckets[length]:
if segment == target:
continue
score = self._fuzz.ratio(segment_py, target_py) / 100.0
if score >= self.threshold:
candidates.append(
HotwordMatch(
original=segment,
replacement=target,
score=round(score, 4),
start=start,
end=end,
)
)
if not candidates:
return text, []
selected = _select_non_overlapping(candidates)
updated = text
applied: List[HotwordMatch] = []
for match in sorted(selected, key=lambda m: m.start, reverse=True):
updated = updated[: match.start] + match.replacement + updated[match.end :]
applied.append(match)
applied.sort(key=lambda m: m.start)
return updated, applied
def _select_non_overlapping(candidates: List[HotwordMatch]) -> List[HotwordMatch]:
ranked = sorted(candidates, key=lambda m: (m.score, m.end - m.start), reverse=True)
selected: List[HotwordMatch] = []
occupied: List[Tuple[int, int]] = []
for candidate in ranked:
if any(not (candidate.end <= start or candidate.start >= end) for start, end in occupied):
continue
selected.append(candidate)
occupied.append((candidate.start, candidate.end))
return sorted(selected, key=lambda m: m.start)
def apply_postprocess_hotwords_to_results(
results: List[Dict[str, Any]],
cfg: Mapping[str, Any],
) -> List[Dict[str, Any]]:
"""Apply compiled matcher to each result dict if configured in cfg."""
matcher = build_postprocess_hotword_matcher(
postprocess_hotwords=cfg.get("postprocess_hotwords"),
postprocess_hotword_file=cfg.get("postprocess_hotword_file"),
postprocess_hotword_threshold=cfg.get("postprocess_hotword_threshold", 0.85),
enable_fuzzy=cfg.get("postprocess_hotword_fuzzy", True),
)
if matcher is None:
return results
return_matches = bool(
cfg.get("return_postprocess_hotword_matches", False)
)
for result in results:
if isinstance(result, dict):
matcher.apply_result(result, return_matches=return_matches)
return results