Skip to content

Commit 55ca4cf

Browse files
committed
feat(groups): 笔记组数据模型与容器侧组化接线
1 parent 5d1f5cd commit 55ca4cf

18 files changed

Lines changed: 1048 additions & 37 deletions

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ pub async fn save_draft_as_note(state: State<'_, AppState>, draft: NoteDraft) ->
231231
purify_stats: None,
232232
tags: None,
233233
properties: None,
234+
group_id: None,
234235
};
235236
state.db.create_note(&new).map_err(|e| e.to_string())
236237
}
@@ -315,6 +316,7 @@ pub async fn process_to_note(
315316
purify_stats: None,
316317
tags: None,
317318
properties: None,
319+
group_id: None,
318320
})
319321
.map_err(|e| e.to_string())
320322
})
@@ -325,6 +327,12 @@ pub async fn process_to_note(
325327
/// 手动新建笔记(REQ-004)。
326328
#[tauri::command]
327329
pub async fn create_note(state: State<'_, AppState>, new: NewNote) -> Result<Note, String> {
330+
// v0.11.0 入参校验:指定组必须存在(外键拦截前置为可诊断错误)
331+
if let Some(gid) = new.group_id {
332+
if gid <= 0 || state.db.get_group(gid).map_err(|e| e.to_string())?.is_none() {
333+
return Err(format!("指定的笔记组不存在: {}", gid));
334+
}
335+
}
328336
let new = NewNote {
329337
title: normalize_title(new.title, "未命名笔记"),
330338
content: truncate_chars(new.content, CONTENT_MAX_CHARS),
@@ -336,6 +344,8 @@ pub async fn create_note(state: State<'_, AppState>, new: NewNote) -> Result<Not
336344
purify_stats: None,
337345
tags: None,
338346
properties: None,
347+
// v0.11.0:手动建笔记可直接指定组(组视图内新建;无效 id 由外键拦截)
348+
group_id: new.group_id,
339349
};
340350
state.db.create_note(&new).map_err(|e| e.to_string())
341351
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,9 @@ pub fn ai_refine_apply(
268268
purify_stats: None,
269269
tags: None,
270270
properties: None,
271+
// v0.11.0:精修基线笔记继承会话组归属(组化接线在会话转笔记链路写入,
272+
// 精修新建基线同样归组——同会话同组,防组内资产漏登)
273+
group_id: crate::note_group_assign::group_of_session(&state.db, session_id).ok().flatten(),
271274
};
272275
let note = state.db.create_note(&new).map_err(|e| e.to_string())?;
273276
// ② 精修版落库(新版本 ai-refine + 成本 meta)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ pub async fn artifact_to_note(
101101
purify_stats: None,
102102
tags: None,
103103
properties: None,
104+
// v0.11.0:产物笔记继承会话组归属(同会话同组;会话未归组则 None)
105+
group_id: crate::note_group_assign::group_of_session(&state.db, session_id)
106+
.ok()
107+
.flatten(),
104108
})
105109
.map_err(|e| e.to_string())
106110
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//! 笔记组 commands(v0.11.0 REQ-195~198 系统层)。
2+
//!
3+
//! @ai-context: 本层只做参数校验、调用数据层、错误映射(AGENTS.md §6);
4+
//! 路由决策在 group_route.rs(纯函数),组化编排在 note_group_assign.rs。
5+
//! @ai-context: REQ-198 可见可改——override_group_route 落"修改即记忆"标记,
6+
//! 改判样本(route_reason)留痕供后续阈值校准。
7+
8+
use tauri::State;
9+
10+
use crate::commands::{normalize_title, AppState};
11+
use crate::types::{NewNoteGroup, Note, NoteGroup};
12+
use crate::video_profile_domain::DomainKind;
13+
14+
/// 组类别白名单(非法值拒绝——前端枚举同口径)。
15+
const GROUP_KINDS: [&str; 3] = ["course", "topic", "standalone"];
16+
17+
/// 组类别校验(白名单前置,防任意字符串入库)。
18+
fn require_kind(kind: &str) -> Result<(), String> {
19+
if GROUP_KINDS.contains(&kind) {
20+
Ok(())
21+
} else {
22+
Err(format!("不支持的组类别: {}(支持: {})", kind, GROUP_KINDS.join("/")))
23+
}
24+
}
25+
26+
/// 领域标签校验(kebab-case 白名单;空 → None)。
27+
fn parse_domain(domain_tag: Option<String>) -> Result<Option<String>, String> {
28+
match domain_tag {
29+
Some(t) if !t.trim().is_empty() => DomainKind::parse(t.trim())
30+
.map(|k| Some(k.as_str().to_string()))
31+
.ok_or_else(|| format!("不支持的领域标签: {}", t)),
32+
_ => Ok(None),
33+
}
34+
}
35+
36+
/// 列出笔记组(含组内笔记数;terrain 可选过滤 container/feed)。
37+
#[tauri::command]
38+
pub fn list_note_groups(
39+
state: State<'_, AppState>,
40+
terrain: Option<String>,
41+
) -> Result<Vec<NoteGroup>, String> {
42+
let terrain = terrain.as_deref().filter(|t| !t.trim().is_empty());
43+
if let Some(t) = terrain {
44+
if t != "container" && t != "feed" {
45+
return Err(format!("不支持的地形: {}(支持: container/feed)", t));
46+
}
47+
}
48+
state.db.list_groups(terrain).map_err(|e| e.to_string())
49+
}
50+
51+
/// 单个组详情(不存在 → 错误;前端按 id 刷新用)。
52+
#[tauri::command]
53+
pub fn get_note_group(state: State<'_, AppState>, id: i64) -> Result<NoteGroup, String> {
54+
if id <= 0 {
55+
return Err("无效的组 id".to_string());
56+
}
57+
state
58+
.db
59+
.get_group(id)
60+
.map_err(|e| e.to_string())?
61+
.ok_or_else(|| format!("笔记组不存在: {}", id))
62+
}
63+
64+
/// 组内笔记列表(组详情面板数据源)。
65+
#[tauri::command]
66+
pub fn list_group_notes(state: State<'_, AppState>, group_id: i64) -> Result<Vec<Note>, String> {
67+
if group_id <= 0 {
68+
return Err("无效的组 id".to_string());
69+
}
70+
state.db.list_notes_by_group(group_id).map_err(|e| e.to_string())
71+
}
72+
73+
/// 用户自建主题组(manual——契约一:粒度对齐领域,领域标签必填)。
74+
#[tauri::command]
75+
pub fn create_topic_group(
76+
state: State<'_, AppState>,
77+
name: String,
78+
domain_tag: String,
79+
terrain: Option<String>,
80+
) -> Result<NoteGroup, String> {
81+
let name = normalize_title(name, "未命名组");
82+
let domain = parse_domain(Some(domain_tag))?
83+
.ok_or_else(|| "主题组必须指定领域标签".to_string())?;
84+
let terrain = terrain.unwrap_or_else(|| "container".to_string());
85+
if terrain != "container" && terrain != "feed" {
86+
return Err(format!("不支持的地形: {}", terrain));
87+
}
88+
// 同领域同地形已有主题组 → 直接复用(契约一防重复抽屉)
89+
if let Some(existing) = state.db.find_topic_group(&domain, &terrain).map_err(|e| e.to_string())? {
90+
return Ok(existing);
91+
}
92+
let label = DomainKind::parse(&domain).map(|k| k.label()).unwrap_or("主题组");
93+
state
94+
.db
95+
.create_group(&NewNoteGroup {
96+
name: if name == "未命名组" { label.to_string() } else { name },
97+
terrain,
98+
kind: "topic".to_string(),
99+
domain_tag: Some(domain),
100+
source: "manual".to_string(),
101+
series_key: None,
102+
route_reason: None,
103+
})
104+
.map_err(|e| e.to_string())
105+
}
106+
107+
/// 重命名组。
108+
#[tauri::command]
109+
pub fn rename_note_group(
110+
state: State<'_, AppState>,
111+
id: i64,
112+
name: String,
113+
) -> Result<bool, String> {
114+
if id <= 0 {
115+
return Err("无效的组 id".to_string());
116+
}
117+
let name = normalize_title(name, "未命名组");
118+
state.db.rename_group(id, &name).map_err(|e| e.to_string())
119+
}
120+
121+
/// 路由改判(REQ-198 修改即记忆):改组类别/领域,留痕 route_reason。
122+
#[tauri::command]
123+
pub fn override_group_route(
124+
state: State<'_, AppState>,
125+
id: i64,
126+
kind: String,
127+
domain_tag: Option<String>,
128+
) -> Result<bool, String> {
129+
if id <= 0 {
130+
return Err("无效的组 id".to_string());
131+
}
132+
require_kind(&kind)?;
133+
let domain = parse_domain(domain_tag)?;
134+
// 主题组改判必须带领域(契约一:主题组粒度对齐领域)
135+
if kind == "topic" && domain.is_none() {
136+
return Err("改判为主题组必须指定领域标签".to_string());
137+
}
138+
let reason = format!(
139+
"用户改判:{}{}",
140+
kind,
141+
domain.as_deref().map(|d| format!("({})", d)).unwrap_or_default()
142+
);
143+
state
144+
.db
145+
.override_group_route(id, &kind, domain.as_deref(), &reason)
146+
.map_err(|e| e.to_string())
147+
}
148+
149+
/// 移动笔记到组(group_id=None 移出组——手动纠错路由误判的兜底路径)。
150+
#[tauri::command]
151+
pub fn move_note_to_group(
152+
state: State<'_, AppState>,
153+
note_id: i64,
154+
group_id: Option<i64>,
155+
) -> Result<bool, String> {
156+
if note_id <= 0 {
157+
return Err("无效的笔记 id".to_string());
158+
}
159+
if let Some(gid) = group_id {
160+
if gid <= 0 {
161+
return Err("无效的组 id".to_string());
162+
}
163+
if state.db.get_group(gid).map_err(|e| e.to_string())?.is_none() {
164+
return Err(format!("笔记组不存在: {}", gid));
165+
}
166+
}
167+
state.db.update_note_group(note_id, group_id).map_err(|e| e.to_string())
168+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ fn make_note(session_id: Option<i64>) -> Note {
2626
purify_stats: None,
2727
tags: None,
2828
properties: None,
29+
group_id: None,
2930
};
3031
db.create_note(&new).expect("create")
3132
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ fn batch_skips_already_converted_session() {
110110
purify_stats: None,
111111
tags: None,
112112
properties: None,
113+
group_id: None,
113114
})
114115
.expect("create note");
115116
// Act

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

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -129,23 +129,6 @@ pub(crate) fn apply_note_structure_with_analysis(
129129
);
130130
}
131131

