Skip to content

Commit 4edb5db

Browse files
committed
feat(notes): v0.7.6 笔记纯本地结构渲染——章节标题+词汇表入笔记(REQ-177~181)
1 parent 974343b commit 4edb5db

11 files changed

Lines changed: 786 additions & 23 deletions

File tree

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

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,19 @@ use std::collections::HashSet;
1212
use tauri::State;
1313

1414
use crate::ai_protocol::TextFilterDecision;
15+
use crate::analysis::analyze_session_opt;
1516
use crate::commands::{normalize_title, AppState, TITLE_MAX_CHARS};
1617
use crate::db::Db;
1718
use crate::db_sessions::SESSION_STATUS_RECORDING;
1819
use crate::note_filter::{apply_ai_decisions, filter_note, NoteFilterResult, PurifyEnv, RULE_VERSION};
20+
use crate::outline::{detect_outline_smart, OutlineConfig};
21+
use crate::structure_note::render_note_structure;
1922
use crate::types::{
2023
BatchNoteResult, ConvertedNote, NewNote, NewSession, NewSessionOcrBlock, NewSessionSegment,
2124
Note, Session, SessionDetail, SessionListItem, SessionOcrBlock, SessionSegment, SkippedNote,
2225
};
2326
use crate::ui_junk::UiJunkList;
27+
use crate::video_profile::ProfileKind;
2428

2529
/// 会话列表单页上限。
2630
const LIST_LIMIT_MAX: u64 = 200;
@@ -189,6 +193,47 @@ fn load_note_material(
189193
Ok((session, segments, ocr_blocks))
190194
}
191195

