Skip to content

Commit 38557f8

Browse files
committed
refactor(rust): 拆 note_filter 第 2 步 — 净化纯函数族
1 parent 368b2d8 commit 38557f8

3 files changed

Lines changed: 144 additions & 112 deletions

File tree

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

Lines changed: 9 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ use crate::purify_config::PurifyConfig;
3333
use crate::symbol_normalize::SymbolNormalizeConfig;
3434
use crate::types::{SessionOcrBlock, SessionScreen, SessionSegment};
3535
use crate::ui_junk::UiJunkList;
36-
use crate::verbal_normalize::{NormalizeConfig, NormalizeStrength};
3736

3837
// ────────────────────────────────────────────────────────────
3938
// 拆分子模块(AGENTS.md §3:单文件 ≤300 行)——父模块**自己**用 #[path] 声明,
@@ -45,10 +44,19 @@ use crate::verbal_normalize::{NormalizeConfig, NormalizeStrength};
4544
#[path = "note_filter_render.rs"]
4645
mod render;
4746

47+
/// 净化纯函数族 + 口头禅词表 + CJK 判定(过滤链与 note_filter_ai 共用)。
48+
#[path = "note_filter_purify.rs"]
49+
mod purify;
50+
4851
/// 再导出:可见性与项 **1:1**(`pub` 项用 `pub use`、`pub(crate)` 项用
4952
/// `pub(crate) use`——后者写成 `pub use` 会触发 E0365,项本身不允许外泄)。
5053
pub use self::render::{refresh_screen_points, render_screen_points};
5154
pub(crate) use self::render::{apply_session_warning, rebuild_markdown};
55+
pub(crate) use self::purify::FILLER_WORDS;
56+
57+
// 净化族(本步起 filter_note_transcript 仍在主文件 ⇒ 直接引入;S3 搬出后本行
58+
// 随之下线,否则触发 unused_imports)。
59+
use self::purify::{concat_transcript, is_filler_only, is_fragment, is_purified_empty, purify_segment};
5260

5361
/// 净化环境(依赖注入聚合——净化配置 + 符号映射 + OCR 纠错表)。
5462
///
@@ -67,18 +75,6 @@ pub struct PurifyEnv {
6775
/// 生成";净化链每次规则变更递增;v0.7.6 结构渲染层接入 REQ-177~181)。
6876
pub const RULE_VERSION: &str = "note-rules-0.7.6";
6977

70-
/// 口头禅词集(REQ-163 删除判定 + REQ-085 AI Filler 候选共用)。
71-
///
72-
/// @ai-context: v0.7.5 扩展(与 verbal_normalize 词表对齐——「大家知道吗/
73-
/// 咱们/我们看」等口语高频词此前只在实时路径被清,笔记路径漏网);
74-
/// 短段全由这些词组成 → 规则级删除;"对"单字不删(回应语义,
75-
/// 且碎片规则已按 ≤2 字处理——验收口径)。
76-
pub(crate) const FILLER_WORDS: &[&str] = &[
77-
"嗯", "啊", "呃", "哦", "诶", "哎", "哈", "嗯嗯", "哈哈", "好的", "对", "那个", "这个",
78-
"就是", "然后", "对吧", "是吧", "对不对", "对不对啊", "好不好", "就是说", "然后呢",
79-
"你们知道吗", "大家知道吗", "大家注意", "大家看", "咱们", "我们看", "我们来看", "接下来呢",
80-
];
81-
8278
/// 被过滤原因。
8379
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8480
#[serde(rename_all = "kebab-case")]
@@ -413,104 +409,6 @@ fn filter_note_empty(title: &str) -> NoteFilterResult {
413409
}
414410
}
415411

