Skip to content

Commit f2efab9

Browse files
committed
feat(notes): 过渡句/修辞问句规则净化(v0.7.5 增补)
- 用户反馈探讨落地(2026-08-20):会话31「讲我们具体的工具了。…过程是什么?」 两处无价值——形式特征明显的废话本地规则化,语义级留给 AI(v0.8.0) - 纯过渡短句规则删除:note_filter_discourse.rs 精确表(整句去标点后 ∈ 表且 ≤8 字才删)——零误杀低召回;「接下来我们看第三章」(含章节内容)与 「讲我们具体的工具了」(带话题预告)不删(宁漏勿误,filtered 可复查) - 修辞问句规则删除:自问自答判定——问句(≤15 字、问号/疑问词结尾)核心词 (去疑问词后最长 CJK 段)在紧邻下一段复现 → 删问句(信息由答案段承载); 「过程是什么?」+「这个过程是制定项目章程」实证命中;开放问题/答案不紧邻/ 疑问过泛(无核心词)保守保留 - 过滤链顺序微调:过渡检查先于碎片(表内 2 字短语以'过渡'原因删除,标签语义 准确);口头禅/碎片/过渡基于原文判定,净化残留检查后置 - 配置:purify_config.json 可校准 transitionDelete/transitionMaxChars/ rhetoricalDelete/rhetoricalMaxChars;FilterStats 新增 transition/rhetorical 落库 purify_stats - 前端:统计卡新增过渡/反问,被过滤对照新增对应原因标签 - 黄金语料 + 单测 +11(过渡表精确匹配/问句边界/核心词提取/自问自答删除/ 开放问题保留/超长问句保留/尾段问句保留);1114 测试全绿;clippy 新代码 零警告;前端构建通过
1 parent 14d4843 commit f2efab9

10 files changed

Lines changed: 463 additions & 27 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ mod model_downloader;
128128
mod model_registry;
129129
mod note_filter;
130130
mod note_filter_ai;
131+
mod note_filter_discourse;
131132
mod novelty;
132133
mod ocr;
133134
mod ocr_correction;

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

Lines changed: 71 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
//!
33
//! @ai-context: 过滤链(纯规则,本地优先):
44
//! ① UI 垃圾特征兜底(复用 REQ-083 黑名单同表)
5-
//! ② 碎片段丢弃(≤2 字 / 时长 <500ms / 纯符号)
6-
//! ③ 低置信丢弃(confidence <0.6)
7-
//! ④ 口语净化(v0.7.5 REQ-162:verbal_normalize 保守档 + symbol_normalize)→
8-
//! 结巴折叠 + 术语替换(REQ-164)——净化后为空/纯符号/短口头禅 → 删除
9-
//! ⑤ 口头禅短段规则级删除(REQ-163:≤8 字且全由口头禅词组成,免 AI)
10-
//! ⑥ 相邻重复段合并(净化后文本才做精确去重——净化顺序契约)
5+
//! ② 低置信丢弃(confidence <0.6)
6+
//! ③ 纯过渡短句删除(v0.7.5 扩展:整句 ∈ 精确表——零误杀低召回;
7+
//! 先于碎片检查——表内 2 字短语以"过渡"原因删除,统计语义更准确)
8+
//! ④ 口头禅短段规则级删除(REQ-163:≤8 字且全由口头禅词组成,免 AI)
9+
//! ⑤ 碎片段丢弃(≤2 字 / 时长 <500ms / 纯符号)
10+
//! ⑥ 口语净化(v0.7.5 REQ-162:verbal_normalize 保守档 + symbol_normalize)→
11+
//! 结巴折叠 + 术语替换(REQ-164)——净化后为空/纯符号 → 删除
12+
//! ⑦ 修辞问句删除(v0.7.5 扩展:自问自答——核心词在紧邻段复现;跨段 pass)
13+
//! ⑧ 相邻重复段合并(净化后文本才做精确去重——净化顺序契约)
1114
//! @ai-context: 单一管线双出口(REQ-081):session_to_note(落库)与
1215
//! preview_session_note(只读预览)共用本模块——输出一致性
1316
//! 由构造保证。过滤**可逆**:原料层(sessions 表)不动,被过滤
@@ -70,6 +73,10 @@ pub enum FilterReason {
7073
AiDelete,
7174
/// 口头禅短段规则级删除(REQ-163:≤8 字全口头禅;含净化后空/纯符号残留)
7275
Filler,
76+
/// 纯过渡短句规则级删除(v0.7.5 扩展:整句 ∈ 精确表,零误杀)
77+
Transition,
78+
/// 修辞问句删除(v0.7.5 扩展:自问自答——核心词在紧邻段复现)
79+
Rhetorical,
7380
}
7481

