Skip to content

Commit 8e9e889

Browse files
committed
feat(ocr): 消费端屏卡——笔记屏段落+大纲/检索按屏+配图attach
1 parent 1536222 commit 8e9e889

8 files changed

Lines changed: 224 additions & 74 deletions

File tree

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

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ pub async fn get_session_detail(state: State<'_, AppState>, id: i64) -> Result<S
8383
// v0.7.3(REQ-160,ADR-015):画面要点屏卡(新数据按 screen_id 分组、
8484
// 旧数据聚类兜底;图匹配失败/目录缺失不阻断——前端无缩略图降级)
8585
let images_dir = state.data_dir.join("session-images").join(id.to_string());
86-
let screens = crate::screens::build_screens(id, &ocr_blocks, &images_dir);
86+
let screens = crate::screens::build_screens(&ocr_blocks, Some(&images_dir));
8787
Ok(SessionDetail { session, segments, ocr_blocks, events, screens })
8888
}
8989

@@ -183,10 +183,13 @@ fn load_note_material(
183183
/// ai_decisions(REQ-085)可选叠加——前端把预览中已确认的 AI
184184
/// 判定结果回传,落库与预览输出保持一致(默认 None=纯规则)。
185185
/// @ai-context: 只允许 finished/failed 会话转换;source 沿用 classroom;
186-
/// v0.7.1 起落库携带 session_id(列表 has_note/查看笔记跳转的数据源)。
186+
/// v0.7.1 起落库携带 session_id(列表 has_note/查看笔记跳转的数据源);
187+
/// v0.7.3(REQ-160):data_dir 供画面要点屏 attach 归档图
188+
/// (配图行随 image_ref 进入笔记 markdown)。
187189
fn convert_to_note(
188190
db: &Db,
189191
ui_junk: &UiJunkList,
192+
data_dir: &std::path::Path,
190193
id: i64,
191194
title: Option<String>,
192195
ai_decisions: Option<Vec<TextFilterDecision>>,
@@ -198,6 +201,10 @@ fn convert_to_note(
198201
if let Some(decisions) = ai_decisions {
199202
result = apply_ai_decisions(result, &decisions);
200203
}
204+
// v0.7.3(REQ-160):画面要点配图(归档 full 图匹配;目录缺失/无图 → 纯文本降级)
205+
let images_dir = data_dir.join("session-images").join(id.to_string());
206+
crate::screens::attach_images(&mut result.ocr_screens, &images_dir);
207+
crate::note_filter::refresh_screen_points(&mut result);
201208
let new = NewNote {
202209
title: result.title.clone(),
203210
content: result.markdown.clone(),
@@ -215,7 +222,7 @@ pub async fn session_to_note(
215222
title: Option<String>,
216223
ai_decisions: Option<Vec<TextFilterDecision>>,
217224
) -> Result<Note, String> {
218-
convert_to_note(&state.db, &state.ui_junk, id, title, ai_decisions)
225+
convert_to_note(&state.db, &state.ui_junk, &state.data_dir, id, title, ai_decisions)
219226
}
220227

221228
/// 批量转笔记核心编排(v0.7.1:部分成功语义——单条失败不阻塞其他)。
@@ -226,6 +233,7 @@ pub async fn session_to_note(
226233
pub fn run_batch_conversion(
227234
db: &Db,
228235
ui_junk: &UiJunkList,
236+
data_dir: &std::path::Path,
229237
ids: Vec<i64>,
230238
) -> Result<BatchNoteResult, String> {
231239
if ids.len() > 50 {
@@ -258,7 +266,7 @@ pub fn run_batch_conversion(
258266
});
259267
continue;
260268
}
261-
match convert_to_note(db, ui_junk, id, None, None) {
269+
match convert_to_note(db, ui_junk, data_dir, id, None, None) {
262270
Ok(note) => converted.push(ConvertedNote { session_id: id, note_id: note.id }),
263271
Err(reason) => skipped.push(SkippedNote { session_id: id, reason }),
264272
}
@@ -280,21 +288,27 @@ pub async fn batch_session_to_note(
280288
state: State<'_, AppState>,
281289
ids: Vec<i64>,
282290
) -> Result<BatchNoteResult, String> {
283-
run_batch_conversion(&state.db, &state.ui_junk, ids)
291+
run_batch_conversion(&state.db, &state.ui_junk, &state.data_dir, ids)
284292
}
285293

286294
/// 会话笔记预览(REQ-081):过滤后只读预览——不落库、不改库。
287295
///
288296
/// @ai-context: 与 session_to_note 同一过滤管线(输出一致性由构造保证);
289297
/// 返回 NoteFilterResult(markdown + 过滤统计 + 被过滤对照 +
290298
/// kept 段),前端展示过滤统计卡/对照复查/一键落库。
299+
/// @ai-context: v0.7.3(REQ-160):ocr_screens attach 归档图(预览屏卡配图;
300+
/// 目录缺失/无图 → 纯文本降级)。
291301
#[tauri::command]
292302
pub async fn preview_session_note(
293303
state: State<'_, AppState>,
294304
id: i64,
295305
) -> Result<NoteFilterResult, String> {
296306
let (session, segments, ocr_blocks) = load_note_material(&state.db, id)?;
297-
Ok(filter_note(&session.title, &segments, &ocr_blocks, &state.ui_junk))
307+
let mut result = filter_note(&session.title, &segments, &ocr_blocks, &state.ui_junk);
308+
let images_dir = state.data_dir.join("session-images").join(id.to_string());
309+
crate::screens::attach_images(&mut result.ocr_screens, &images_dir);
310+
crate::note_filter::refresh_screen_points(&mut result);
311+
Ok(result)
298312
}
299313

300314
/// 归一化转写段来源标识(asr | subtitle | fused,其余回退 asr)。
@@ -326,6 +340,7 @@ pub async fn session_quality_report(
326340
}
327341

328342
/// 会话大纲(REQ-077):OCR 全帧块 → 大纲条目(产物视图侧边导航,点击跳转)。
343+
/// @ai-context: v0.7.3(REQ-160):屏标题优先(版面角色分类),无屏回退文本启发式。
329344
#[tauri::command]
330345
pub async fn session_outline(
331346
state: State<'_, AppState>,
@@ -335,7 +350,12 @@ pub async fn session_outline(
335350
return Err("无效的会话 id".to_string());
336351
}
337352
let ocr_blocks = state.db.list_ocr_blocks(id).map_err(|e| e.to_string())?;
338-
Ok(crate::outline::detect_outline(&ocr_blocks, &crate::outline::OutlineConfig::default()))
353+
let screens = crate::screens::build_screens(&ocr_blocks, None);
354+
Ok(crate::outline::detect_outline_smart(
355+
&ocr_blocks,
356+
&screens,
357+
&crate::outline::OutlineConfig::default(),
358+
))
339359
}
340360

341361
/// 课程分组(REQ-078):课程键 = 标题章节前缀("第3章"等);无前缀 → 标题本身。

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ fn batch_converts_all_eligible_sessions() {
5757
let a = finished_session(&db, "A课");
5858
let b = finished_session(&db, "B课");
5959
// Act
60-
let result = run_batch_conversion(&db, &UiJunkList::defaults(), vec![a, b]).expect("batch");
60+
let result = run_batch_conversion(&db, &UiJunkList::defaults(), std::path::Path::new("."), vec![a, b]).expect("batch");
6161
// Assert:全部转换成功;关联已建立(find_note_by_session 可见)
6262
assert_eq!(result.converted.len(), 2);
6363
assert!(result.skipped.is_empty());
@@ -74,7 +74,7 @@ fn batch_skips_recording_and_duplicate_and_invalid() {
7474
let ok = finished_session(&db, "正常课");
7575
// Act
7676
let result =
77-
run_batch_conversion(&db, &UiJunkList::defaults(), vec![live, ok, ok, 0, -3]).expect("batch");
77+
run_batch_conversion(&db, &UiJunkList::defaults(), std::path::Path::new("."), vec![live, ok, ok, 0, -3]).expect("batch");
7878
// Assert:只转换 1 条;跳过 3 条(重复 id 静默去重,不计入跳过)
7979
assert_eq!(result.converted.len(), 1);
8080
assert_eq!(result.converted[0].session_id, ok);
@@ -99,7 +99,7 @@ fn batch_skips_already_converted_session() {
9999
})
100100
.expect("create note");
101101
// Act
102-
let result = run_batch_conversion(&db, &UiJunkList::defaults(), vec![id]).expect("batch");
102+
let result = run_batch_conversion(&db, &UiJunkList::defaults(), std::path::Path::new("."), vec![id]).expect("batch");
103103
// Assert:不重复生成,原因显式
104104
assert!(result.converted.is_empty());
105105
assert_eq!(result.skipped.len(), 1);
@@ -111,7 +111,7 @@ fn batch_skips_missing_session() {
111111
// Arrange
112112
let db = mem_db();
113113
// Act:不存在的会话 id
114-
let result = run_batch_conversion(&db, &UiJunkList::defaults(), vec![9999]).expect("batch");
114+
let result = run_batch_conversion(&db, &UiJunkList::defaults(), std::path::Path::new("."), vec![9999]).expect("batch");
115115
// Assert
116116
assert!(result.converted.is_empty());
117117
assert_eq!(result.skipped.len(), 1);
@@ -124,7 +124,7 @@ fn batch_rejects_over_limit() {
124124
let db = mem_db();
125125
let ids: Vec<i64> = (1..=51).collect();
126126
// Act
127-
let result = run_batch_conversion(&db, &UiJunkList::defaults(), ids);
127+
let result = run_batch_conversion(&db, &UiJunkList::defaults(), std::path::Path::new("."), ids);
128128
// Assert
129129
assert!(result.is_err());
130130
assert!(result.unwrap_err().contains("上限 50"));
@@ -138,7 +138,7 @@ fn batch_partial_failure_does_not_block_others() {
138138
let ok = finished_session(&db, "正常课");
139139
// Act
140140
let result =
141-
run_batch_conversion(&db, &UiJunkList::defaults(), vec![live, ok]).expect("batch");
141+
run_batch_conversion(&db, &UiJunkList::defaults(), std::path::Path::new("."), vec![live, ok]).expect("batch");
142142
// Assert:后续会话仍成功转换
143143
assert_eq!(result.converted.len(), 1);
144144
assert_eq!(result.converted[0].session_id, ok);

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,22 @@ pub struct OcrBlockHit {
2525
pub region: String,
2626
/// 命中图相对路径(full/xxx.webp;无图 None——纯 OCR 块无归档)
2727
pub image_path: Option<String>,
28+
/// v0.7.3(REQ-160):命中块所属屏(None=旧数据无屏)
29+
#[serde(default)]
30+
pub screen_id: Option<i64>,
31+
/// 屏时间区间(first/last_seen;屏定位基准)
32+
#[serde(default)]
33+
pub screen_first_ms: Option<u64>,
34+
#[serde(default)]
35+
pub screen_last_ms: Option<u64>,
2836
}
2937

30-
/// 检索会话 OCR 块(REQ-133):关键词 → 命中块 + 会话标题 + 图路径。
38+
/// 检索会话 OCR 块(REQ-133):关键词 → 命中块 + 会话标题 + 图路径 + 屏区间。
3139
///
3240
/// @ai-context: 只搜画面要点(region=full——字幕区是转写冗余,搜字幕用段搜索);
3341
/// 大小写不敏感;结果有界(100 条防超大 payload)。
42+
/// @ai-context: v0.7.3(REQ-160):命中块带屏信息(同屏块 min/max 时间戳——
43+
/// 前端"命中→跳屏"定位;旧数据无 screen_id → None 降级)。
3444
pub fn search_ocr_blocks(db: &Db, keyword: &str, limit: usize) -> Result<Vec<OcrBlockHit>> {
3545
let kw = keyword.trim().to_lowercase();
3646
if kw.is_empty() {
@@ -40,21 +50,40 @@ pub fn search_ocr_blocks(db: &Db, keyword: &str, limit: usize) -> Result<Vec<Ocr
4050
let mut hits = Vec::new();
4151
for item in db.list_sessions(None, 500, 0)? {
4252
let session = &item.session;
43-
for block in db.list_ocr_blocks(session.id)? {
53+
let blocks = db.list_ocr_blocks(session.id)?;
54+
// 屏区间映射(命中定位到屏:min/max 时间戳)
55+
let mut screen_ranges: std::collections::HashMap<i64, (u64, u64)> =
56+
std::collections::HashMap::new();
57+
for b in &blocks {
58+
if let Some(sid) = b.screen_id {
59+
let e = screen_ranges.entry(sid).or_insert((b.timestamp_ms, b.timestamp_ms));
60+
e.0 = e.0.min(b.timestamp_ms);
61+
e.1 = e.1.max(b.timestamp_ms);
62+
}
63+
}
64+
for block in &blocks {
4465
if block.region != "full" {
4566
continue; // 字幕区不参与图内检索(与段搜索分工)
4667
}
4768
if block.text.to_lowercase().contains(&kw) {
69+
let (sf, sl) = block
70+
.screen_id
71+
.and_then(|sid| screen_ranges.get(&sid).copied())
72+
.map(|(a, b)| (Some(a), Some(b)))
73+
.unwrap_or((None, None));
4874
hits.push(OcrBlockHit {
4975
session_id: session.id,
5076
session_title: session.title.clone(),
5177
ocr_block_id: block.id,
5278
timestamp_ms: block.timestamp_ms,
53-
text: block.text,
54-
region: block.region,
79+
text: block.text.clone(),
80+
region: block.region.clone(),
5581
// 图路径推断:full/<ts>.webp(与 image_store 命名约定一致;
5682
// 文件不存在 → None——OCR 块可能无归档图)
5783
image_path: image_path_for(db, session.id, block.timestamp_ms),
84+
screen_id: block.screen_id,
85+
screen_first_ms: sf,
86+
screen_last_ms: sl,
5887
});
5988
if hits.len() >= limit {
6089
return Ok(hits);

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

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,16 @@
1616
//! 的短文本不是"纯符号"碎片);confidence=None(字幕段)跳过
1717
//! 低置信规则。
1818
19-
use std::collections::HashSet;
20-
2119
use crate::ai_protocol::{TextFilterAction, TextFilterDecision};
22-
use crate::types::{SessionOcrBlock, SessionSegment, TranscriptSegment};
20+
use crate::types::{SessionOcrBlock, SessionScreen, SessionSegment, TranscriptSegment};
2321
use crate::ui_junk::UiJunkList;
24-
use crate::watermark_filter::{detect_watermarks, WatermarkConfig, WatermarkInput};
2522

2623
/// 碎片段最大字符数(≤2 字丢弃)。
2724
pub const FRAGMENT_MAX_CHARS: usize = 2;
2825
/// 碎片段最小时长(<500ms 丢弃)。
2926
pub const FRAGMENT_MIN_DURATION_MS: u64 = 500;
3027
/// 低置信丢弃阈值(confidence <0.6 丢弃)。
3128
pub const LOW_CONFIDENCE_THRESHOLD: f32 = 0.6;
32-
/// OCR 画面要点最低分数(沿用 concat 口径)。
33-
const OCR_MIN_SCORE: f32 = 0.5;
3429

3530
/// 被过滤原因。
3631
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -84,8 +79,12 @@ pub struct NoteFilterResult {
8479
pub markdown: String,
8580
/// 保留段(合并段已延伸 end_ms;AI merge 已拼接文本——仅产物层)
8681
pub kept: Vec<SessionSegment>,
87-
/// 画面要点(水印/UI 垃圾/低分已排除)
82+
/// 画面要点(屏段落行:区间+标题+正文+标签+配图;水印/UI 垃圾/低分已排除)
8883
pub ocr_points: Vec<String>,
84+
/// v0.7.3(REQ-160):画面要点屏(结构化——前端预览渲染屏卡;
85+
/// 命令层 attach 图后 image_ref 填充,markdown 配图行随之可渲染)
86+
#[serde(default)]
87+
pub ocr_screens: Vec<SessionScreen>,
8988
pub stats: FilterStats,
9089
pub filtered: Vec<FilteredItem>,
9190
pub merged: Vec<MergedItem>,
@@ -171,10 +170,13 @@ pub fn filter_note(
171170
}
172171
kept.push(seg);
173172
}
174-
// ② 画面要点(水印 + UI 垃圾 + 低分 + 去重)
175-
let ocr_points = build_ocr_points(ocr_blocks, ui_junk);
173+
// ② 画面要点(v0.7.3 REQ-160:可消费块过滤 → 屏构建 → 屏段落渲染;
174+
// 水印 + UI 垃圾 + 低分在 filter_usable_blocks 排除——与原料口径解耦)
175+
let usable = crate::screens::filter_usable_blocks(ocr_blocks, ui_junk);
176+
let ocr_screens = crate::screens::build_screens(&usable, None);
177+
let ocr_points = render_screen_points(&ocr_screens);
176178
let markdown = rebuild_markdown(title, &kept, &ocr_points);
177-
NoteFilterResult { title: title.to_string(), markdown, kept, ocr_points, stats, filtered, merged }
179+
NoteFilterResult { title: title.to_string(), markdown, kept, ocr_points, ocr_screens, stats, filtered, merged }
178180
}
179181

180182
/// 碎片段判定(纯函数):≤2 字 / 时长 <500ms / 纯符号。
@@ -188,39 +190,57 @@ fn is_fragment(seg: &SessionSegment) -> bool {
188190
|| text.chars().all(|c| !c.is_alphanumeric() && !is_cjk(c))
189191
}
190192

191-
/// 画面要点构建(纯函数):水印排除 + UI 垃圾 + 低分过滤 + 精确去重。
192-
fn build_ocr_points(ocr_blocks: &[SessionOcrBlock], ui_junk: &UiJunkList) -> Vec<String> {
193-
// 水印检测(REQ-059):全帧块(字幕区文本已在讲述内容中,不重复进要点)
194-
let inputs: Vec<WatermarkInput> = ocr_blocks
195-
.iter()
196-
.filter(|b| b.region == "full")
197-
.map(|b| WatermarkInput {
198-
text: b.text.clone(),
199-
timestamp_ms: b.timestamp_ms,
200-
region_key: None,
201-
})
202-
.collect();
203-
let watermarks = detect_watermarks(&inputs, &WatermarkConfig::default());
204-
let mut sorted: Vec<&SessionOcrBlock> = ocr_blocks
193+
/// 画面要点屏段落渲染(纯函数):屏卡 → 笔记画面要点行(Markdown 列表项)。
194+
///
195+
/// @ai-context: 每屏一段:`- **[MM:SS–MM:SS] 标题**` + 正文行(二级列表)+
196+
/// 标签行 + 配图行(image_ref 有值时;src 为相对 data_dir 路径
197+
/// `session-images/{sid}/full/{ts}.webp`——前端渲染拼 baseUrl)。
198+
/// @ai-context: 无标题屏(旧数据无 bbox 降级)用"画面 N"占位——不丢内容。
199+
pub fn render_screen_points(screens: &[SessionScreen]) -> Vec<String> {
200+
let mut lines = Vec::new();
201+
for (i, s) in screens.iter().enumerate() {
202+
let range = format!(
203+
"[{}–{}]",
204+
crate::concat::format_timestamp(s.first_seen_ms),
205+
crate::concat::format_timestamp(s.last_seen_ms)
206+
);
207+
let title = s.title.clone().unwrap_or_else(|| format!("画面 {}", i + 1));
208+
lines.push(format!("- **{} {}**", range, title));
209+
for b in &s.body {
210+
lines.push(format!(" - {}", b));
211+
}
212+
if !s.labels.is_empty() {
213+
lines.push(format!(" - 标签:{}", s.labels.join(" · ")));
214+
}
215+
if let Some(rel) = &s.image_ref {
216+
lines.push(format!(" - ![画面 {}](session-images/{}/{}", i + 1, s.session_id, rel));
217+
}
218+
}
219+
lines
220+
}
221+
222+
/// 刷新画面要点段落(纯函数):ocr_screens 重新渲染 + 重建 markdown。
223+
///
224+
/// @ai-context: 命令层 attach_images 填充 image_ref 后调用——配图行随
225+
/// image_ref 出现/消失,保持 markdown 与 ocr_screens 一致
226+
/// (单管线双出口原则的图版本)。
227+
pub fn refresh_screen_points(result: &mut NoteFilterResult) {
228+
result.ocr_points = render_screen_points(&result.ocr_screens);
229+
let transcript: Vec<TranscriptSegment> = result
230+
.kept
205231
.iter()
206-
.filter(|b| {
207-
b.region == "full"
208-
&& b.score >= OCR_MIN_SCORE
209-
&& !b.text.trim().is_empty()
210-
&& !ui_junk.is_junk(&b.text)
211-
&& !watermarks.texts.iter().any(|w| b.text.trim() == w)
232+
.map(|s| TranscriptSegment {
233+
start_ms: s.start_ms,
234+
end_ms: s.end_ms,
235+
text: s.text.clone(),
236+
word_timestamps: None,
237+
confidence: None,
238+
volume: None,
212239
})
213240
.collect();
214-
sorted.sort_by_key(|b| b.timestamp_ms);
215-
let mut seen = HashSet::new();
216-
let mut points = Vec::new();
217-
for b in sorted {
218-
let text = b.text.trim().to_string();
219-
if seen.insert(text.clone()) {
220-
points.push(format!("[{}] {}", crate::concat::format_timestamp(b.timestamp_ms), text));
221-
}
222-
}
223-
points
241+
let paragraphs = crate::concat::split_transcript_paragraphs(&transcript);
242+
result.markdown =
243+
crate::concat::assemble_markdown(&result.title, &paragraphs, &result.ocr_points);
224244
}
225245

226246
/// 组装 Markdown(标题 + 讲述内容 + 画面要点;段落切分复用 concat 口径)。

0 commit comments

Comments
 (0)