416-
/// 单段口语净化(纯函数):结巴折叠 → 书面化(保守档 Light)→ 符号规范化 →
417-
/// 术语替换;返回净化后文本(统计计数由调用方入参累加)。
418-
///
419-
/// @ai-context: 顺序契约(会话31 实证驱动):**折叠必须先于书面化**——verbal
420-
/// compress_repeats 会把"甲甲甲"先压成"甲甲"(2 连短语重复),
421-
/// 折叠规则(≥3 连同字)随后不再命中,结巴残留(「甲甲甲」→「甲」
422-
/// 验收不达标);折叠在前则 3 连先收拢、书面化不再误动。
423-
fn purify_segment(
424-
text: &str,
425-
config: &PurifyConfig,
426-
symbol_cfg: &SymbolNormalizeConfig,
427-
stats: &mut FilterStats,
428-
) -> String {
429-
let mut out = text.to_string();
430-
let changed = |before: &str, after: &str| before != after;
431-
let before = out.clone();
432-
let mut fold_hit = false;
433-
if config.stutter_fold {
434-
let folded = crate::stutter_fold::fold_stutter(&out);
435-
fold_hit = folded != out;
436-
out = folded;
437-
}
438-
if config.verbal_normalize {
439-
let vcfg = NormalizeConfig { strength: NormalizeStrength::Light };
440-
out = crate::verbal_normalize::normalize(&out, &vcfg);
441-
}
442-
if config.symbol_normalize {
443-
out = crate::symbol_normalize::normalize(&out, symbol_cfg);
444-
}
445-
let mut term_hit = false;
446-
if config.term_replace {
447-
let replaced = crate::stutter_fold::apply_term_replacements(&out);
448-
term_hit = replaced != out;
449-
out = replaced;
450-
}
451-
if changed(&before, &out) {
452-
stats.verbal += 1;
453-
}
454-
if fold_hit {
455-
stats.stutter += 1;
456-
}
457-
if term_hit {
458-
stats.term_replace += 1;
459-
}
460-
out
461-
}
462-
463-
/// 净化残留判定(纯函数):空串 / 纯符号(无字母数字汉字)→ 无信息内容。
464-
fn is_purified_empty(text: &str) -> bool {
465-
let t = text.trim();
466-
t.is_empty() || t.chars().all(|c| !c.is_alphanumeric() && !is_cjk(c))
467-
}
468-
469-
/// 口头禅短段判定(纯函数,REQ-163):去首尾标点后 ≤filler_max_chars 字且
470-
/// 全部空白分隔 token ∈ 口头禅词表 → 删除候选。
471-
///
472-
/// @ai-context: "对不对?"→去"?"→"对不对" ✓;"对"单字 <2 不删(回应语义);
473-
/// "3.14"数字不删(非口头禅词);"你说得对"含"你说得"不删。
474-
fn is_filler_only(text: &str, config: &PurifyConfig) -> bool {
475-
let stripped: String = text
476-
.trim()
477-
.trim_matches(|c: char| c.is_ascii_punctuation() || "。!?,、;:…·".contains(c))
478-
.to_string();
479-
let chars = stripped.chars().count();
480-
if chars < 2 || chars > config.filler_max_chars {
481-
return false;
482-
}
483-
let tokens: Vec<&str> = stripped.split_whitespace().collect();
484-
!tokens.is_empty() && tokens.iter().all(|t| FILLER_WORDS.contains(t))
485-
}
486-
487-
/// 保留段转写文本拼接(供 OCR 共现校验/纠错——画面词与讲述词互证)。
488-
fn concat_transcript(kept: &[SessionSegment]) -> String {
489-
let mut out = String::new();
490-
for s in kept {
491-
out.push_str(&s.text);
492-
out.push(' ');
493-
}
494-
out
495-
}
496-
497-
/// 碎片段判定(纯函数):≤2 字 / 时长 <500ms / 纯符号(阈值可配置 REQ-173)。
498-
///
499-
/// @ai-context: 纯符号 = 无字母数字汉字("----/···");"3.14/2024" 含数字
500-
/// 不算纯符号——误杀保护(数字内容不误删)。
501-
fn is_fragment(seg: &SessionSegment, config: &PurifyConfig) -> bool {
502-
let text = seg.text.trim();
503-
text.chars().count() <= config.fragment_max_chars
504-
|| seg.end_ms.saturating_sub(seg.start_ms) < config.fragment_min_duration_ms
505-
|| text.chars().all(|c| !c.is_alphanumeric() && !is_cjk(c))
506-
}
507-
508-
/// CJK 统一表意文字区段(含扩展 A)。
509-
fn is_cjk(c: char) -> bool {
510-
let u = c as u32;
511-
(0x4E00..=0x9FFF).contains(&u) || (0x3400..=0x4DBF).contains(&u)
512-
}
513-
514412
// ────────────────────────────────────────────────────────────
515413
// REQ-085:AI 复核(已拆至 note_filter_ai.rs——登记拆分计划落地;
516414
// 公共 API 再导出保持对外兼容)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
//! @ai-context: 笔记过滤域的**净化纯函数族 + 口头禅词表 + CJK 判定**(批 0-C3 Task 8
2+
//! S2 从 note_filter.rs 平铺拆出,AGENTS.md §3 单文件 ≤300 行)——被过滤链
3+
//! (note_filter_chain::filter_note_transcript)与 note_filter_ai 消费。
4+
//! @ai-context: 副作用:**全部是纯函数**(无 IO / 无 DB / 无锁 / 无全局态);唯一可变
5+
//! 写入是 `purify_segment` 经 `&mut FilterStats` 累加计数(统计口径 = 语义,
6+
//! `stats.verbal`/`stutter`/`term_replace` 三处计数位置不可合并)。
7+
//! @ai-context: 边界(**顺序契约,逐字搬运不改**):`purify_segment` 内部四步
8+
//! **折叠必须先于书面化**——verbal_normalize::compress_repeats 会把"甲甲甲"
9+
//! 先压成"甲甲",折叠规则(≥3 连同字)随后不再命中,结巴残留(会话31 实证)。
10+
//! @ai-context: 边界(既有缺陷,只搬不改):`is_purified_empty`/`is_fragment` 里
11+
//! `!c.is_alphanumeric() && !is_cjk(c)` 的 `is_cjk` 在语义上被
12+
//! `is_alphanumeric` 覆盖(CJK 属 Unicode Alphabetic)⇒ 冗余但无害;
13+
//! 改成 `is_ascii_alphanumeric` 或删 `is_cjk` 会改变全角数字/字母/假名判定。
14+
//! `concat_transcript` 每段后**无条件**追加半角空格(含末段)。
15+
//! `is_filler_only` 的手写全角标点集 `"。!?,、;:…·"` 一个字不能动。
16+
//! @ai-context: 可见性:5 个函数放宽为 `pub(crate)`(B→C 方向调用,9 个私有 fn 零直测
17+
//! ⇒ 放宽不改变任何测试);`is_cjk` 保持私有(唯一消费者同在本模块)。
18+
19+
use crate::purify_config::PurifyConfig;
20+
use crate::symbol_normalize::SymbolNormalizeConfig;
21+
use crate::types::SessionSegment;
22+
use crate::verbal_normalize::{NormalizeConfig, NormalizeStrength};
23+
24+
use super::FilterStats;
25+
26+
/// 口头禅词集(REQ-163 删除判定 + REQ-085 AI Filler 候选共用)。
27+
///
28+
/// @ai-context: v0.7.5 扩展(与 verbal_normalize 词表对齐——「大家知道吗/
29+
/// 咱们/我们看」等口语高频词此前只在实时路径被清,笔记路径漏网);
30+
/// 短段全由这些词组成 → 规则级删除;"对"单字不删(回应语义,
31+
/// 且碎片规则已按 ≤2 字处理——验收口径)。
32+
pub(crate) const FILLER_WORDS: &[&str] = &[
33+
"嗯", "啊", "呃", "哦", "诶", "哎", "哈", "嗯嗯", "哈哈", "好的", "对", "那个", "这个",
34+
"就是", "然后", "对吧", "是吧", "对不对", "对不对啊", "好不好", "就是说", "然后呢",
35+
"你们知道吗", "大家知道吗", "大家注意", "大家看", "咱们", "我们看", "我们来看", "接下来呢",
36+
];
37+
38+
/// 单段口语净化(纯函数):结巴折叠 → 书面化(保守档 Light)→ 符号规范化 →
39+
/// 术语替换;返回净化后文本(统计计数由调用方入参累加)。
40+
///
41+
/// @ai-context: 顺序契约(会话31 实证驱动):**折叠必须先于书面化**——verbal
42+
/// compress_repeats 会把"甲甲甲"先压成"甲甲"(2 连短语重复),
43+
/// 折叠规则(≥3 连同字)随后不再命中,结巴残留(「甲甲甲」→「甲」
44+
/// 验收不达标);折叠在前则 3 连先收拢、书面化不再误动。
45+
pub(crate) fn purify_segment(
46+
text: &str,
47+
config: &PurifyConfig,
48+
symbol_cfg: &SymbolNormalizeConfig,
49+
stats: &mut FilterStats,
50+
) -> String {
51+
let mut out = text.to_string();
52+
let changed = |before: &str, after: &str| before != after;
53+
let before = out.clone();
54+
let mut fold_hit = false;
55+
if config.stutter_fold {
56+
let folded = crate::stutter_fold::fold_stutter(&out);
57+
fold_hit = folded != out;
58+
out = folded;
59+
}
60+
if config.verbal_normalize {
61+
let vcfg = NormalizeConfig { strength: NormalizeStrength::Light };
62+
out = crate::verbal_normalize::normalize(&out, &vcfg);
63+
}
64+
if config.symbol_normalize {
65+
out = crate::symbol_normalize::normalize(&out, symbol_cfg);
66+
}
67+
let mut term_hit = false;
68+
if config.term_replace {
69+
let replaced = crate::stutter_fold::apply_term_replacements(&out);
70+
term_hit = replaced != out;
71+
out = replaced;
72+
}
73+
if changed(&before, &out) {
74+
stats.verbal += 1;
75+
}
76+
if fold_hit {
77+
stats.stutter += 1;
78+
}
79+
if term_hit {
80+
stats.term_replace += 1;
81+
}
82+
out
83+
}
84+
85+
/// 净化残留判定(纯函数):空串 / 纯符号(无字母数字汉字)→ 无信息内容。
86+
pub(crate) fn is_purified_empty(text: &str) -> bool {
87+
let t = text.trim();
88+
t.is_empty() || t.chars().all(|c| !c.is_alphanumeric() && !is_cjk(c))
89+
}
90+
91+
/// 口头禅短段判定(纯函数,REQ-163):去首尾标点后 ≤filler_max_chars 字且
92+
/// 全部空白分隔 token ∈ 口头禅词表 → 删除候选。
93+
///
94+
/// @ai-context: "对不对?"→去"?"→"对不对" ✓;"对"单字 <2 不删(回应语义);
95+
/// "3.14"数字不删(非口头禅词);"你说得对"含"你说得"不删。
96+
pub(crate) fn is_filler_only(text: &str, config: &PurifyConfig) -> bool {
97+
let stripped: String = text
98+
.trim()
99+
.trim_matches(|c: char| c.is_ascii_punctuation() || "。!?,、;:…·".contains(c))
100+
.to_string();
101+
let chars = stripped.chars().count();
102+
if chars < 2 || chars > config.filler_max_chars {
103+
return false;
104+
}
105+
let tokens: Vec<&str> = stripped.split_whitespace().collect();
106+
!tokens.is_empty() && tokens.iter().all(|t| FILLER_WORDS.contains(t))
107+
}
108+
109+
/// 保留段转写文本拼接(供 OCR 共现校验/纠错——画面词与讲述词互证)。
110+
pub(crate) fn concat_transcript(kept: &[SessionSegment]) -> String {
111+
let mut out = String::new();
112+
for s in kept {
113+
out.push_str(&s.text);
114+
out.push(' ');
115+
}
116+
out
117+
}
118+
119+
/// 碎片段判定(纯函数):≤2 字 / 时长 <500ms / 纯符号(阈值可配置 REQ-173)。
120+
///
121+
/// @ai-context: 纯符号 = 无字母数字汉字("----/···");"3.14/2024" 含数字
122+
/// 不算纯符号——误杀保护(数字内容不误删)。
123+
pub(crate) fn is_fragment(seg: &SessionSegment, config: &PurifyConfig) -> bool {
124+
let text = seg.text.trim();
125+
text.chars().count() <= config.fragment_max_chars
126+
|| seg.end_ms.saturating_sub(seg.start_ms) < config.fragment_min_duration_ms
127+
|| text.chars().all(|c| !c.is_alphanumeric() && !is_cjk(c))
128+
}
129+
130+
/// CJK 统一表意文字区段(含扩展 A)。
131+
fn is_cjk(c: char) -> bool {
132+
let u = c as u32;
133+
(0x4E00..=0x9FFF).contains(&u) || (0x3400..=0x4DBF).contains(&u)
134+
}

