Skip to content

Commit c33db00

Browse files
committed
feat(note-filter): 正文源多态——图文会话 OCR 文本入笔记 markdown(v0.12.0 M1)
ADR-021:filter_note 引入 BodySource 枚举 + detect_body_source 纯函数分派—— Transcript→既有口语过滤链(视频,零改动);OcrDirect→note_filter_ocr 精简链 (排序/置信 0.5/符号归一/相邻去重/图文提取标注段,OCR 文本直接入 markdown 正文, 修复纯图文会话转笔记空内容);Empty→标题仅。refresh_screen_points/ apply_ai_decisions/render_note_structure 按 body_source 分流防覆盖。 note_body_source / note_filter_ocr 各 6/7 黄金用例。
1 parent 2f13308 commit c33db00

14 files changed

Lines changed: 762 additions & 19 deletions

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@
55

66
## [Unreleased] - 2026-08-21
77

8+
### v0.12.0 图文与窗口捕获重塑(2026-08-23 规划定稿,详见 [docs/versions/v0.12.0.md](docs/versions/v0.12.0.md))
9+
10+
- **正文源多态(M1)**:filter_note 增加 detect_body_source 抽象层——图文会话 OCR 文本直接进笔记 markdown(修复纯图文会话转笔记空内容);OCR 走精简净化链(跳过口语净化)
11+
- **WGC 窗口级捕获(M2)**:新增 capture/wgc_capture.rs,WGC 主路径 + DXGI 降级 1 + GDI 兜底三级自愈链——目标窗口不被遮挡
12+
- **系统级覆盖层截图(M3)**:Tauri 独立全屏透明窗口替代应用内 letterbox 框选,1:1 原始像素显示 + 全局快捷键
13+
- **DeepSeek 默认化(M4)**:默认 Provider 切 DeepSeek + 默认模型 deepseek-v4-flash-vision-exp;设置页预设/文案/价格表同步;旧 key 环境变量废除
14+
- **视频画面要点降级 + vision 精修(M5)**:本地 OCR 只做字幕/图文采集(视频会话关键帧纯图,存图触发解耦);AI 精修可选图片理解(vision_refine_enabled 开关默认关,仅视频会话)
15+
- **采集浮窗化(M6)**:Tauri 子窗口 alwaysOnTop 悬浮小窗(状态/转写/控制),全屏看视频时仍可操作采集
16+
- **文档(M7)**:ADR-021(正文源多态)+ ADR-022(WGC 三级链)+ ADR-023(视频 OCR 下线 + vision 精修隐私契约)
17+
818
### v0.11.7 图文会话(截屏导入)(2026-08-22 规划,详见 [docs/versions/v0.11.7.md](docs/versions/v0.11.7.md))
919

