|
| 1 | +//! 组结算 commands(v0.11.3;防沼泽仪式系统层)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 结算必须是用户可见的仪式而非静默后台(v4 路线图 Step 6): |
| 4 | +//! settlement_plan 先呈现候选(重复合并对 + 归档候选),用户 |
| 5 | +//! 确认后 execute_settlement 执行;核心提炼为本地规则版组核心笔记。 |
| 6 | +//! @ai-context: 归档不删除(fragments.status=archived 可恢复);合并=保留长文本 |
| 7 | +//! 归档短重复项;结算记录落 settlements + metrics(group_settled) |
| 8 | +//! (北极星组成③)。 |
| 9 | +//! @ai-context: 本层只做参数校验、编排数据层/纯函数、错误映射(AGENTS.md §6)。 |
| 10 | +
|
| 11 | +use serde::Serialize; |
| 12 | +use tauri::State; |
| 13 | + |
| 14 | +use crate::commands::AppState; |
| 15 | +use crate::settlement::{find_merge_pairs, settlement_due, SettlementSignals, ARCHIVE_AGE_DAYS}; |
| 16 | +use crate::types::NewNote; |
| 17 | + |
| 18 | +/// 合并对呈现(前端展示由用户确认)。 |
| 19 | +#[derive(Debug, Clone, Serialize)] |
| 20 | +#[serde(rename_all = "camelCase")] |
| 21 | +pub struct MergePairView { |
| 22 | + pub keep_id: i64, |
| 23 | + pub drop_id: i64, |
| 24 | + pub keep_text: String, |
| 25 | + pub drop_text: String, |
| 26 | +} |
| 27 | + |
| 28 | +/// 归档候选呈现。 |
| 29 | +#[derive(Debug, Clone, Serialize)] |
| 30 | +#[serde(rename_all = "camelCase")] |
| 31 | +pub struct ArchiveCandidateView { |
| 32 | + pub id: i64, |
| 33 | + pub text: String, |
| 34 | +} |
| 35 | + |
| 36 | +/// 结算计划(仪式第一步:呈现——用户看见沼泽全貌)。 |
| 37 | +#[derive(Debug, Clone, Serialize)] |
| 38 | +#[serde(rename_all = "camelCase")] |
| 39 | +pub struct SettlementPlan { |
| 40 | + pub item_count: usize, |
| 41 | + pub due: bool, |
| 42 | + pub last_settled_at: Option<i64>, |
| 43 | + pub merge_pairs: Vec<MergePairView>, |
| 44 | + pub archive_candidates: Vec<ArchiveCandidateView>, |
| 45 | +} |
| 46 | + |
| 47 | +/// 结算结果(仪式收尾:留痕可溯)。 |
| 48 | +#[derive(Debug, Clone, Serialize)] |
| 49 | +#[serde(rename_all = "camelCase")] |
| 50 | +pub struct SettlementResult { |
| 51 | + pub merged: usize, |
| 52 | + pub archived: usize, |
| 53 | + pub core_note_id: Option<i64>, |
| 54 | +} |
| 55 | + |
| 56 | +/// 生成结算计划(阈值/周期判定 + 重复合并对 + 归档候选)。 |
| 57 | +#[tauri::command] |
| 58 | +pub fn settlement_plan(state: State<'_, AppState>, group_id: i64) -> Result<SettlementPlan, String> { |
| 59 | + if group_id <= 0 { |
| 60 | + return Err("无效的组 id".to_string()); |
| 61 | + } |
| 62 | + settlement_plan_inner(&state, group_id) |
| 63 | +} |
| 64 | + |
| 65 | +/// 执行结算(仪式第二步:按用户选择应用合并/归档 + 核心提炼 + 留痕)。 |
| 66 | +/// |
| 67 | +/// @ai-context: 计划在执行时重算(防陈旧计划误伤——呈现与执行间的窗口内 |
| 68 | +/// 可能有新碎片进组);核心提炼为本地规则版(组核心笔记:笔记 |
| 69 | +/// 目录 + 活跃碎片摘要),AI 精修可选增强留 V1.0。 |
| 70 | +#[tauri::command] |
| 71 | +pub fn execute_settlement( |
| 72 | + state: State<'_, AppState>, |
| 73 | + group_id: i64, |
| 74 | + apply_merges: bool, |
| 75 | + apply_archives: bool, |
| 76 | +) -> Result<SettlementResult, String> { |
| 77 | + if group_id <= 0 { |
| 78 | + return Err("无效的组 id".to_string()); |
| 79 | + } |
| 80 | + // 重算计划(新鲜口径) |
| 81 | + let state_clone: AppState = (*state).clone(); |
| 82 | + let plan = settlement_plan_inner(&state_clone, group_id)?; |
| 83 | + let mut merged = 0usize; |
| 84 | + let mut archived = 0usize; |
| 85 | + if apply_merges { |
| 86 | + for pair in &plan.merge_pairs { |
| 87 | + if state.db.set_fragment_status(pair.drop_id, "archived").map_err(|e| e.to_string())? { |
| 88 | + merged += 1; |
| 89 | + } |
| 90 | + } |
| 91 | + } |
| 92 | + if apply_archives { |
| 93 | + for cand in &plan.archive_candidates { |
| 94 | + if state.db.set_fragment_status(cand.id, "archived").map_err(|e| e.to_string())? { |
| 95 | + archived += 1; |
| 96 | + } |
| 97 | + } |
| 98 | + } |
| 99 | + // 核心提炼:组核心笔记(本地规则——笔记目录 + 碎片摘要,落组内) |
| 100 | + let core_note_id = create_core_note(&state_clone, group_id)?; |
| 101 | + // 留痕:结算记录 + 北极星埋点(组成③经历过结算) |
| 102 | + let stats = serde_json::json!({ |
| 103 | + "merged": merged, "archived": archived, "coreNoteId": core_note_id, |
| 104 | + "itemCountBefore": plan.item_count, |
| 105 | + }) |
| 106 | + .to_string(); |
| 107 | + state.db.create_settlement(group_id, &stats).map_err(|e| e.to_string())?; |
| 108 | + let payload = serde_json::json!({ "groupId": group_id, "merged": merged, "archived": archived }).to_string(); |
| 109 | + let _ = state.db.add_metric_event("group_settled", &payload); |
| 110 | + Ok(SettlementResult { merged, archived, core_note_id }) |
| 111 | +} |
| 112 | + |
| 113 | +/// settlement_plan 的内部实现(command 与 execute 复用,避免 State 二次借用)。 |
| 114 | +fn settlement_plan_inner(state: &AppState, group_id: i64) -> Result<SettlementPlan, String> { |
| 115 | + let group = state |
| 116 | + .db |
| 117 | + .get_group(group_id) |
| 118 | + .map_err(|e| e.to_string())? |
| 119 | + .ok_or_else(|| format!("笔记组不存在: {}", group_id))?; |
| 120 | + let fragments = state.db.list_fragments_by_group(group_id).map_err(|e| e.to_string())?; |
| 121 | + let notes = state.db.list_notes_by_group(group_id).map_err(|e| e.to_string())?; |
| 122 | + let now_secs = crate::db::unix_seconds(); |
| 123 | + let last_settled_at = state.db.latest_settlement_at(group_id).map_err(|e| e.to_string())?; |
| 124 | + let item_count = fragments.len() + notes.len(); |
| 125 | + let due = settlement_due(&SettlementSignals { |
| 126 | + item_count, |
| 127 | + last_settled_at, |
| 128 | + created_at: group.created_at, |
| 129 | + now_secs, |
| 130 | + }); |
| 131 | + let items: Vec<(i64, String)> = |
| 132 | + fragments.iter().take(200).map(|f| (f.id, f.text.clone())).collect(); |
| 133 | + let texts: std::collections::HashMap<i64, &str> = |
| 134 | + fragments.iter().map(|f| (f.id, f.text.as_str())).collect(); |
| 135 | + let merge_pairs = find_merge_pairs(&items) |
| 136 | + .into_iter() |
| 137 | + .filter_map(|(keep, drop)| { |
| 138 | + Some(MergePairView { |
| 139 | + keep_id: keep, |
| 140 | + drop_id: drop, |
| 141 | + keep_text: texts.get(&keep)?.to_string(), |
| 142 | + drop_text: texts.get(&drop)?.to_string(), |
| 143 | + }) |
| 144 | + }) |
| 145 | + .collect(); |
| 146 | + let age_cutoff = now_secs - ARCHIVE_AGE_DAYS * 86_400; |
| 147 | + let mut archive_candidates = Vec::new(); |
| 148 | + for f in &fragments { |
| 149 | + if f.created_at < age_cutoff && !state.db.fragment_has_card(f.id).map_err(|e| e.to_string())? { |
| 150 | + archive_candidates.push(ArchiveCandidateView { id: f.id, text: f.text.clone() }); |
| 151 | + } |
| 152 | + } |
| 153 | + Ok(SettlementPlan { item_count, due, last_settled_at, merge_pairs, archive_candidates }) |
| 154 | +} |
| 155 | + |
| 156 | +/// 组核心笔记(本地规则提炼:笔记目录 + 活跃碎片摘要)。 |
| 157 | +fn create_core_note(state: &AppState, group_id: i64) -> Result<Option<i64>, String> { |
| 158 | + let group = match state.db.get_group(group_id).map_err(|e| e.to_string())? { |
| 159 | + Some(g) => g, |
| 160 | + None => return Ok(None), |
| 161 | + }; |
| 162 | + let notes = state.db.list_notes_by_group(group_id).map_err(|e| e.to_string())?; |
| 163 | + let fragments = state.db.list_fragments_by_group(group_id).map_err(|e| e.to_string())?; |
| 164 | + if notes.is_empty() && fragments.is_empty() { |
| 165 | + return Ok(None); // 空组无核心可提炼(诚实不造假燃料) |
| 166 | + } |
| 167 | + let mut md = format!("# {} · 结算提炼\n\n", group.name); |
| 168 | + if !notes.is_empty() { |
| 169 | + md.push_str("## 组内笔记\n\n"); |
| 170 | + for n in ¬es { |
| 171 | + md.push_str(&format!("- {}\n", n.title)); |
| 172 | + } |
| 173 | + md.push('\n'); |
| 174 | + } |
| 175 | + if !fragments.is_empty() { |
| 176 | + md.push_str("## 活跃碎片摘要\n\n"); |
| 177 | + for f in fragments.iter().take(30) { |
| 178 | + let excerpt: String = f.text.chars().take(50).collect(); |
| 179 | + md.push_str(&format!("- {}\n", excerpt)); |
| 180 | + } |
| 181 | + } |
| 182 | + let note = state |
| 183 | + .db |
| 184 | + .create_note(&NewNote { |
| 185 | + title: format!("{} · 结算提炼", group.name), |
| 186 | + content: md, |
| 187 | + source: "manual".to_string(), |
| 188 | + session_id: None, |
| 189 | + rule_version: None, |
| 190 | + purify_stats: None, |
| 191 | + tags: None, |
| 192 | + properties: None, |
| 193 | + group_id: Some(group_id), |
| 194 | + }) |
| 195 | + .map_err(|e| e.to_string())?; |
| 196 | + Ok(Some(note.id)) |
| 197 | +} |
0 commit comments