Skip to content

Commit 1b24168

Browse files
committed
fix(notes): 清偿TD-B——术语锚点词边界+大小写折叠匹配
1 parent f8be4d3 commit 1b24168

2 files changed

Lines changed: 86 additions & 2 deletions

File tree

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,47 @@ fn chapter_name(
179179
}
180180

181181
/// 术语在 kept 段中首次出现时刻(纯函数;无命中 → None——不带锚点不丢行)。
182+
///
183+
/// @ai-context: TD-2026-08-20-B:大小写折叠 + 词边界匹配——短术语(如 "AI")
184+
/// 不再锚到 "AIR"/"said" 等无关子串;中文子串语义不受影响
185+
/// (边界只约束 ASCII 字母数字,汉字前后不设限——中文无词边界)。
182186
fn first_occurrence_ms(kept: &[crate::types::SessionSegment], term: &str) -> Option<u64> {
187+
let term_lower = term.to_lowercase();
183188
kept.iter()
184-
.find(|s| s.text.contains(term))
189+
.find(|s| word_boundary_contains(&s.text, &term_lower))
185190
.map(|s| s.start_ms)
186191
}
187192

193+
/// 词边界包含(纯函数):term 在 text 中出现(大小写不敏感),且命中位置
194+
/// 前后均非 ASCII 字母数字。
195+
///
196+
/// @ai-context: 只检查单字节(UTF-8 多字节字符的高位字节恒非 ASCII 字母数字,
197+
/// 汉字/标点天然通过);find 偏移恒为合法字符边界,无切片 panic。
198+
/// @ai-context: 内部自行大小写折叠(调用方不须预 lower——TD-B 审查后健壮化)。
199+
fn word_boundary_contains(text: &str, term: &str) -> bool {
200+
if term.is_empty() {
201+
return true;
202+
}
203+
let term_lower = term.to_lowercase();
204+
let lower = text.to_lowercase();
205+
let bytes = lower.as_bytes();
206+
let mut start = 0;
207+
while let Some(rel) = lower[start..].find(&term_lower) {
208+
let pos = start + rel;
209+
let before_ok = pos == 0 || !bytes[pos - 1].is_ascii_alphanumeric();
210+
let after = pos + term_lower.len();
211+
let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric();
212+
if before_ok && after_ok {
213+
return true;
214+
}
215+
start = pos + 1;
216+
if start >= bytes.len() {
217+
break;
218+
}
219+
}
220+
false
221+
}
222+
188223
#[cfg(test)]
189224
#[path = "structure_note_tests.rs"]
190225
mod tests;

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

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::glossary::GlossaryCandidate;
99
use crate::note_filter::{FilterStats, NoteFilterResult};
1010
use crate::outline::OutlineEntry;
1111
use crate::purify_config::PurifyConfig;
12-
use crate::structure_note::{render_note_structure, NoteStructureConfig};
12+
use crate::structure_note::{render_note_structure, word_boundary_contains, NoteStructureConfig};
1313
use crate::types::SessionSegment;
1414

1515
/// 构造会话段(净化后保留段形态)。
@@ -413,3 +413,52 @@ fn structure_config_roundtrips_json() {
413413
assert!(parsed.structure.chapter_headings);
414414
assert!(parsed.structure.glossary_block);
415415
}
416+
417+
// TD-2026-08-20-B:词边界 + 大小写折叠(词边界匹配直接单测 + 经 first_occurrence_ms 集成)
418+
419+
#[test]
420+
fn word_boundary_rejects_short_term_inside_longer_word() {
421+
// Arrange/Act/Assert:短术语 "AI" 不得锚到 "AIR"/"said" 等子串
422+
assert!(!word_boundary_contains("AIR 飞行指南", "ai"));
423+
assert!(!word_boundary_contains("saidthings", "said")); // 折叠后仍在词内 → 拒绝
424+
assert!(word_boundary_contains("AI 时代来了", "ai"));
425+
assert!(word_boundary_contains("he said it", "said"));
426+
}
427+
428+
#[test]
429+
fn word_boundary_case_insensitive() {
430+
// Act/Assert:大小写折叠——"OCR" 命中 "ocr 引擎"
431+
assert!(word_boundary_contains("OCR 引擎已加载", "ocr"));
432+
assert!(word_boundary_contains("OCR 引擎已加载", "OCR"));
433+
assert!(!word_boundary_contains("SOCRATES 哲学", "ocr")); // 折叠后仍在词内 → 拒绝
434+
}
435+
436+
#[test]
437+
fn word_boundary_chinese_substring_unaffected() {
438+
// Act/Assert:汉字前后不设边界(中文无词边界概念)——"项目" 命中 "项目管理"
439+
assert!(word_boundary_contains("项目管理很关键", "项目"));
440+
assert!(word_boundary_contains("敏捷开发", "开发"));
441+
}
442+
443+
#[test]
444+
fn word_boundary_empty_term_and_edge_positions() {
445+
// Act/Assert:空术语防御性命中;串首/串尾边界成立
446+
assert!(word_boundary_contains("任意文本", ""));
447+
assert!(word_boundary_contains("AI起步", "ai"));
448+
assert!(word_boundary_contains("起步AI", "ai"));
449+
}
450+
451+
#[test]
452+
fn first_occurrence_anchor_respects_word_boundary() {
453+
// Arrange:段文本含 "AI 提效"(应命中)与 "AIR 质量"(不应命中)
454+
let kept = vec![
455+
seg(1, 1_000, 2_000, "本章讲 AIR 质量模型"),
456+
seg(2, 3_000, 4_000, "AI 提效的三个方法"),
457+
];
458+
459+
// Act:锚点应为第二段
460+
let anchor = super::first_occurrence_ms(&kept, "AI");
461+
462+
// Assert
463+
assert_eq!(anchor, Some(3_000));
464+
}

0 commit comments

Comments
 (0)