Skip to content

Commit cd269ca

Browse files
committed
fix(asr): 热词含 tokens 表外字时过滤后重建流(防解码断言崩溃)
1 parent eb3dc70 commit cd269ca

2 files changed

Lines changed: 103 additions & 1 deletion

File tree

‎app/src-tauri/src/streaming_asr.rs‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ pub struct StreamingAsrEngine {
9393
/// 可选标点恢复器(ADR-012 F4-2:重打分未通过的 final 补语义标点;
9494
/// 模型缺失 → None 零开销降级,不阻断 ASR)
9595
punctuator: Option<OfflinePunctuation>,
96+
/// tokens.txt 单字集合(热词过滤;读取失败 → None 不阻断——仅失去过滤能力)
97+
token_chars: Option<std::collections::HashSet<char>>,
9698
}
9799

98100
impl StreamingAsrEngine {
@@ -154,6 +156,11 @@ impl StreamingAsrEngine {
154156
sentence_pcm: Vec::new(),
155157
rescorer,
156158
punctuator,
159+
// 2026-08-21 热词崩溃修复:tokens.txt 单字集合——领域热词(心理成长
160+
// 种子词等)含 tokens 表外字(焦/冥/哲)时 sherpa-onnx EncodeBase
161+
// 失败仍创建 ContextGraph,greedy_search 解码断言 abort(exit
162+
// 0xffffffff,用户真机日志实证);读取失败 → None(不阻断加载)。
163+
token_chars: load_token_chars(&models.tokens),
157164
})
158165
}
159166

@@ -250,7 +257,18 @@ impl StreamingAsrEngine {
250257
.and_then(|v| v.hotwords_string());
251258
match hotwords.as_deref() {
252259
Some(h) if !h.trim().is_empty() => {
253-
self.recognizer.create_stream_with_hotwords(h)
260+
// TD-032 延伸修复(2026-08-21):热词含 tokens.txt 外字符时
261+
// sherpa-onnx 编码失败仍创建 ContextGraph → greedy_search 解码
262+
// 断言 abort(exit 0xffffffff);过滤后重建,空则回退普通流。
263+
let filtered = match &self.token_chars {
264+
Some(chars) => filter_hotwords_by_tokens(h, chars),
265+
None => h.to_string(),
266+
};
267+
if filtered.trim().is_empty() {
268+
self.recognizer.create_stream()
269+
} else {
270+
self.recognizer.create_stream_with_hotwords(&filtered)
271+
}
254272
}
255273
_ => self.recognizer.create_stream(),
256274
}
@@ -307,6 +325,36 @@ impl StreamingAsrEngine {
307325
}
308326
}
309327

328+
/// 读取 tokens.txt 构建单字集合(纯函数;失败 → None)。
329+
///
330+
/// @ai-context: sherpa-onnx 的 EncodeBase 按字符查 token ID(日志实证
331+
/// "Cannot find ID for token 焦")——只收集单字符 token;
332+
/// 多字符 token(▁/标点/英文词)不参与单字覆盖判断。
333+
fn load_token_chars(tokens_path: &str) -> Option<std::collections::HashSet<char>> {
334+
std::fs::read_to_string(tokens_path).ok().map(|raw| {
335+
raw.lines()
336+
.filter_map(|l| l.split_whitespace().next())
337+
.filter(|t| t.chars().count() == 1)
338+
.flat_map(|t| t.chars())
339+
.collect()
340+
})
341+
}
342+
343+
/// 热词 tokens 过滤(纯函数):仅保留所有字符都在 token 集合中的词。
344+
///
345+
/// @ai-context: 词级剔除("冥想"含非法字"冥" → 整词剔除,语义完整);
346+
/// 全部被剔 → 空串(调用方回退普通流,防 ContextGraph 崩溃)。
347+
fn filter_hotwords_by_tokens(
348+
hotwords: &str,
349+
token_chars: &std::collections::HashSet<char>,
350+
) -> String {
351+
hotwords
352+
.split_whitespace()
353+
.filter(|w| w.chars().all(|c| token_chars.contains(&c)))
354+
.collect::<Vec<_>>()
355+
.join(" ")
356+
}
357+
310358
// 兼容 re-export:编辑距离(asr_rescore.rs 实现;dtw_align/subtitle_ocr/fusion 引用此路径)。
311359
pub use crate::asr_rescore::levenshtein;
312360