1020
- **图文采集**:课堂助手第三动线——全屏框选截屏 → kind=photo 图文会话(OCR 全图识别 + 屏卡流 + 转笔记);sessions.kind 字段 + 列表 📷 徽标 + 详情空态;与实时捕获互斥 + 24h 崩溃残留清扫 + 零截图不留空壳

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ mod model_registry;
221221
mod note_filter;
222222
mod note_filter_ai;
223223
mod note_filter_discourse;
224+
// v0.12.0 M1(ADR-021):正文源多态——BodySource 判定 + OCR 精简过滤链
225+
mod note_body_source;
226+
mod note_filter_ocr;
224227
// v0.11.0(REQ-197):容器侧组化业务层(系列课程组 + 结构密度路由)
225228
mod note_group_assign;
226229
// v0.8.0 F1(REQ-141 丢图修复):精修版配图本地合并降级(协议 v2 image 块兜底)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//! 笔记正文源检测(v0.12.0 M1,ADR-021)。
2+
//!
3+
//! @ai-context: `(segments, ocr_blocks)` 二元组缺乏"哪个是正文"的表达层——
4+
//! filter_note 硬编码 segments 为唯一正文源,图文会话(kind=photo,
5+
//! ADR-020)用户手动框选的 OCR 文本始终辅助位不入 markdown,
6+
//! 导致转笔记正文为空(设计债)。本模块引入 BodySource 枚举表达
7+
//! 正文来源,detect_body_source 纯函数按输入特征智能判断。
8+
//! @ai-context: 正文源多态是长期重构——未来导入 PDF/网页截图转笔记只需新增
9+
//! BodySource 变体 + 对应过滤链,不改既有 Transcript 路径。
10+
11+
use crate::types::{SessionOcrBlock, SessionSegment};
12+
13+
/// 笔记正文来源。
14+
///
15+
/// @ai-context: Transcript=转写段为正文(视频会话,既有口语过滤链零改动);
16+
/// OcrDirect=OCR 块直接为正文(图文会话——用户框选即意图);
17+
/// Empty=无可用正文(标题仅,不 panic)。
18+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19+
pub enum BodySource {
20+
/// 转写段为正文(视频会话——既有过滤链)
21+
Transcript,
22+
/// OCR 块直接为正文(图文会话——photo_capture 写入的 region=full 块特征)
23+
OcrDirect,
24+
/// 无可用正文(标题仅)
25+
#[default]
26+
Empty,
27+
}
28+
29+
/// 检测正文来源(纯函数)。
30+
///
31+
/// @ai-context: 判定规则——① 存在非空转写段 → Transcript(视频会话特征,
32+
/// 与 kind 字段解耦:未来导入语音会话同路径);② segments 空但
33+
/// 存在 region=full 且文本非空的 OCR 块 → OcrDirect(photo_capture
34+
/// ADR-020 落库块特征;region=subtitle 是字幕辅助块,不作正文);
35+
/// ③ 两者皆空 → Empty(标题仅)。
36+
pub fn detect_body_source(
37+
segments: &[SessionSegment],
38+
ocr_blocks: &[SessionOcrBlock],
39+
) -> BodySource {
40+
if segments.iter().any(|s| !s.text.trim().is_empty()) {
41+
return BodySource::Transcript;
42+
}
43+
if ocr_blocks
44+
.iter()
45+
.any(|b| b.region == "full" && !b.text.trim().is_empty())
46+
{
47+
return BodySource::OcrDirect;
48+
}
49+
BodySource::Empty
50+
}
51+
52+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
53+
#[cfg(test)]
54+
#[path = "note_body_source_tests.rs"]
55+
mod tests;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//! 正文源检测单测(v0.12.0 M1,ADR-021)。
2+
//!
3+
//! @ai-context: AAA 模式;黄金用例 5 场景——转写优先/图文 OCR/字幕不入正文/
4+
//! 空文本排除/双空标题仅。
5+
6+
use super::*;
7+
use crate::types::{SessionOcrBlock, SessionSegment};
8+
9+
fn seg(id: i64, text: &str) -> SessionSegment {
10+
SessionSegment {
11+
id,
12+
session_id: 1,
13+
start_ms: 0,
14+
end_ms: 1000,
15+
text: text.to_string(),
16+
source: "asr".to_string(),
17+
confidence: Some(0.9),
18+
volume: None,
19+
speech_rate: None,
20+
pause_ms: None,
21+
speaker: None,
22+
}
23+
}
24+
25+
fn block(ts: u64, region: &str, text: &str) -> SessionOcrBlock {
26+
SessionOcrBlock {
27+
id: 0,
28+
session_id: 1,
29+
timestamp_ms: ts,
30+
text: text.to_string(),
31+
score: 0.9,
32+
region: region.to_string(),
33+
region_kind: None,
34+
bbox: None,
35+
screen_id: None,
36+
}
37+
}
38+
39+
/// 场景 1:有非空转写段 → Transcript(即使存在 full OCR 块——视频会话特征)。
40+
#[test]
41+
fn non_empty_segments_win_over_ocr() {
42+
// Arrange:转写段 + 图文 OCR 块并存(fused 会话可能两者都有)
43+
let segments = vec![seg(1, "今天讲第三章")];
44+
let blocks = vec![block(100, "full", "网页标题")];
45+
// Act & Assert
46+
assert_eq!(detect_body_source(&segments, &blocks), BodySource::Transcript);
47+
}
48+
49+
/// 场景 2:空转写 + full OCR 块 → OcrDirect(图文会话,photo_capture 链路特征)。
50+
#[test]
51+
fn empty_segments_with_full_ocr_is_ocr_direct() {
52+
// Arrange:无转写段 + region=full 块
53+
let blocks = vec![block(1000, "full", "网页标题")];
54+
// Act & Assert
55+
assert_eq!(detect_body_source(&[], &blocks), BodySource::OcrDirect);
56+
}
57+
58+
/// 场景 3:空转写 + 仅 subtitle OCR → Empty(字幕是辅助块,不作正文)。
59+
#[test]
60+
fn subtitle_blocks_are_not_body() {
61+
// Arrange:region=subtitle(视频字幕链路块特征)
62+
let blocks = vec![block(1000, "subtitle", "屏幕上显示的字幕")];
63+
// Act & Assert
64+
assert_eq!(detect_body_source(&[], &blocks), BodySource::Empty);
65+
}
66+
67+
/// 场景 4:空转写 + full 块但文本全空白 → Empty(空文本不算正文)。
68+
#[test]
69+
fn blank_text_blocks_are_ignored() {
70+
// Arrange:region=full 但文本为空白
71+
let blocks = vec![block(1000, "full", " ")];
72+
// Act & Assert
73+
assert_eq!(detect_body_source(&[], &blocks), BodySource::Empty);
74+
}
75+
76+
/// 场景 5:空转写 + 空 OCR → Empty(标题仅,不 panic)。
77+
#[test]
78+
fn both_empty_is_title_only() {
79+
// Act & Assert
80+
assert_eq!(detect_body_source(&[], &[]), BodySource::Empty);
81+
}
82+
83+
/// 场景 6:转写段全部空白 → 按空处理(回落 OCR/Empty 判定)。
84+
#[test]
85+
fn blank_segments_fall_through() {
86+
// Arrange:全空白转写段 + full OCR 块
87+
let segments = vec![seg(1, " "), seg(2, "")];
88+
let blocks = vec![block(1000, "full", "正文要点")];
89+
// Act & Assert:空白段不构成正文,OCR 生效
90+
assert_eq!(detect_body_source(&segments, &blocks), BodySource::OcrDirect);
91+
}

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

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
//! 笔记废话出口过滤管线(REQ-082 / v0.6.0 M1;v0.7.5 净化接线)。
1+
//! 笔记废话出口过滤管线(REQ-082 / v0.6.0 M1;v0.7.5 净化接线;v0.12.0 M1 正文源多态)。
22
//!
3+
//! @ai-context: v0.12.0(ADR-021):filter_note 升级为正文源分派入口——
4+
//! detect_body_source 判定 (segments, ocr_blocks) 的正文来源:
5+
//! Transcript→既有口语过滤链(视频,零改动);OcrDirect→
6+
//! note_filter_ocr 精简链(图文,OCR 文本直接入 markdown 正文);
7+
//! Empty→标题仅。分派后重建路径(refresh_screen_points/
8+
//! apply_ai_decisions/structure)按 body_source 分流,防覆盖。
39
//! @ai-context: 过滤链(纯规则,本地优先):
410
//! ① UI 垃圾特征兜底(复用 REQ-083 黑名单同表)
511
//! ② 低置信丢弃(confidence <0.6)
@@ -22,6 +28,7 @@
2228
//! 的短文本不是"纯符号"碎片);confidence=None(字幕段)跳过
2329
//! 低置信规则;净化阈值集中 purify_config(REQ-173 JSON 可校准)。
2430
31+
use crate::note_body_source::{detect_body_source, BodySource};
2532
use crate::purify_config::PurifyConfig;
2633
use crate::symbol_normalize::SymbolNormalizeConfig;
2734
use crate::types::{SessionOcrBlock, SessionScreen, SessionSegment, TranscriptSegment};
@@ -165,14 +172,45 @@ pub struct NoteFilterResult {
165172
/// 会话转笔记"诚实降级;serde skip:markdown 已含该行,前端无需重复字段)
166173
#[serde(skip)]
167174
pub(crate) warning: Option<String>,
175+
/// v0.12.0(ADR-021):正文来源(serde skip——内部透传:refresh_screen_points/
176+
/// apply_ai_decisions/structure 按来源分派重建,不序列化给前端)
177+
#[serde(skip)]
178+
pub(crate) body_source: BodySource,
179+
/// v0.12.0(ADR-021):OCR 直接正文净化文本序列(OcrDirect 分支产物;
180+
/// serde skip——markdown 已含正文,前端无需重复字段)
181+
#[serde(skip)]
182+
pub(crate) ocr_body: Vec<String>,
168183
}
169184