‎docs/standards/line-limit-exemptions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
| app/src-tauri/src/screens_tests.rs | 433 | 画面要点屏构建测试域(分组/聚类/图匹配/可消费块过滤回归)单模块 #[path] 挂载 | 若再增长:可消费块过滤组拆至 screens_filter_tests.rs |
6262
| app/src-tauri/src/note_filter_golden_tests.rs | 431 | v0.7.5(REQ-172):黄金语料回归域(会话31/29 实证 + 结构渲染 2 例 + 审查补测)单模块 #[path] 挂载 | 若再增长:结构渲染组拆至 note_filter_golden_structure_tests.rs |
6363
| app/src-tauri/src/db_note_groups_tests.rs | 426 | v0.11.0 组数据层测试域 + v0.14.1 删除/影响面用例(级联归零/悬空清理/迁移回归)——单模块 #[path] 挂载(video_profile_tests.rs 同款先例),组删除语义变更集中验证 | 若再增长:删除/影响面组拆至 db_note_groups_delete_tests.rs |
64-
| app/src-tauri/src/note_filter.rs | 426 | **拆分进行中(批 0-C3 Task 8 S1/3)**:渲染层已拆至 note_filter_render.rs,本行行数每步由 `--write` 刷新 —— v0.6.0 M1(REQ-082/085):笔记过滤域(过滤链 + AI 判定应用 + 画面要点净化)内聚于单一管线(双出口一致性由构造保证);AI 部分已按登记计划拆至 note_filter_ai.rs | 拆分进行中:S2 净化纯函数族拆至 note_filter_purify.rs · S3 转写段过滤链拆至 note_filter_chain.rs(最终 ≤300 ⇒ 本条目整行移除) |
64+
| app/src-tauri/src/note_filter.rs | 426 | **拆分进行中(批 0-C3 Task 8 S2/3)**:渲染层已拆至 note_filter_render.rs、净化纯函数族已拆至 note_filter_purify.rs,本行行数每步由 `--write` 刷新 —— v0.6.0 M1(REQ-082/085):笔记过滤域(过滤链 + AI 判定应用 + 画面要点净化)内聚于单一管线(双出口一致性由构造保证);AI 部分已按登记计划拆至 note_filter_ai.rs | 拆分进行中:S3 转写段过滤链拆至 note_filter_chain.rs(最终 ≤300 ⇒ 本条目整行移除) |
6565
| app/src-tauri/src/analysis.rs | 423 | v0.5.0 M2 起结构化分析编排域(章节/重点/术语/讲者 + 事件消费 + step_boundaries/practice_segments/player_actions 三字段 + 审查修复按类型判定);各机制输出聚合内聚于单一分析函数 | 若再增长:build_chapter_signals 事件版拆至 analysis_signals.rs |
6666
| app/src-tauri/src/artifact_templates_tests.rs | 423 | 产物模板测试域(五档案模板 + 代码块/步骤卡扩展 + 叙事变体 golden)单模块 #[path] 挂载 | 若再增长:代码块/步骤卡组拆至 artifact_code_tests.rs |
6767
| app/src-tauri/src/app_setup.rs | 420 | 本模块承载 setup 的 AppState 初始化(数据目录/DB/引擎池/可校准(自动摘取,待细化) | 若再增长:按职责拆分 |

0 commit comments

Comments
 (0)