132-
/// 构建规则草稿(REQ-081 单一管线三出口:预览/落库/AI 精修共用——
133-
/// 净化→配图→警示→结构渲染同函数同口径;v0.8.0 M2 提取供 ai_refine 任务
134-
/// 复用,保证 AI 精修的输入基线 = 规则版输出基线)。
135-
///
136-
/// @ai-context: 返回 NoteFilterResult(markdown 即规则版输出);AI 精修任务
137-
/// 在其上追加章节/术语上下文(analyze 另跑)后构建 AiRefineRequest。
138-
pub(crate) fn build_rule_draft(
139-
db: &Db,
140-
ui_junk: &UiJunkList,
141-
env: &PurifyEnv,
142-
data_dir: &std::path::Path,
143-
id: i64,
144-
title: Option<String>,
145-
) -> Result<NoteFilterResult, String> {
146-
Ok(build_rule_draft_with_analysis(db, ui_junk, env, data_dir, id, title)?.0)
147-
}
148-
149132
/// 构建规则草稿 + 结构分析(审查修复 2026-08-21:分析与结构渲染一次完成并
150133
/// 返回 analysis——AI 精修任务直接复用章节/术语,消除二次 analyze 双跑)。
151134
pub(crate) fn build_rule_draft_with_analysis(
@@ -193,10 +176,31 @@ fn convert_to_note(
193176
title: Option<String>,
194177
ai_decisions: Option<Vec<TextFilterDecision>>,
195178
) -> Result<Note, String> {
196-
let mut result = build_rule_draft(db, ui_junk, env, data_dir, id, title)?;
179+
// v0.11.0:取草稿同时拿 analysis(章节/术语——结构密度路由信号,免二次分析)
180+
let (mut result, analysis) =
181+
build_rule_draft_with_analysis(db, ui_junk, env, data_dir, id, title)?;
197182
if let Some(decisions) = ai_decisions {
198183
result = apply_ai_decisions(result, &decisions);
199184
}
185+
// v0.11.0(REQ-197):容器侧组化——系列课程组/结构密度路由;组化失败
186+
// 不阻断转笔记主链路(笔记先落库,group_id=None 诚实降级)
187+
let group_id = match load_note_material(db, id) {
188+
Ok((session, segments, ocr_blocks)) => {
189+
match crate::note_group_assign::resolve_group_for_session(
190+
db, &session, &analysis, &segments, &ocr_blocks,
191+
) {
192+
Ok(gid) => Some(gid),
193+
Err(e) => {
194+
eprintln!("[groups] 组化失败(笔记照常落库,未归组): {e}");
195+
None
196+
}
197+
}
198+
}
199+
Err(e) => {
200+
eprintln!("[groups] 组化原料装载失败(笔记照常落库): {e}");
201+
None
202+
}
203+
};
200204
let new = NewNote {
201205
title: result.title.clone(),
202206
content: result.markdown.clone(),
@@ -207,6 +211,7 @@ fn convert_to_note(
207211
purify_stats: Some(serde_json::to_string(&result.stats).unwrap_or_default()),
208212
tags: None,
209213
properties: None,
214+
group_id,
210215
};
211216
db.create_note(&new).map_err(|e| e.to_string())
212217
}

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,26 @@ pub(crate) fn init_schema(conn: &Connection) -> Result<()> {
7979
payload_json TEXT NOT NULL DEFAULT '{}'
8080
);
8181
CREATE INDEX IF NOT EXISTS idx_events_session ON session_events(session_id, timestamp_ms);
82-
CREATE INDEX IF NOT EXISTS idx_events_kind ON session_events(session_id, kind);",
82+
CREATE INDEX IF NOT EXISTS idx_events_kind ON session_events(session_id, kind);
83+
-- v0.11.0(REQ-195,v4 §7.4 融合点基建):笔记组——统一产物层唯一容器;
84+
-- terrain 为管线分叉前提(container/feed),kind 三种形成方式(课程/主题/独立)
85+
CREATE TABLE IF NOT EXISTS note_groups (
86+
id INTEGER PRIMARY KEY AUTOINCREMENT,
87+
name TEXT NOT NULL,
88+
terrain TEXT NOT NULL DEFAULT 'container',
89+
kind TEXT NOT NULL DEFAULT 'standalone',
90+
domain_tag TEXT,
91+
source TEXT NOT NULL DEFAULT 'route',
92+
series_key TEXT,
93+
route_reason TEXT,
94+
route_overridden INTEGER NOT NULL DEFAULT 0,
95+
created_at INTEGER NOT NULL,
96+
updated_at INTEGER NOT NULL
97+
);
98+
-- 课程组幂等键(同一系列名唯一;部分索引仅约束非 NULL)
99+
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_series
100+
ON note_groups(series_key) WHERE series_key IS NOT NULL;
101+
CREATE INDEX IF NOT EXISTS idx_groups_domain ON note_groups(domain_tag);",
83102
)?;
84103
// v0.5.0 M1(REQ-043):旧库迁移——sessions 表补 profile 列(兼容既有数据库)
85104
ensure_column(conn, "sessions", "profile", "ALTER TABLE sessions ADD COLUMN profile TEXT")?;
@@ -177,6 +196,16 @@ pub(crate) fn init_schema(conn: &Connection) -> Result<()> {
177196
)?;
178197
// 关联查询索引(列表 has_note 子查询 + 笔记页来源跳转共用)
179198
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_notes_session ON notes(session_id)")?;
199+
// v0.11.0(REQ-195):notes 补 group_id 列——笔记↔组关联
200+
// (删组 ON DELETE SET NULL:笔记是用户资产,只断关联不删笔记;
201+
// 旧数据 NULL=未归组,不猜不填——ADR-014 先例)
202+
ensure_column(
203+
conn,
204+
"notes",
205+
"group_id",
206+
"ALTER TABLE notes ADD COLUMN group_id INTEGER REFERENCES note_groups(id) ON DELETE SET NULL",
207+
)?;
208+
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_notes_group ON notes(group_id)")?;
180209
// v0.7.7(REQ-183):结构图记录表(建表幂等——新库建表/旧库补表)
181210
crate::db_structures::init(conn)?;
182211
// v0.8.0 M4(REQ-144):笔记版本快照链 + AI 成本记录(幂等建表)

0 commit comments

Comments
 (0)