|
| 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