‎app/src-tauri/src/streaming_asr_tests.rs‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
88
// v0.7.0 M0:silence_feed_decision 随端点处理域拆至子模块 endpoint
99
use super::endpoint::silence_feed_decision;
10+
// 2026-08-21 热词崩溃修复:纯函数在模块级(与引擎分离,纯逻辑可单测)
11+
use super::{filter_hotwords_by_tokens, load_token_chars};
1012
use crate::streaming_asr::{
1113
StreamingAsrConfig, StreamingAsrEngine, StreamingAsrEvent, StreamingAsrModels,
1214
};
@@ -68,6 +70,58 @@ fn default_rule3_is_8_seconds() {
6870
assert_eq!(StreamingAsrConfig::default().rule3_min_utterance_secs, 8.0);
6971
}
7072

73+
// ── 热词 tokens 过滤(2026-08-21:领域热词含 tokens 表外字 → 解码 abort)──
74+
75+
#[test]
76+
fn filter_hotwords_keeps_covered_words() {
77+
// Arrange:token 集合覆盖常用字(焦/冥/哲 缺席——用户真机日志实证)
78+
let chars: std::collections::HashSet<char> =
79+
"心理成长情绪压力习惯认知思维自律人生哲学".chars().collect();
80+
// Act & Assert:全覆盖词原样保留
81+
assert_eq!(
82+
filter_hotwords_by_tokens("心理 成长 习惯", &chars),
83+
"心理 成长 习惯"
84+
);
85+
}
86+
87+
#[test]
88+
fn filter_hotwords_drops_uncovered_words() {
89+
// Arrange:冥想/哲学 含表外字(冥/哲)→ 整词剔除(词级,语义完整)
90+
let chars: std::collections::HashSet<char> = "心理成长".chars().collect();
91+
// Act & Assert:覆盖词保留,表外词剔除
92+
assert_eq!(
93+
filter_hotwords_by_tokens("心理 冥想 哲学 成长", &chars),
94+
"心理 成长"
95+
);
96+
}
97+
98+
#[test]
99+
fn filter_hotwords_all_uncovered_returns_empty() {
100+
// Act & Assert:全部被剔 → 空串(调用方回退普通流,防 ContextGraph 崩溃)
101+
let chars: std::collections::HashSet<char> = "心理".chars().collect();
102+
assert_eq!(filter_hotwords_by_tokens("冥想 哲学", &chars), "");
103+
}
104+
105+
#[test]
106+
fn load_token_chars_collects_single_char_tokens() {
107+
// Arrange:临时 tokens.txt(单字 + 多字符 token + 带 id 行混合)
108+
let dir = tempfile::tempdir().unwrap();
109+
let path = dir.path().join("tokens.txt");
110+
std::fs::write(&path, "心 1\n理 2\n▁ 3\n<sos> 4\n成长 5\n").unwrap();
111+
// Act:读取构建单字集合
112+
let chars = load_token_chars(path.to_str().unwrap()).unwrap();
113+
// Assert:仅单字符 token 入集(▁=U+2581 也是单字符;多字符 token 不参与)
114+
assert!(chars.contains(&'心') && chars.contains(&'理') && chars.contains(&'▁'));
115+
assert!(!chars.contains(&'成'));
116+
assert_eq!(chars.len(), 3);
117+
}
118+
119+
#[test]
120+
fn load_token_chars_missing_file_is_none() {
121+
// Act & Assert:文件缺失 → None(不阻断加载,仅失去过滤能力)
122+
assert!(load_token_chars("C:\\nonexistent\\tokens.txt").is_none());
123+
}
124+
71125
/// 集成测试:用本机真实 Zipformer 模型验证加载与喂入不崩溃。
72126
///
73127
/// @ai-context: 模型目录取 ENTROPY_STREAMING_MODEL_DIR 环境变量,默认

0 commit comments

Comments
 (0)