Skip to content

Commit d85d9fe

Browse files
committed
refactor(rust): 拆 note_filter 第 3 步 — 转写段过滤链
1 parent b974a19 commit d85d9fe

3 files changed

Lines changed: 248 additions & 213 deletions

File tree

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

Lines changed: 7 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,19 @@ mod render;
4848
#[path = "note_filter_purify.rs"]
4949
mod purify;
5050

51+
/// 转写段正文过滤链(8 阶段单遍 + 跨段后置 pass;判定顺序 = 语义)。
52+
#[path = "note_filter_chain.rs"]
53+
mod chain;
54+
5155
/// 再导出:可见性与项 **1:1**(`pub` 项用 `pub use`、`pub(crate)` 项用
5256
/// `pub(crate) use`——后者写成 `pub use` 会触发 E0365,项本身不允许外泄)。
5357
pub use self::render::{refresh_screen_points, render_screen_points};
5458
pub(crate) use self::render::{apply_session_warning, rebuild_markdown};
5559
pub(crate) use self::purify::FILLER_WORDS;
5660

57-
// 净化族(本步起 filter_note_transcript 仍在主文件 ⇒ 直接引入;S3 搬出后本行
58-
// 随之下线,否则触发 unused_imports)。
59-
use self::purify::{concat_transcript, is_filler_only, is_fragment, is_purified_empty, purify_segment};
61+
// 过滤链入口(模块私有——唯一消费者是下方 filter_note;可见性不放宽,
62+
// 调用点字面量 `filter_note_transcript(...)` 与拆前逐字一致)。
63+
use self::chain::filter_note_transcript;
6064

6165
/// 净化环境(依赖注入聚合——净化配置 + 符号映射 + OCR 纠错表)。
6266
///
@@ -220,174 +224,6 @@ pub fn filter_note(
220224
}
221225
}
222226

