Skip to content

Commit 7abc9a3

Browse files
committed
feat(asr): REQ-266 热词防护单测与标题注入源扩展
1 parent 4caadc9 commit 7abc9a3

3 files changed

Lines changed: 116 additions & 0 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,15 @@ pub fn detect_video_domain(
178178
/// @param kind - 领域标识(kebab-case;非法值明确报错)
179179
/// @param fine - v0.13.6(REQ-220):细目 id 数组(可选)——候选集 = 粗种子 ∪ 细目种子
180180
/// (前端/后端术语不同,细分预热命中率↑);缺省 None 向后兼容
181+
/// @param title - v0.20.1(REQ-266):窗口标题(可选)——标题主题词自动注入
182+
/// (title_rules::title_hotword_candidates;品牌/纯数字/超长剔除;
183+
/// tokens 表外字由 use-time filter 兜底)
181184
#[tauri::command]
182185
pub fn preheat_domain_hotwords(
183186
state: State<'_, AppState>,
184187
kind: String,
185188
fine: Option<Vec<String>>,
189+
title: Option<String>,
186190
) -> Result<usize, String> {
187191
let kind = crate::video_profile_domain::DomainKind::parse(
188192
&kind.chars().take(30).collect::<String>(),
@@ -198,6 +202,14 @@ pub fn preheat_domain_hotwords(
198202
candidates.push(c);
199203
}
200204
}
205+
// 标题主题词候选(REQ-266:窗口标题自动注入源)
206+
if let Some(t) = title {
207+
for c in crate::title_rules::title_hotword_candidates(&t) {
208+
if !candidates.contains(&c) {
209+
candidates.push(c);
210+
}
211+
}
212+
}
201213
let mut vocab = state
202214
.vocab
203215
.lock()

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,47 @@ fn sensevoice_inference_latency_per_window() {
382382
total
383383
);
384384
}
385+
386+
// ── REQ-266(v0.20.1):tokens 表外字过滤/崩溃防护单测 ──
387+
388+
#[test]
389+
fn hotword_filter_keeps_in_vocab_words_only() {
390+
// Arrange:tokens 表仅含 {a,b,心,理};热词表混入表外字词
391+
let chars: std::collections::HashSet<char> = ['a', 'b', '心', '理'].into_iter().collect();
392+
// Act
393+
let out = filter_hotwords_by_tokens("ab 心 a 焦虑 心理 xyz", &chars);
394+
// Assert:整词剔除("焦虑"含 焦 不在表 → 整词删,保语义完整);全在表者保留
395+
assert_eq!(out, "ab 心 a 心理");
396+
}
397+
398+
#[test]
399+
fn hotword_filter_all_outside_returns_empty() {
400+
// Arrange:无任何表内字
401+
let chars: std::collections::HashSet<char> = ['a'].into_iter().collect();
402+
// Act
403+
let out = filter_hotwords_by_tokens("焦虑 冥想 哲学", &chars);
404+
// Assert:空串——调用方回退普通流(防 ContextGraph 崩溃,2026-08-21 修复回归)
405+
assert_eq!(out, "");
406+
}
407+
408+
#[test]
409+
fn hotword_filter_empty_token_set_is_safe() {
410+
let chars: std::collections::HashSet<char> = std::collections::HashSet::new();
411+
assert_eq!(filter_hotwords_by_tokens("abc 中文", &chars), "");
412+
assert_eq!(filter_hotwords_by_tokens("", &chars), "");
413+
}
414+
415+
#[test]
416+
fn token_chars_load_single_char_columns_only() {
417+
// Arrange:临时 tokens.txt(行格式 "<符号> <id>")——只收单字符列
418+
// (▁ 为 sherpa 空格 token,属单字符列一并收集——热词文本不含 ▁,无害)
419+
let raw = "a 1\nb 2\nab 3\n焦 4\n▁ 5\n";
420+
let path = std::env::temp_dir().join(format!("asr_eval_tokens_{}.txt", std::process::id()));
421+
std::fs::write(&path, raw).expect("写临时 tokens");
422+
// Act
423+
let set = load_token_chars(path.to_string_lossy().as_ref()).expect("解析临时 tokens");
424+
let _ = std::fs::remove_file(&path);
425+
// Assert:单字符列收集;多字符符号不收集
426+
assert!(set.contains(&'a') && set.contains(&'b') && set.contains(&'焦') && set.contains(&'▁'));
427+
assert_eq!(set.len(), 4);
428+
}

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,38 @@ pub fn normalize_source_title(raw: &str) -> String {
3030
trimmed.to_string()
3131
}
3232

33+
/// 标题 → 热词候选(REQ-266 注入源扩展,纯函数)。
34+
///
35+
/// @ai-context: 采集目标窗口标题携带课程/主题专名("项目从立项到交付全流程|落地
36+
/// 式项目全流程 - 抖音")——按分隔符切 token、剔除平台品牌与纯数字、
37+
/// 长度 2..=14 字符,去重限量(≤10),供 preheat 注入;tokens 表外字
38+
/// 风险由 use-time 过滤兜底(streaming_asr filter_hotwords_by_tokens)。
39+
pub fn title_hotword_candidates(raw: &str) -> Vec<String> {
40+
const SEPARATORS: &[char] = &['|', '|', '-', '_', ' ', '/', '·', ':', ':'];
41+
let normalized = normalize_source_title(raw);
42+
let mut out: Vec<String> = Vec::new();
43+
for tok in normalized.split(SEPARATORS) {
44+
let tok = tok.trim();
45+
let n = tok.chars().count();
46+
if !(2..=14).contains(&n) {
47+
continue;
48+
}
49+
if tok.chars().all(|c| c.is_ascii_digit()) {
50+
continue; // 纯数字(年份/计数)无术语价值
51+
}
52+
if BRAND_SUFFIXES.iter().any(|b| tok == *b) {
53+
continue; // 平台品牌名不入词表
54+
}
55+
if !out.iter().any(|t| t == tok) {
56+
out.push(tok.to_string());
57+
}
58+
if out.len() >= 10 {
59+
break;
60+
}
61+
}
62+
out
63+
}
64+
3365
/// 同源去重:existing 含 base → 追加「 #N」(N=现有最大后缀 +1,首个为 #2)。
3466
///
3567
/// @ai-context: 纯比较不猜测——只处理「恰好等于 base」与「base #N」两类;
@@ -163,4 +195,32 @@ mod tests {
163195
);
164196
assert_eq!(first_line_title(std::iter::empty::<&str>()), None);
165197
}
198+
199+
// ── title_hotword_candidates(REQ-266)──
200+
201+
#[test]
202+
fn title_candidates_split_and_strip_brand() {
203+
// "项目从立项到交付全流程|落地式项目全流程 - 抖音" → 两段主题词;品牌剔除
204+
let cs = title_hotword_candidates("项目从立项到交付全流程|落地式项目全流程 - 抖音");
205+
assert!(cs.contains(&"项目从立项到交付全流程".to_string()));
206+
assert!(cs.contains(&"落地式项目全流程".to_string()));
207+
assert!(!cs.iter().any(|t| t == "抖音"));
208+
}
209+
210+
#[test]
211+
fn title_candidates_bare_brand_and_tiny_empty() {
212+
assert!(title_hotword_candidates("抖音").is_empty());
213+
assert!(title_hotword_candidates("").is_empty());
214+
assert!(title_hotword_candidates("a").is_empty());
215+
assert!(title_hotword_candidates("2024").is_empty());
216+
}
217+
218+
#[test]
219+
fn title_candidates_dedupe_and_cap() {
220+
// 重复 token 只取一次;超出 10 个截断不 panic
221+
let many = "词一 词二 词三 词四 词五 词六 词七 词八 词九 词十 词十一 词一";
222+
let cs = title_hotword_candidates(many);
223+
assert!(cs.len() <= 10);
224+
assert_eq!(cs.iter().filter(|t| *t == "词一").count(), 1);
225+
}
166226
}

0 commit comments

Comments
 (0)