170-
/// 笔记过滤(纯函数):转写段 + OCR 块 → 过滤后笔记。
185+
/// 笔记过滤(纯函数,正文源分派入口):转写段 + OCR 块 → 过滤后笔记。
186+
///
187+
/// @ai-context: v0.12.0(ADR-021):detect_body_source 三路分派——
188+
/// Transcript→filter_note_transcript(既有口语过滤链,零改动);
189+
/// OcrDirect→filter_note_from_ocr(图文 OCR 精简链,文本入正文);
190+
/// Empty→标题仅。视频会话路径行为逐字节不变(回归护栏)。
191+
pub fn filter_note(
192+
title: &str,
193+
segments: &[SessionSegment],
194+
ocr_blocks: &[SessionOcrBlock],
195+
ui_junk: &UiJunkList,
196+
env: &PurifyEnv,
197+
) -> NoteFilterResult {
198+
match detect_body_source(segments, ocr_blocks) {
199+
BodySource::Transcript => {
200+
filter_note_transcript(title, segments, ocr_blocks, ui_junk, env)
201+
}
202+
BodySource::OcrDirect => crate::note_filter_ocr::filter_note_from_ocr(title, ocr_blocks, env),
203+
BodySource::Empty => filter_note_empty(title),
204+
}
205+
}
206+
207+
/// 转写段正文过滤链(视频会话——既有路径,v0.12.0 提取自原 filter_note 主体,
208+
/// 逻辑零改动;OcrDirect/Empty 分派见 filter_note)。
171209
///
172210
/// @ai-context: 转写段按过滤链处理(见模块头);OCR 画面要点先经
173211
/// screens::filter_usable_blocks(v0.7.5:低分 0.7/单字符/边缘
174212
/// 条带/视频页 UI 共现/错字纠错)排除,再屏构建与精确去重。
175-
pub fn filter_note(
213+
fn filter_note_transcript(
176214
title: &str,
177215
segments: &[SessionSegment],
178216
ocr_blocks: &[SessionOcrBlock],
@@ -329,6 +367,29 @@ pub fn filter_note(
329367
merged,
330368
purify: config.clone(),
331369
warning: None,
370+
body_source: BodySource::Transcript,
371+
ocr_body: Vec::new(),
372+
}
373+
}
374+
375+
/// 空正文过滤链(纯函数):无转写段且无可用 OCR 块 → 标题仅 markdown。
376+
///
377+
/// @ai-context: 不 panic 契约(图文会话无内容时转笔记的诚实降级——标题
378+
/// 即全部;与 v0.11.7 可行性契约一致)。
379+
fn filter_note_empty(title: &str) -> NoteFilterResult {
380+
NoteFilterResult {
381+
title: title.to_string(),
382+
markdown: format!("# {}", title),
383+
kept: Vec::new(),
384+
ocr_points: Vec::new(),
385+
ocr_screens: Vec::new(),
386+
stats: FilterStats::default(),
387+
filtered: Vec::new(),
388+
merged: Vec::new(),
389+
purify: PurifyConfig::default(),
390+
warning: None,
391+
body_source: BodySource::Empty,
392+
ocr_body: Vec::new(),
332393
}
333394
}
334395

@@ -479,13 +540,23 @@ pub(crate) fn apply_session_warning(result: &mut NoteFilterResult, status: &str)
479540
/// 净化配置随 result 透传(段落阈值/锚点与预览口径一致)。
480541
pub fn refresh_screen_points(result: &mut NoteFilterResult) {
481542
result.ocr_points = render_screen_points(&result.ocr_screens);
482-
result.markdown = rebuild_markdown(
483-
&result.title,
484-
&result.kept,
485-
&[],
486-
&result.purify,
487-
result.warning.as_deref(),
488-
);
543+
// v0.12.0(ADR-021):按正文源分派重建——OcrDirect 走 OCR 正文重建
544+
// (kept 为空,走 rebuild_markdown 会把 OCR 正文覆盖成标题仅)
545+
result.markdown = match result.body_source {
546+
BodySource::OcrDirect => crate::note_filter_ocr::rebuild_ocr_markdown(
547+
&result.title,
548+
&result.ocr_body,
549+
&result.purify,
550+
result.warning.as_deref(),
551+
),
552+
_ => rebuild_markdown(
553+
&result.title,
554+
&result.kept,
555+
&[],
556+
&result.purify,
557+
result.warning.as_deref(),
558+
),
559+
};
489560
}
490561

491562
/// 组装 Markdown(标题 + 讲述内容;段落切分复用 concat 口径;

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ pub fn apply_ai_decisions(
175175
mut result: NoteFilterResult,
176176
decisions: &[TextFilterDecision],
177177
) -> NoteFilterResult {
178+
// v0.12.0(ADR-021):OcrDirect 分支无转写段——AI 判定针对段 id,对图文
179+
// OCR 正文无操作;markdown 重建若按 kept(空)会覆盖 OCR 正文,直接返回
180+
if result.body_source == crate::note_body_source::BodySource::OcrDirect {
181+
return result;
182+
}
178183
if decisions.is_empty() {
179184
return result;
180185
}

0 commit comments

Comments
 (0)