7582
/// 被过滤条目(预览对照可复查、定位原料)。
@@ -104,6 +111,12 @@ pub struct FilterStats {
104111
/// v0.7.5(REQ-168):OCR 错字纠错块数
105112
#[serde(default)]
106113
pub ocr_corrected: usize,
114+
/// v0.7.5 扩展:纯过渡短句删除数(精确表)
115+
#[serde(default)]
116+
pub transition: usize,
117+
/// v0.7.5 扩展:修辞问句删除数(自问自答)
118+
#[serde(default)]
119+
pub rhetorical: usize,
107120
}
108121

109122
/// 合并条目(相邻重复合并 / AI merge 展示层拼接)。
@@ -182,44 +195,61 @@ pub fn filter_note(
182195
});
183196
continue;
184197
}
185-
// ② 碎片段(≤2 字 / <500ms / 纯符号——"----/···" 等无信息内容)
186-
if is_fragment(&seg, config) {
187-
stats.fragments += 1;
198+
// ② 低置信丢弃(confidence=None 的字幕段跳过——无置信度证据不删)
199+
if seg.confidence.is_some_and(|c| c < config.low_confidence_threshold) {
200+
stats.low_confidence += 1;
188201
filtered.push(FilteredItem {
189202
segment_id: seg.id,
190-
reason: FilterReason::Fragment,
203+
reason: FilterReason::LowConfidence,
191204
text: text.to_string(),
192205
start_ms: seg.start_ms,
193206
});
194207
continue;
195208
}
196-
// ③ 低置信丢弃(confidence=None 的字幕段跳过——无置信度证据不删)
197-
if seg.confidence.is_some_and(|c| c < config.low_confidence_threshold) {
198-
stats.low_confidence += 1;
209+
// ③ 纯过渡短句删除(v0.7.5 扩展:整句 ∈ 精确表——"接下来/我们来看"
210+
// 单独成段无信息;"接下来我们看第三章"不在表内不误杀)。
211+
// 先于碎片检查:表内 2 字短语(首先/总之/好吧)若后置会被碎片规则
212+
// (≤2 字)先删,原因标签失真(过渡原因更准确)
213+
if config.transition_delete
214+
&& crate::note_filter_discourse::is_transition_short(text, config.transition_max_chars)
215+
{
216+
stats.transition += 1;
199217
filtered.push(FilteredItem {
200218
segment_id: seg.id,
201-
reason: FilterReason::LowConfidence,
219+
reason: FilterReason::Transition,
202220
text: text.to_string(),
203221
start_ms: seg.start_ms,
204222
});
205223
continue;
206224
}
207-
// ④ 口语净化(REQ-162/164):书面化(保守档)→ 符号 → 结巴折叠 → 术语替换
208-
let original = seg.text.clone();
209-
let purified = purify_segment(&original, config, symbol_cfg, &mut stats);
210-
// 净化残留检查:空/纯符号("对不对?"→"?")/ 短口头禅("哈")→ 删除
211-
if is_purified_empty(&purified) {
225+
// ④ 口头禅短段规则级删除(REQ-163:免 AI——段 1018「对不对?」类;
226+
// 基于原文判定——净化前形状特征未被破坏)
227+
if config.filler_delete && is_filler_only(text, config) {
212228
stats.filler += 1;
213229
filtered.push(FilteredItem {
214230
segment_id: seg.id,
215231
reason: FilterReason::Filler,
216-
text: original,
232+
text: text.to_string(),
233+
start_ms: seg.start_ms,
234+
});
235+
continue;
236+
}
237+
// ⑤ 碎片段(≤2 字 / <500ms / 纯符号——"----/···" 等无信息内容)
238+
if is_fragment(&seg, config) {
239+
stats.fragments += 1;
240+
filtered.push(FilteredItem {
241+
segment_id: seg.id,
242+
reason: FilterReason::Fragment,
243+
text: text.to_string(),
217244
start_ms: seg.start_ms,
218245
});
219246
continue;
220247
}
221-
// ⑤ 口头禅短段规则级删除(REQ-163:免 AI——段 1018「对不对?」类)
222-
if config.filler_delete && is_filler_only(&purified, config) {
248+
// ⑥ 口语净化(REQ-162/164):书面化(保守档)→ 符号 → 结巴折叠 → 术语替换
249+
let original = seg.text.clone();
250+
let purified = purify_segment(&original, config, symbol_cfg, &mut stats);
251+
// ⑦ 净化残留检查:空/纯符号("对不对?"→"?")/ 短口头禅("哈")→ 删除
252+
if is_purified_empty(&purified) {
223253
stats.filler += 1;
224254
filtered.push(FilteredItem {
225255
segment_id: seg.id,
@@ -230,8 +260,23 @@ pub fn filter_note(
230260
continue;
231261
}
232262
seg.text = purified;
233-
// ⑥ 相邻重复段合并(净化后文本精确去重——净化顺序契约;合并延伸 end_ms)
234-
if let Some(last) = kept.last_mut() {
263+
kept.push(seg);
264+
}
265+
// ⑥ 修辞问句删除(v0.7.5 扩展:自问自答——问句核心词在紧邻段复现,
266+
// 会话31「过程是什么?」+「这个过程是制定项目章程」实证;跨段上下文
267+
// 需 kept 全集,故在单遍循环后执行)
268+
if config.rhetorical_delete {
269+
kept = crate::note_filter_discourse::drop_rhetorical_questions(
270+
kept,
271+
config.rhetorical_max_chars,
272+
&mut stats,
273+
&mut filtered,
274+
);
275+
}
276+
// ⑦ 相邻重复段合并(净化后文本精确去重——净化顺序契约;合并延伸 end_ms)
277+
let mut deduped: Vec<SessionSegment> = Vec::new();
278+
for seg in kept {
279+
if let Some(last) = deduped.last_mut() {
235280
if last.text.trim() == seg.text.trim() {
236281
stats.duplicates += 1;
237282
filtered.push(FilteredItem {
@@ -250,8 +295,9 @@ pub fn filter_note(
250295
continue;
251296
}
252297
}
253-
kept.push(seg);
298+
deduped.push(seg);
254299
}
300+
let kept = deduped;
255301
// 画面要点(v0.7.3 REQ-160:可消费块过滤 → 屏构建 → 屏段落渲染;
256302
// v0.7.5:过滤含单字符/边缘条带/视频页共现/错字纠错——见 screens.rs)
257303
let transcript = concat_transcript(&kept);
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//! 话语层净化(v0.7.5 扩展:过渡短句 + 修辞问句)。
2+
//!
3+
//! @ai-context: 会话31 实证讨论(用户反馈"讲我们具体的工具了。过程是什么?"
4+
//! 两段无价值):规则层只抓**形式特征明显**的废话——① 纯过渡
5+
//! 短句(整句 ∈ 精确表才删,零误杀低召回);② 自问自答句
6+
//! (问句核心词在紧邻下一段复现 → 修辞自问 → 删问句,信息由
7+
//! 答案段承载)。带话题的过渡句("讲我们具体的工具了")与
8+
//! 开放性问题(无答案)**不删**——宁漏勿误(可逆契约:删除
9+
//! 进 filtered 供预览对照复查)。
10+
//! @ai-context: 纯函数无 IO;疑问词/核心词提取为启发式——边界全部钉在
11+
//! 单测与黄金语料里。
12+
13+
use crate::note_filter::{FilteredItem, FilterReason, FilterStats};
14+
15+
/// 纯过渡短句精确表(整句去标点后 ∈ 表且 ≤transition_max_chars 字才删)。
16+
///
17+
/// @ai-context: 只收录"单独成段时必为过渡/开场"的短语;**精确匹配非前缀**
18+
/// ——"接下来我们看第三章"(含章节内容)不在表内不删(零误杀);
19+
/// "好/行/可以"等单字回应语不进表(语义短句保留,与"对"同口径)。
20+
pub const TRANSITION_PHRASES: &[&str] = &[
21+
"接下来", "下面", "下面我们来看", "接下来我们", "我们来看", "我们看", "先看", "首先",
22+
"最后", "总之", "那么", "那好", "行吧", "好吧", "开始吧", "上课了", "我们开始",
23+
"开始上课", "今天我们来", "今天给大家",
24+
];
25+
26+
/// 疑问词表(修辞问句判定 + 核心词提取)。
27+
const QUESTION_WORDS: &[&str] = &[
28+
"什么", "为什么", "怎么", "如何", "为何", "多少", "哪里", "是不是", "有没有", "能不能",
29+
"哪", "几", "吗", "呢",
30+
];
31+
32+
/// 纯过渡短句判定(纯函数):去首尾标点后 ∈ 精确表 且 ≤max_chars 字。
33+
pub fn is_transition_short(text: &str, max_chars: usize) -> bool {
34+
let t = trim_punct(text);
35+
let chars = t.chars().count();
36+
chars > 0 && chars <= max_chars && TRANSITION_PHRASES.contains(&t.as_str())
37+
}
38+
39+
/// 问句判定(纯函数):以问号结尾,或以疑问词结尾(ASR 常丢问号)。
40+
pub fn is_question(text: &str) -> bool {
41+
let t = text.trim();
42+
if t.is_empty() {
43+
return false;
44+
}
45+
if t.ends_with('?') || t.ends_with('?') {
46+
return true;
47+
}
48+
QUESTION_WORDS.iter().any(|w| t.ends_with(w))
49+
}
50+
51+
/// 问句核心词提取(纯函数):去疑问词与标点后最长的连续 CJK 段(≥2 字)。
52+
///
53+
/// @ai-context: "过程是什么?" → 去"什么" → "过程是"(话题骨架);答案段含
54+
/// 该子串("**这个过程是**制定项目章程")即话题延续证据。
55+
/// @ai-context: 无 2 字以上 CJK 段("为什么?"/"多少?")→ None(疑问过泛,
56+
/// 无话题可验——保守不删)。
57+
pub fn question_core(text: &str) -> Option<String> {
58+
let mut t = text.to_string();
59+
for w in QUESTION_WORDS {
60+
t = t.replace(w, "");
61+
}
62+
let mut best: Option<String> = None;
63+
let mut cur = String::new();
64+
let flush = |cur: &mut String, best: &mut Option<String>| {
65+
if cur.chars().count() >= 2 {
66+
let longer = best.as_ref().is_none_or(|b: &String| cur.chars().count() > b.chars().count());
67+
if longer {
68+
*best = Some(std::mem::take(cur));
69+
return;
70+
}
71+
}
72+
cur.clear();
73+
};
74+
for c in t.chars() {
75+
if is_cjk(c) {
76+
cur.push(c);
77+
} else {
78+
flush(&mut cur, &mut best);
79+
}
80+
}
81+
flush(&mut cur, &mut best);
82+
best
83+
}
84+
85+
/// 修辞问句删除(纯函数,跨段上下文):问句的下一段包含其核心词 → 自问自答
86+
/// → 移出 kept 进 filtered(reason=Rhetorical——前端标签"反问")。
87+
///
88+
/// @ai-context: 紧邻下一段限定(答案不在紧邻段 = 开放问题/伏笔 → 保留);
89+
/// 问句超长(>rhetorical_max_chars)不删(复杂问句多为真问题);
90+
/// 核心词缺失/过泛不删。实现:先借用 kept 预判(下一段检查),
91+
/// 再移动阶段消费(into_iter 后不可再借用)。
92+
pub fn drop_rhetorical_questions(
93+
kept: Vec<crate::types::SessionSegment>,
94+
max_chars: usize,
95+
stats: &mut FilterStats,
96+
filtered: &mut Vec<FilteredItem>,
97+
) -> Vec<crate::types::SessionSegment> {
98+
// 预判阶段(借用 kept——下一段检查需要随机访问)
99+
let flags: Vec<bool> = (0..kept.len())
100+
.map(|i| {
101+
let text = kept[i].text.trim();
102+
if text.chars().count() > max_chars || !is_question(text) {
103+
return false;
104+
}
105+
match question_core(text) {
106+
Some(core) => kept
107+
.get(i + 1)
108+
.map(|n| n.text.contains(&core))
109+
.unwrap_or(false),
110+
None => false,
111+
}
112+
})
113+
.collect();
114+
// 移动阶段
115+
let mut out: Vec<crate::types::SessionSegment> = Vec::with_capacity(kept.len());
116+
for (i, seg) in kept.into_iter().enumerate() {
117+
if flags[i] {
118+
stats.rhetorical += 1;
119+
filtered.push(FilteredItem {
120+
segment_id: seg.id,
121+
reason: FilterReason::Rhetorical,
122+
text: seg.text.clone(),
123+
start_ms: seg.start_ms,
124+
});
125+
} else {
126+
out.push(seg);
127+
}
128+
}
129+
out
130+
}
131+
132+
/// 去首尾标点(纯函数)。
133+
fn trim_punct(text: &str) -> String {
134+
text.trim()
135+
.trim_matches(|c: char| c.is_ascii_punctuation() || "。!?,、;:…·".contains(c))
136+
.to_string()
137+
}
138+
139+
/// CJK 统一表意文字区段(含扩展 A)。
140+
fn is_cjk(c: char) -> bool {
141+
let u = c as u32;
142+
(0x4E00..=0x9FFF).contains(&u) || (0x3400..=0x4DBF).contains(&u)
143+
}
144+
145+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
146+
#[cfg(test)]
147+
#[path = "note_filter_discourse_tests.rs"]
148+
mod tests;

0 commit comments

Comments
 (0)