Skip to content

Commit 497de33

Browse files
committed
feat(settlement): 结算数据层与计划执行命令
1 parent 420f54d commit 497de33

9 files changed

Lines changed: 377 additions & 1 deletion

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,25 @@ impl Rating {
194194
.to_string()
195195
}
196196
}
197+
198+
/// 学习循环指标读数(v4 §8 过程指标可见化——防测量层过度建设的前提是先有读数)。
199+
#[derive(Debug, Clone, serde::Serialize)]
200+
#[serde(rename_all = "camelCase")]
201+
pub struct LearningMetrics {
202+
pub card_reviewed: i64,
203+
pub fragment_upgraded: i64,
204+
pub group_settled: i64,
205+
}
206+
207+
/// 学习循环指标(北极星过程读数:复习次数/碎片升级数/组结算数)。
208+
#[tauri::command]
209+
pub fn learning_metrics(state: State<'_, AppState>) -> Result<LearningMetrics, String> {
210+
Ok(LearningMetrics {
211+
card_reviewed: state.db.count_metric_events("card_reviewed").map_err(|e| e.to_string())?,
212+
fragment_upgraded: state
213+
.db
214+
.count_metric_events("fragment_upgraded")
215+
.map_err(|e| e.to_string())?,
216+
group_settled: state.db.count_metric_events("group_settled").map_err(|e| e.to_string())?,
217+
})
218+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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 &notes {
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+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ impl Db {
154154
}
155155

156156
/// 组是否有复习记录(北极星组成①;经 flashcards.group_id 关联)。
157+
/// 登记豁免 dead_code:按组北极星展示接线在 V1.0 周报面(v4 §7.5)。
158+
#[allow(dead_code)]
157159
pub fn group_has_reviews(&self, group_id: i64) -> Result<bool> {
158160
self.with_conn(|conn| {
159161
let count: i64 = conn.query_row(

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ impl Db {
8585
}
8686

8787
/// 碎片计数(组结算触发信号;按组+status 统计)。
88+
/// 登记豁免 dead_code:结算触发器当前用 list 长度折算,计数接口留
89+
/// 给 v0.11.3+ 阈值埋点面板消费。
90+
#[allow(dead_code)]
8891
pub fn count_fragments(&self, group_id: Option<i64>, status: Option<&str>) -> Result<i64> {
8992
self.with_conn(|conn| {
9093
let mut sql = "SELECT COUNT(*) FROM fragments WHERE 1=1".to_string();
@@ -106,6 +109,8 @@ impl Db {
106109
}
107110

108111
/// 移动碎片到组(None=移出;用户纠错/结算归组共用)。
112+
/// 登记豁免 dead_code:碎片移动 UI 随 v0.11.3+ 结算面建设接线。
113+
#[allow(dead_code)]
109114
pub fn update_fragment_group(&self, id: i64, group_id: Option<i64>) -> Result<bool> {
110115
self.with_conn(|conn| {
111116
let affected = conn.execute(

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,15 @@ pub(crate) fn init_schema(conn: &Connection) -> Result<()> {
142142
payload_json TEXT NOT NULL DEFAULT '{}',
143143
created_at INTEGER NOT NULL
144144
);
145-
CREATE INDEX IF NOT EXISTS idx_metrics_kind ON metrics_events(kind);",
145+
CREATE INDEX IF NOT EXISTS idx_metrics_kind ON metrics_events(kind);
146+
-- v0.11.3(组结算机制;防沼泽仪式记录——结算历史可追溯)
147+
CREATE TABLE IF NOT EXISTS settlements (
148+
id INTEGER PRIMARY KEY AUTOINCREMENT,
149+
group_id INTEGER NOT NULL REFERENCES note_groups(id) ON DELETE CASCADE,
150+
stats_json TEXT NOT NULL,
151+
created_at INTEGER NOT NULL
152+
);
153+
CREATE INDEX IF NOT EXISTS idx_settlements_group ON settlements(group_id, created_at);",
146154
)?;
147155
// v0.5.0 M1(REQ-043):旧库迁移——sessions 表补 profile 列(兼容既有数据库)
148156
ensure_column(conn, "sessions", "profile", "ALTER TABLE sessions ADD COLUMN profile TEXT")?;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//! 结算数据层(v0.11.3;settlements 记录 + 归档候选查询)。
2+
//!
3+
//! @ai-context: 结算历史是周期触发器的判据源(last_settled_at)与北极星
4+
//! 组成③(经历过结算);归档不删除——fragments.status 翻转可恢复。
5+
6+
use rusqlite::params;
7+
8+
use crate::db::{unix_seconds, Db};
9+
use crate::error::Result;
10+
11+
impl Db {
12+
/// 记一次结算(stats_json=提炼/合并/归档统计)。
13+
pub fn create_settlement(&self, group_id: i64, stats_json: &str) -> Result<i64> {
14+
let now = unix_seconds();
15+
self.with_conn(|conn| {
16+
conn.execute(
17+
"INSERT INTO settlements (group_id, stats_json, created_at) VALUES (?1, ?2, ?3)",
18+
params![group_id, stats_json, now],
19+
)?;
20+
Ok(conn.last_insert_rowid() as i64)
21+
})
22+
}
23+
24+
/// 组最近一次结算时刻(None=从未结算——周期判定回退建组时刻)。
25+
pub fn latest_settlement_at(&self, group_id: i64) -> Result<Option<i64>> {
26+
self.with_conn(|conn| {
27+
let mut stmt = conn.prepare(
28+
"SELECT created_at FROM settlements WHERE group_id = ?1
29+
ORDER BY created_at DESC LIMIT 1",
30+
)?;
31+
let mut rows = stmt.query_map(params![group_id], |r| r.get::<_, i64>(0))?;
32+
match rows.next() {
33+
Some(Ok(t)) => Ok(Some(t)),
34+
Some(Err(e)) => Err(e.into()),
35+
None => Ok(None),
36+
}
37+
})
38+
}
39+
40+
/// 碎片是否有闪卡绑定(归档候选判据:有卡碎片不进归档——学习循环资产)。
41+
pub fn fragment_has_card(&self, fragment_id: i64) -> Result<bool> {
42+
self.with_conn(|conn| {
43+
let count: i64 = conn.query_row(
44+
"SELECT COUNT(*) FROM flashcards WHERE fragment_id = ?1",
45+
params![fragment_id],
46+
|r| r.get(0),
47+
)?;
48+
Ok(count > 0)
49+
})
50+
}
51+
}
52+
53+
/// 单测独立文件。
54+
#[cfg(test)]
55+
#[path = "db_settlements_tests.rs"]
56+
mod tests;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//! db_settlements 单测(内存库;AAA 模式)。
2+
3+
use crate::db::Db;
4+
use crate::db_flashcards::NewFlashcard;
5+
use crate::db_fragments::NewFragment;
6+
use crate::types::NewNoteGroup;
7+
8+
/// 内存库。
9+
fn mem_db() -> Db {
10+
Db::open(":memory:").expect("内存库打开")
11+
}
12+
13+
/// 建组助手。
14+
fn make_group(db: &Db) -> i64 {
15+
db.create_group(&NewNoteGroup {
16+
name: "结算组".to_string(),
17+
terrain: "feed".to_string(),
18+
kind: "topic".to_string(),
19+
domain_tag: Some("beauty".to_string()),
20+
source: "route".to_string(),
21+
series_key: None,
22+
route_reason: None,
23+
})
24+
.expect("group")
25+
.id
26+
}
27+
28+
/// 碎片入参助手。
29+
fn frag(group_id: i64, text: &str) -> NewFragment {
30+
NewFragment {
31+
text: text.to_string(),
32+
image_path: None,
33+
domain_tag: None,
34+
group_id: Some(group_id),
35+
source: "manual".to_string(),
36+
}
37+
}
38+
39+
#[test]
40+
fn settlement_record_and_latest_lookup() {
41+
// Arrange
42+
let db = mem_db();
43+
let g = make_group(&db);
44+
assert_eq!(db.latest_settlement_at(g).expect("none"), None);
45+
// Act:两次结算取最新
46+
db.create_settlement(g, r#"{"merged":1}"#).expect("s1");
47+
db.create_settlement(g, r#"{"merged":2}"#).expect("s2");
48+
// Assert:最近结算时刻可读(周期触发器判据源)
49+
assert!(db.latest_settlement_at(g).expect("some").is_some());
50+
}
51+
52+
#[test]
53+
fn fragment_card_binding_blocks_archive() {
54+
// Arrange:两碎片——一有卡绑定一没有
55+
let db = mem_db();
56+
let g = make_group(&db);
57+
let with_card = db.create_fragment(&frag(g, "有卡碎片")).expect("f1");
58+
let bare = db.create_fragment(&frag(g, "裸碎片")).expect("f2");
59+
db.create_card(&NewFlashcard {
60+
group_id: g,
61+
note_id: None,
62+
fragment_id: Some(with_card.id),
63+
front: "有卡碎片".to_string(),
64+
back: "back".to_string(),
65+
kind: "fact".to_string(),
66+
state_json: "{}".to_string(),
67+
due_at: 0,
68+
})
69+
.expect("card");
70+
// Act/Assert:绑定判定正确(有卡碎片不进归档——学习循环资产)
71+
assert!(db.fragment_has_card(with_card.id).expect("q1"));
72+
assert!(!db.fragment_has_card(bare.id).expect("q2"));
73+
}

0 commit comments

Comments
 (0)