223-
/// 转写段正文过滤链(视频会话——既有路径,v0.12.0 提取自原 filter_note 主体,
224-
/// 逻辑零改动;OcrDirect/Empty 分派见 filter_note)。
225-
///
226-
/// @ai-context: 转写段按过滤链处理(见模块头);OCR 画面要点先经
227-
/// screens::filter_usable_blocks(v0.7.5:低分 0.7/单字符/边缘
228-
/// 条带/视频页 UI 共现/错字纠错)排除,再屏构建与精确去重。
229-
fn filter_note_transcript(
230-
title: &str,
231-
segments: &[SessionSegment],
232-
ocr_blocks: &[SessionOcrBlock],
233-
ui_junk: &UiJunkList,
234-
env: &PurifyEnv,
235-
) -> NoteFilterResult {
236-
let config = &env.config;
237-
let symbol_cfg = &env.symbol;
238-
let corrections = &env.corrections;
239-
// ① 转写段过滤链(空文本段跳过;按时间排序保证相邻性)
240-
let mut sorted: Vec<SessionSegment> = segments
241-
.iter()
242-
.filter(|s| !s.text.trim().is_empty())
243-
.cloned()
244-
.collect();
245-
sorted.sort_by_key(|s| (s.start_ms, s.id));
246-
let mut kept: Vec<SessionSegment> = Vec::new();
247-
let mut stats = FilterStats::default();
248-
let mut filtered = Vec::new();
249-
let mut merged = Vec::new();
250-
for mut seg in sorted {
251-
let text = seg.text.trim();
252-
// ① UI 垃圾特征兜底(与 REQ-083 同表——源头漏拦的兜底)
253-
if ui_junk.is_junk(text) {
254-
stats.ui_junk += 1;
255-
filtered.push(FilteredItem {
256-
segment_id: seg.id,
257-
reason: FilterReason::UiJunk,
258-
text: text.to_string(),
259-
start_ms: seg.start_ms,
260-
});
261-
continue;
262-
}
263-
// ② 低置信丢弃(confidence=None 的字幕段跳过——无置信度证据不删)
264-
if seg.confidence.is_some_and(|c| c < config.low_confidence_threshold) {
265-
stats.low_confidence += 1;
266-
filtered.push(FilteredItem {
267-
segment_id: seg.id,
268-
reason: FilterReason::LowConfidence,
269-
text: text.to_string(),
270-
start_ms: seg.start_ms,
271-
});
272-
continue;
273-
}
274-
// ③ 纯过渡短句删除(v0.7.5 扩展:整句 ∈ 精确表——"接下来/我们来看"
275-
// 单独成段无信息;"接下来我们看第三章"不在表内不误杀)。
276-
// 先于碎片检查:表内 2 字短语(首先/总之/好吧)若后置会被碎片规则
277-
// (≤2 字)先删,原因标签失真(过渡原因更准确)
278-
if config.transition_delete
279-
&& crate::note_filter_discourse::is_transition_short(text, config.transition_max_chars)
280-
{
281-
stats.transition += 1;
282-
filtered.push(FilteredItem {
283-
segment_id: seg.id,
284-
reason: FilterReason::Transition,
285-
text: text.to_string(),
286-
start_ms: seg.start_ms,
287-
});
288-
continue;
289-
}
290-
// ④ 口头禅短段规则级删除(REQ-163:免 AI——段 1018「对不对?」类;
291-
// 基于原文判定——净化前形状特征未被破坏)
292-
if config.filler_delete && is_filler_only(text, config) {
293-
stats.filler += 1;
294-
filtered.push(FilteredItem {
295-
segment_id: seg.id,
296-
reason: FilterReason::Filler,
297-
text: text.to_string(),
298-
start_ms: seg.start_ms,
299-
});
300-
continue;
301-
}
302-
// ⑤ 碎片段(≤2 字 / <500ms / 纯符号——"----/···" 等无信息内容)
303-
if is_fragment(&seg, config) {
304-
stats.fragments += 1;
305-
filtered.push(FilteredItem {
306-
segment_id: seg.id,
307-
reason: FilterReason::Fragment,
308-
text: text.to_string(),
309-
start_ms: seg.start_ms,
310-
});
311-
continue;
312-
}
313-
// ⑥ 口语净化(REQ-162/164):书面化(保守档)→ 符号 → 结巴折叠 → 术语替换
314-
let original = seg.text.clone();
315-
let purified = purify_segment(&original, config, symbol_cfg, &mut stats);
316-
// ⑦ 净化残留检查:空/纯符号("对不对?"→"?")/ 短口头禅("哈")→ 删除
317-
if is_purified_empty(&purified) {
318-
stats.filler += 1;
319-
filtered.push(FilteredItem {
320-
segment_id: seg.id,
321-
reason: FilterReason::Filler,
322-
text: original,
323-
start_ms: seg.start_ms,
324-
});
325-
continue;
326-
}
327-
seg.text = purified;
328-
kept.push(seg);
329-
}
330-
// ⑥ 修辞问句删除(v0.7.5 扩展:自问自答——问句核心词在紧邻段复现,
331-
// 会话31「过程是什么?」+「这个过程是制定项目章程」实证;跨段上下文
332-
// 需 kept 全集,故在单遍循环后执行)
333-
if config.rhetorical_delete {
334-
kept = crate::note_filter_discourse::drop_rhetorical_questions(
335-
kept,
336-
config.rhetorical_max_chars,
337-
&mut stats,
338-
&mut filtered,
339-
);
340-
}
341-
// ⑦ 相邻重复段合并(净化后文本精确去重——净化顺序契约;合并延伸 end_ms)
342-
let mut deduped: Vec<SessionSegment> = Vec::new();
343-
for seg in kept {
344-
if let Some(last) = deduped.last_mut() {
345-
if last.text.trim() == seg.text.trim() {
346-
stats.duplicates += 1;
347-
filtered.push(FilteredItem {
348-
segment_id: seg.id,
349-
reason: FilterReason::Duplicate,
350-
text: seg.text.clone(),
351-
start_ms: seg.start_ms,
352-
});
353-
merged.push(MergedItem {
354-
segment_id: seg.id,
355-
into_segment_id: last.id,
356-
text: seg.text.clone(),
357-
start_ms: seg.start_ms,
358-
});
359-
last.end_ms = last.end_ms.max(seg.end_ms);
360-
continue;
361-
}
362-
}
363-
deduped.push(seg);
364-
}
365-
let kept = deduped;
366-
// 画面要点(v0.7.3 REQ-160:可消费块过滤 → 屏构建 → 屏段落渲染;
367-
// v0.7.5:过滤含单字符/边缘条带/视频页共现/错字纠错——见 screens.rs)
368-
let transcript = concat_transcript(&kept);
369-
let (usable, ocr_corrected) =
370-
crate::screens::filter_usable_blocks(ocr_blocks, ui_junk, config, &transcript, corrections);
371-
stats.ocr_corrected = ocr_corrected;
372-
let ocr_screens = crate::screens::build_screens(&usable, None);
373-
let ocr_points = render_screen_points(&ocr_screens);
374-
let markdown = rebuild_markdown(title, &kept, &[], config, None);
375-
NoteFilterResult {
376-
title: title.to_string(),
377-
markdown,
378-
kept,
379-
ocr_points,
380-
ocr_screens,
381-
stats,
382-
filtered,
383-
merged,
384-
purify: config.clone(),
385-
warning: None,
386-
body_source: BodySource::Transcript,
387-
ocr_body: Vec::new(),
388-
}
389-
}
390-
391227
/// 空正文过滤链(纯函数):无转写段且无可用 OCR 块 → 标题仅 markdown。
392228
///
393229
/// @ai-context: 不 panic 契约(图文会话无内容时转笔记的诚实降级——标题
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
//! @ai-context: 笔记过滤域的**转写段正文过滤链**(批 0-C3 Task 8 S3 从 note_filter.rs
2+
//! 平铺拆出,AGENTS.md §3 单文件 ≤300 行)——视频会话的既有路径,
3+
//! v0.12.0(ADR-021)提取自原 filter_note 主体,**函数体逐字节未改**。
4+
//! @ai-context: 副作用:无 IO / 无 DB / 无锁 —— 纯内存文本管线(输入 &[SessionSegment]
5+
//! + &[SessionOcrBlock],输出 NoteFilterResult);唯一可变写入是
6+
//! &mut FilterStats 计数与 seg.text 净化回写。
7+
//! @ai-context: 边界(**判定顺序 = 语义,任何重排都会静默改变输出文本**):
8+
//! 单遍 6 规则顺序固定 ①ui_junk → ②低置信 → ③过渡短句 → ④口头禅 →
9+
//! ⑤碎片 → ⑥净化残留;③ 必须先于 ⑤(表内 2 字短语否则被碎片规则先删、
10+
//! reason 从 Transition 变 Fragment);⑥ 净化必须先于 ⑧ 相邻去重
11+
//! (净化顺序契约:"嗯…嗯" 与 "嗯" 才判为重复);⑦ 修辞问句是**跨段后置
12+
//! pass**(依赖 kept 全集,不可挪进循环);⑧ 合并延伸 `last.end_ms = max`。
13+
//! `stats.filler += 1` 出现两处(④ 口癖命中 / ⑥ 净化残留)——语义不同,
14+
//! 合并即错。
15+
//! @ai-context: 边界(既有缺陷,只搬不改):本函数经 `concat_transcript` 传给
16+
//! screens::filter_usable_blocks 的共现文本**末段带尾随半角空格**。
17+
//! @ai-context: 可见性:`pub(crate)`(主文件 filter_note 的同分录调用点需要)——
18+
//! 9 个私有 fn 零直测,放宽不改变任何测试。
19+
20+
use crate::note_body_source::BodySource;
21+
use crate::types::{SessionOcrBlock, SessionSegment};
22+
use crate::ui_junk::UiJunkList;
23+
24+
use super::purify::{concat_transcript, is_filler_only, is_fragment, is_purified_empty, purify_segment};
25+
use super::render::rebuild_markdown;
26+
// `render_screen_points` 经**父模块的再导出**消费(拆前 `crate::note_filter::render_screen_points`
27+
// 就是过滤链的入口)——保持对外名字可达,不新增平行路径、也不产生 unused import。
28+
use super::{
29+
render_screen_points, FilterStats, FilteredItem, FilterReason, MergedItem, NoteFilterResult,
30+
PurifyEnv,
31+
};
32+
33+
/// 转写段正文过滤链(视频会话——既有路径,v0.12.0 提取自原 filter_note 主体,
34+
/// 逻辑零改动;OcrDirect/Empty 分派见 filter_note)。
35+
///
36+
/// @ai-context: 转写段按过滤链处理(见模块头);OCR 画面要点先经
37+
/// screens::filter_usable_blocks(v0.7.5:低分 0.7/单字符/边缘
38+
/// 条带/视频页 UI 共现/错字纠错)排除,再屏构建与精确去重。
39+
pub(crate) fn filter_note_transcript(
40+
title: &str,
41+
segments: &[SessionSegment],
42+
ocr_blocks: &[SessionOcrBlock],
43+
ui_junk: &UiJunkList,
44+
env: &PurifyEnv,
45+
) -> NoteFilterResult {
46+
let config = &env.config;
47+
let symbol_cfg = &env.symbol;
48+
let corrections = &env.corrections;
49+
// ① 转写段过滤链(空文本段跳过;按时间排序保证相邻性)
50+
let mut sorted: Vec<SessionSegment> = segments
51+
.iter()
52+
.filter(|s| !s.text.trim().is_empty())
53+
.cloned()
54+
.collect();
55+
sorted.sort_by_key(|s| (s.start_ms, s.id));
56+
let mut kept: Vec<SessionSegment> = Vec::new();
57+
let mut stats = FilterStats::default();
58+
let mut filtered = Vec::new();
59+
let mut merged = Vec::new();
60+
for mut seg in sorted {
61+
let text = seg.text.trim();
62+
// ① UI 垃圾特征兜底(与 REQ-083 同表——源头漏拦的兜底)
63+
if ui_junk.is_junk(text) {
64+
stats.ui_junk += 1;
65+
filtered.push(FilteredItem {
66+
segment_id: seg.id,
67+
reason: FilterReason::UiJunk,
68+
text: text.to_string(),
69+
start_ms: seg.start_ms,
70+
});
71+
continue;
72+
}
73+
// ② 低置信丢弃(confidence=None 的字幕段跳过——无置信度证据不删)
74+
if seg.confidence.is_some_and(|c| c < config.low_confidence_threshold) {
75+
stats.low_confidence += 1;
76+
filtered.push(FilteredItem {
77+
segment_id: seg.id,
78+
reason: FilterReason::LowConfidence,
79+
text: text.to_string(),
80+
start_ms: seg.start_ms,
81+
});
82+
continue;
83+
}
84+
// ③ 纯过渡短句删除(v0.7.5 扩展:整句 ∈ 精确表——"接下来/我们来看"
85+
// 单独成段无信息;"接下来我们看第三章"不在表内不误杀)。
86+
// 先于碎片检查:表内 2 字短语(首先/总之/好吧)若后置会被碎片规则
87+
// (≤2 字)先删,原因标签失真(过渡原因更准确)
88+
if config.transition_delete
89+
&& crate::note_filter_discourse::is_transition_short(text, config.transition_max_chars)
90+
{
91+
stats.transition += 1;
92+
filtered.push(FilteredItem {
93+
segment_id: seg.id,
94+
reason: FilterReason::Transition,
95+
text: text.to_string(),
96+
start_ms: seg.start_ms,
97+
});
98+
continue;
99+
}
100+
// ④ 口头禅短段规则级删除(REQ-163:免 AI——段 1018「对不对?」类;
101+
// 基于原文判定——净化前形状特征未被破坏)
102+
if config.filler_delete && is_filler_only(text, config) {
103+
stats.filler += 1;
104+
filtered.push(FilteredItem {
105+
segment_id: seg.id,
106+
reason: FilterReason::Filler,
107+
text: text.to_string(),
108+
start_ms: seg.start_ms,
109+
});
110+
continue;
111+
}
112+
// ⑤ 碎片段(≤2 字 / <500ms / 纯符号——"----/···" 等无信息内容)
113+
if is_fragment(&seg, config) {
114+
stats.fragments += 1;
115+
filtered.push(FilteredItem {
116+
segment_id: seg.id,
117+
reason: FilterReason::Fragment,
118+
text: text.to_string(),
119+
start_ms: seg.start_ms,
120+
});
121+
continue;
122+
}
123+
// ⑥ 口语净化(REQ-162/164):书面化(保守档)→ 符号 → 结巴折叠 → 术语替换
124+
let original = seg.text.clone();
125+
let purified = purify_segment(&original, config, symbol_cfg, &mut stats);
126+
// ⑦ 净化残留检查:空/纯符号("对不对?"→"?")/ 短口头禅("哈")→ 删除
127+
if is_purified_empty(&purified) {
128+
stats.filler += 1;
129+
filtered.push(FilteredItem {
130+
segment_id: seg.id,
131+
reason: FilterReason::Filler,
132+
text: original,
133+
start_ms: seg.start_ms,
134+
});
135+
continue;
136+
}
137+
seg.text = purified;
138+
kept.push(seg);
139+
}
140+
// ⑥ 修辞问句删除(v0.7.5 扩展:自问自答——问句核心词在紧邻段复现,
141+
// 会话31「过程是什么?」+「这个过程是制定项目章程」实证;跨段上下文
142+
// 需 kept 全集,故在单遍循环后执行)
143+
if config.rhetorical_delete {
144+
kept = crate::note_filter_discourse::drop_rhetorical_questions(
145+
kept,
146+
config.rhetorical_max_chars,
147+
&mut stats,
148+
&mut filtered,
149+
);
150+
}
151+
// ⑦ 相邻重复段合并(净化后文本精确去重——净化顺序契约;合并延伸 end_ms)
152+
let mut deduped: Vec<SessionSegment> = Vec::new();
153+
for seg in kept {
154+
if let Some(last) = deduped.last_mut() {
155+
if last.text.trim() == seg.text.trim() {
156+
stats.duplicates += 1;
157+
filtered.push(FilteredItem {
158+
segment_id: seg.id,
159+
reason: FilterReason::Duplicate,
160+
text: seg.text.clone(),
161+
start_ms: seg.start_ms,
162+
});
163+
merged.push(MergedItem {
164+
segment_id: seg.id,
165+
into_segment_id: last.id,
166+
text: seg.text.clone(),
167+
start_ms: seg.start_ms,
168+
});
169+
last.end_ms = last.end_ms.max(seg.end_ms);
170+
continue;
171+
}
172+
}
173+
deduped.push(seg);
174+
}
175+
let kept = deduped;
176+
// 画面要点(v0.7.3 REQ-160:可消费块过滤 → 屏构建 → 屏段落渲染;
177+
// v0.7.5:过滤含单字符/边缘条带/视频页共现/错字纠错——见 screens.rs)
178+
let transcript = concat_transcript(&kept);
179+
let (usable, ocr_corrected) =
180+
crate::screens::filter_usable_blocks(ocr_blocks, ui_junk, config, &transcript, corrections);
181+
stats.ocr_corrected = ocr_corrected;
182+
let ocr_screens = crate::screens::build_screens(&usable, None);
183+
let ocr_points = render_screen_points(&ocr_screens);
184+
let markdown = rebuild_markdown(title, &kept, &[], config, None);
185+
NoteFilterResult {
186+
title: title.to_string(),
187+
markdown,
188+
kept,
189+
ocr_points,
190+
ocr_screens,
191+
stats,
192+
filtered,
193+
merged,
194+
purify: config.clone(),
195+
warning: None,
196+
body_source: BodySource::Transcript,
197+
ocr_body: Vec::new(),
198+
}
199+
}

0 commit comments

Comments
 (0)