196+
/// 结构渲染接线(v0.7.6 REQ-177/178,双出口共用)。
197+
///
198+
/// @ai-context: 在 refresh_screen_points 之后调用——净化/配图/警示行先落定,
199+
/// 结构层在其上叠加章节标题与词汇表块;预览与落库同函数同口径
200+
/// (REQ-081 单一管线双出口契约)。
201+
/// @ai-context: 输入为会话原料(segments/ocr_blocks)——章节检测/大纲标题/
202+
/// 术语表均为纯本地规则(analysis/outline/glossary 复用)——
203+
/// 不依赖云端 AI(本地优先铁律);失败不阻断转笔记主链路
204+
/// (分析失败按空结构处理,不抛错)。
205+
fn apply_note_structure(
206+
result: &mut NoteFilterResult,
207+
db: &Db,
208+
session: &Session,
209+
segments: &[SessionSegment],
210+
ocr_blocks: &[SessionOcrBlock],
211+
env: &PurifyEnv,
212+
) {
213+
// 档案驱动:章节检测/术语表按档案开关(网课开、口播关——analysis 内部门控)
214+
let kind = session
215+
.profile
216+
.as_deref()
217+
.map(ProfileKind::parse)
218+
.unwrap_or(ProfileKind::Lecture);
219+
let detail = SessionDetail {
220+
session: session.clone(),
221+
segments: segments.to_vec(),
222+
ocr_blocks: ocr_blocks.to_vec(),
223+
events: db.list_events(session.id).unwrap_or_default(),
224+
screens: Vec::new(),
225+
};
226+
let analysis = analyze_session_opt(&detail, kind, &env.symbol);
227+
let outline = detect_outline_smart(ocr_blocks, &result.ocr_screens, &OutlineConfig::default());
228+
let _ = render_note_structure(
229+
result,
230+
&analysis.chapters,
231+
&outline,
232+
&analysis.glossary,
233+
&env.config.structure,
234+
);
235+
}
236+
192237
/// 会话 → 笔记核心(v0.7.1 提取:单条与批量共用同一管线)。
193238
///
194239
/// @ai-context: REQ-082:过滤链(UI 垃圾/重复合并/碎片/低置信/口语净化/口头禅
@@ -225,6 +270,9 @@ fn convert_to_note(
225270
// 重建口径一致;警示为正文行,用户可手动删除)
226271
crate::note_filter::apply_session_warning(&mut result, &session.status);
227272
crate::note_filter::refresh_screen_points(&mut result);
273+
// v0.7.6(REQ-177/178):结构渲染——章节标题 + 词汇表块(纯本地增强层;
274+
// 无结构数据/分析失败 → 原样输出不阻断,见 apply_note_structure)
275+
apply_note_structure(&mut result, db, &session, &segments, &ocr_blocks, env);
228276
let new = NewNote {
229277
title: result.title.clone(),
230278
content: result.markdown.clone(),
@@ -344,28 +392,27 @@ pub async fn batch_session_to_note(
344392
/// kept 段),前端展示过滤统计卡/对照复查/一键落库。
345393
/// @ai-context: v0.7.3(REQ-160):ocr_screens attach 归档图(预览屏卡配图;
346394
/// 目录缺失/无图 → 纯文本降级)。
395+
/// @ai-context: v0.7.6(REQ-177/178):预览与落库同口径——结构渲染(章节
396+
/// 标题 + 词汇表块)在预览即生效。
347397
#[tauri::command]
348398
pub async fn preview_session_note(
349399
state: State<'_, AppState>,
350400
id: i64,
351401
) -> Result<NoteFilterResult, String> {
352402
let (session, segments, ocr_blocks) = load_note_material(&state.db, id)?;
353-
let mut result = filter_note(
354-
&session.title,
355-
&segments,
356-
&ocr_blocks,
357-
&state.ui_junk,
358-
&PurifyEnv {
359-
config: state.purify.clone(),
360-
symbol: state.symbol_normalize.clone(),
361-
corrections: state.ocr_corrections.clone(),
362-
},
363-
);
403+
let env = PurifyEnv {
404+
config: state.purify.clone(),
405+
symbol: state.symbol_normalize.clone(),
406+
corrections: state.ocr_corrections.clone(),
407+
};
408+
let mut result = filter_note(&session.title, &segments, &ocr_blocks, &state.ui_junk, &env);
364409
let images_dir = state.data_dir.join("session-images").join(id.to_string());
365410
crate::screens::attach_images(&mut result.ocr_screens, &images_dir);
366411
// v0.7.5(REQ-170):预览与落库同口径——异常会话预览即带警示行
367412
crate::note_filter::apply_session_warning(&mut result, &session.status);
368413
crate::note_filter::refresh_screen_points(&mut result);
414+
// v0.7.6(REQ-177/178):结构渲染(与 convert_to_note 同函数同口径)
415+
apply_note_structure(&mut result, &state.db, &session, &segments, &ocr_blocks, &env);
369416
Ok(result)
370417
}
371418

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ mod streaming_asr;
149149
mod structure_engine;
150150
mod structure_models;
151151
mod structure_tier;
152+
// v0.7.6(REQ-177/178):笔记结构渲染层——章节标题 + 词汇表块(纯函数)
153+
mod structure_note;
152154
mod stutter_fold;
153155
mod subtitle;
154156
mod subtitle_detect;

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ pub struct PurifyEnv {
4242
}
4343

4444
/// 笔记规则版本(REQ-171:notes.rule_version 落库值——笔记可回答"用哪版规则
45-
/// 生成";净化链每次规则变更递增)。
46-
pub const RULE_VERSION: &str = "note-rules-0.7.5";
45+
/// 生成";净化链每次规则变更递增;v0.7.6 结构渲染层接入 REQ-177~181)。
46+
pub const RULE_VERSION: &str = "note-rules-0.7.6";
4747

4848
/// 口头禅词集(REQ-163 删除判定 + REQ-085 AI Filler 候选共用)。
4949
///
@@ -117,6 +117,15 @@ pub struct FilterStats {
117117
/// v0.7.5 扩展:修辞问句删除数(自问自答)
118118
#[serde(default)]
119119
pub rhetorical: usize,
120+
/// v0.7.6(REQ-180):结构渲染——插入的章节标题数
121+
#[serde(default)]
122+
pub chapters: usize,
123+
/// v0.7.6(REQ-180):结构渲染——有 outline 标题命中的章节数
124+
#[serde(default)]
125+
pub titled_chapters: usize,
126+
/// v0.7.6(REQ-180):结构渲染——词汇表条目数
127+
#[serde(default)]
128+
pub glossary_terms: usize,
120129
}
121130

122131
/// 合并条目(相邻重复合并 / AI merge 展示层拼接)。

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,92 @@ fn open_question_kept_in_note() {
300300
assert_eq!(result.stats.rhetorical, 0);
301301
assert!(result.markdown.contains("大家思考一下为什么"));
302302
}
303+
304+
// ────────────────────────────────────────────────
305+
// v0.7.6 扩展(REQ-181):结构渲染黄金语料——章节标题 + 词汇表
306+
// ────────────────────────────────────────────────
307+
308+
/// 会话31 实证:净化产物 → 结构渲染叠加章节标题(outline 命中命名)。
309+
///
310+
/// @ai-context: 模拟命令层接线顺序(filter_note → apply_session_warning →
311+
/// refresh_screen_points → render_note_structure);章节边界由
312+
/// chapter_detect 语义构造(网课档案)——本测试钉结构渲染产出,
313+
/// 章节检测本身由 chapter_detect_tests 覆盖。
314+
#[test]
315+
fn session31_structure_chapter_headings_from_outline() {
316+
// Arrange:两章内容(边界 9000ms/30000ms)+ 章节窗口内 outline 标题
317+
let segments = vec![
318+
asr(1, 0, 5000, "项目启动是一个艺术"),
319+
asr(2, 10_000, 15_000, "这是项目章程"),
320+
asr(3, 35_000, 40_000, "这是项目范围说明书"),
321+
];
322+
let blocks = vec![
323+
block(2_000, "项目启动", 0.9),
324+
block(12_000, "项目章程", 0.9),
325+
block(36_000, "项目范围", 0.9),
326+
];
327+
let mut result = run("测试", &segments, &blocks);
328+
// Act:命令层接线语义——警示行(finished 无)+ 刷新 + 结构渲染
329+
apply_session_warning(&mut result, "finished");
330+
refresh_screen_points(&mut result);
331+
let chapters = vec![
332+
crate::chapter_detect::ChapterBoundary { time_ms: 9_000, votes: 2, topic_drop: 0.5 },
333+
crate::chapter_detect::ChapterBoundary { time_ms: 30_000, votes: 2, topic_drop: 0.4 },
334+
];
335+
let outline = vec![
336+
crate::outline::OutlineEntry { time_ms: 12_000, text: "项目章程".to_string() },
337+
crate::outline::OutlineEntry { time_ms: 36_000, text: "项目范围".to_string() },
338+
];
339+
let _ = crate::structure_note::render_note_structure(
340+
&mut result,
341+
&chapters,
342+
&outline,
343+
&[],
344+
&crate::structure_note::NoteStructureConfig::default(),
345+
);
346+
// Assert:两章标题带时间锚点;章节名取各自窗口内 outline 标题
347+
// (第一章窗口 [9s,30s) 命中 12s"项目章程";第二章窗口 [30s,∞) 命中
348+
// 36s"项目范围"——窗口归属正确,跨窗口不串)
349+
assert!(result.markdown.contains("## 项目章程 [00:09]"), "第一章取窗口内标题");
350+
assert!(result.markdown.contains("## 项目范围 [00:30]"), "第二章取窗口内标题");
351+
// 统计可序列化(purify_stats 落库口径)
352+
let json = serde_json::to_string(&result.stats).expect("stats serializable");
353+
assert!(json.contains("chapters") && json.contains("titled_chapters"));
354+
}
355+
356+
/// 会话31 实证:结构渲染词汇表块——术语候选 → 尾部词汇表(锚点回跳)。
357+
#[test]
358+
fn session31_structure_glossary_block() {
359+
// Arrange:术语"项目章程"在 kept 段出现(锚点);无 kept 出现术语不带锚点
360+
let segments = vec![asr(1, 12_000, 15_000, "这是项目章程的要点")];
361+
let mut result = run("测试", &segments, &[]);
362+
apply_session_warning(&mut result, "finished");
363+
refresh_screen_points(&mut result);
364+
let glossary = vec![
365+
crate::glossary::GlossaryCandidate {
366+
term: "项目章程".to_string(),
367+
ocr_count: 5,
368+
asr_count: 1,
369+
score: 8.0,
370+
},
371+
crate::glossary::GlossaryCandidate {
372+
term: "WBS".to_string(),
373+
ocr_count: 3,
374+
asr_count: 0,
375+
score: 3.0,
376+
},
377+
];
378+
// Act
379+
let _ = crate::structure_note::render_note_structure(
380+
&mut result,
381+
&[],
382+
&[],
383+
&glossary,
384+
&crate::structure_note::NoteStructureConfig::default(),
385+
);
386+
// Assert:词汇表块在尾部;命中术语带 [MM:SS] 锚点;未命中不带;统计落库
387+
assert!(result.markdown.ends_with("词汇表\n\n- [00:12] 项目章程(画面 ×5 / 语音 ×1)\n- WBS(画面 ×3 / 语音 ×0)"));
388+
assert_eq!(result.stats.glossary_terms, 2);
389+
let json = serde_json::to_string(&result.stats).expect("stats serializable");
390+
assert!(json.contains("glossary_terms"));
391+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ pub struct PurifyConfig {
6464
pub ocr_correct: bool,
6565
/// 段落时间戳锚点 [MM:SS] 前缀(REQ-165,可开关)
6666
pub anchor_timestamps: bool,
67+
/// v0.7.6(REQ-179):结构渲染配置(章节标题/词汇表块——嵌套 JSON 可校准)
68+
pub structure: crate::structure_note::NoteStructureConfig,
6769
}
6870

6971
impl Default for PurifyConfig {
@@ -92,6 +94,7 @@ impl Default for PurifyConfig {
9294
filler_delete: true,
9395
ocr_correct: true,
9496
anchor_timestamps: true,
97+
structure: crate::structure_note::NoteStructureConfig::default(),
9598
}
9699
}
97100
}

0 commit comments

Comments
 (0)