Skip to content

Commit d9dc8cd

Browse files
committed
feat(cards): 闪卡数据层与复习评分命令
1 parent 80f1c38 commit d9dc8cd

6 files changed

Lines changed: 616 additions & 1 deletion

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
//! 闪卡与复习 commands(v0.11.2 学习循环统一系统层)。
2+
//!
3+
//! @ai-context: 组→闪卡生成为本地规则版(card_generate 纯函数)——AI 生成
4+
//! 为可选增强(复用 ai_client 授权/成本/审计全套),本版不接线。
5+
//! @ai-context: 复习链路=提取优先(前端先 front 后 back 再评分);调度走
6+
//! scheduler(FSRS-6);每次评分落 review_logs + metrics_events
7+
//! (北极星从第一天记——Phase 4 门控判据)。
8+
//! @ai-context: 本层只做参数校验、编排数据层/纯函数、错误映射(AGENTS.md §6)。
9+
10+
use tauri::State;
11+
12+
use crate::card_generate::{card_from_fragment, cards_from_note};
13+
use crate::commands::AppState;
14+
use crate::db_flashcards::NewFlashcard;
15+
use crate::scheduler::{schedule, CardState, Rating};
16+
use crate::types::Flashcard;
17+
18+
/// 复习队列单次上限(防无界查询)。
19+
const DUE_LIST_LIMIT_MAX: usize = 200;
20+
21+
/// 当前时刻(Unix 毫秒;调度/到期统一口径)。
22+
fn now_ms() -> u64 {
23+
std::time::SystemTime::now()
24+
.duration_since(std::time::UNIX_EPOCH)
25+
.map(|d| d.as_millis() as u64)
26+
.unwrap_or(0)
27+
}
28+
29+
/// 组→闪卡生成(本地规则版;幂等——同组同 front 不重复产卡)。
30+
///
31+
/// @ai-context: 双卡源:组内笔记词汇表块(术语卡)+ 组内碎片(多句卡);
32+
/// 新卡 due_at=now(立即可复习——首次学习也是提取优先);
33+
/// 碎片出卡记 fragment_upgraded 指标(碎片升级率=Phase 4 门控)。
34+
#[tauri::command]
35+
pub fn generate_group_cards(state: State<'_, AppState>, group_id: i64) -> Result<usize, String> {
36+
if group_id <= 0 {
37+
return Err("无效的组 id".to_string());
38+
}
39+
if state.db.get_group(group_id).map_err(|e| e.to_string())?.is_none() {
40+
return Err(format!("笔记组不存在: {}", group_id));
41+
}
42+
let now = now_ms();
43+
let new_state = serde_json::to_string(&CardState::default()).unwrap_or_default();
44+
let mut generated = 0usize;
45+
// ① 笔记词汇表块 → 术语卡
46+
let notes = state.db.list_notes_by_group(group_id).map_err(|e| e.to_string())?;
47+
for note in notes {
48+
for cand in cards_from_note(&note.content) {
49+
if state.db.card_front_exists(group_id, &cand.front).map_err(|e| e.to_string())? {
50+
continue;
51+
}
52+
state
53+
.db
54+
.create_card(&NewFlashcard {
55+
group_id,
56+
note_id: Some(note.id),
57+
fragment_id: None,
58+
front: cand.front,
59+
back: cand.back,
60+
kind: "fact".to_string(),
61+
state_json: new_state.clone(),
62+
due_at: now as i64,
63+
})
64+
.map_err(|e| e.to_string())?;
65+
generated += 1;
66+
}
67+
}
68+
// ② 碎片 → 多句卡(单句碎片诚实不出卡——card_generate 内门控)
69+
let fragments = state.db.list_fragments_by_group(group_id).map_err(|e| e.to_string())?;
70+
for frag in fragments {
71+
let Some(cand) = card_from_fragment(&frag.text) else { continue };
72+
if state.db.card_front_exists(group_id, &cand.front).map_err(|e| e.to_string())? {
73+
continue;
74+
}
75+
state
76+
.db
77+
.create_card(&NewFlashcard {
78+
group_id,
79+
note_id: None,
80+
fragment_id: Some(frag.id),
81+
front: cand.front,
82+
back: cand.back,
83+
kind: "fact".to_string(),
84+
state_json: new_state.clone(),
85+
due_at: now as i64,
86+
})
87+
.map_err(|e| e.to_string())?;
88+
generated += 1;
89+
// 碎片升级率埋点(Phase 4 门控判据,从第一天记)
90+
let payload = serde_json::json!({ "fragmentId": frag.id, "groupId": group_id }).to_string();
91+
let _ = state.db.add_metric_event("fragment_upgraded", &payload);
92+
}
93+
Ok(generated)
94+
}
95+
96+
/// 到期复习队列(组过滤可选;到期最紧在前)。
97+
#[tauri::command]
98+
pub fn list_due_cards(
99+
state: State<'_, AppState>,
100+
group_id: Option<i64>,
101+
limit: Option<usize>,
102+
) -> Result<Vec<Flashcard>, String> {
103+
let limit = limit.unwrap_or(50).min(DUE_LIST_LIMIT_MAX);
104+
state
105+
.db
106+
.list_due_cards(group_id, now_ms() as i64, limit)
107+
.map_err(|e| e.to_string())
108+
}
109+
110+
/// 到期卡计数(组面板"复习 N"徽标)。
111+
#[tauri::command]
112+
pub fn count_due_cards(state: State<'_, AppState>, group_id: Option<i64>) -> Result<i64, String> {
113+
state.db.count_due_cards(group_id, now_ms() as i64).map_err(|e| e.to_string())
114+
}
115+
116+
/// 复习评分(提取优先闭环:front→回忆→back→评分→调度推进)。
117+
///
118+
/// @ai-context: state_json 损坏 → 回退新卡状态重学(诚实降级不 panic);
119+
/// card_reviewed 指标每次必记(北极星组成①的数据源)。
120+
#[tauri::command]
121+
pub fn review_card(
122+
state: State<'_, AppState>,
123+
card_id: i64,
124+
rating: String,
125+
) -> Result<Flashcard, String> {
126+
if card_id <= 0 {
127+
return Err("无效的卡片 id".to_string());
128+
}
129+
let rating = Rating::parse(&rating).ok_or_else(|| format!("不支持的评分: {}", rating))?;
130+
let card = state
131+
.db
132+
.get_card(card_id)
133+
.map_err(|e| e.to_string())?
134+
.ok_or_else(|| format!("卡片不存在: {}", card_id))?;
135+
// 状态解析(损坏回退默认——重学比崩溃诚实)
136+
let current: CardState =
137+
serde_json::from_str(&card.state_json).unwrap_or_default();
138+
let now = now_ms();
139+
let outcome = schedule(
140+
if current.reps > 0 { Some(&current) } else { None },
141+
rating,
142+
now,
143+
);
144+
let state_json = serde_json::to_string(&outcome.next).unwrap_or_default();
145+
state
146+
.db
147+
.update_card_schedule(card_id, &state_json, outcome.due_at_ms as i64)
148+
.map_err(|e| e.to_string())?;
149+
state
150+
.db
151+
.add_review_log(card_id, &rating.to_string_lower(), now as i64)
152+
.map_err(|e| e.to_string())?;
153+
// 北极星埋点(组有复习记录=组成①;payload 带组 id 供按组聚合)
154+
let payload =
155+
serde_json::json!({ "cardId": card_id, "groupId": card.group_id, "rating": rating.to_string_lower() })
156+
.to_string();
157+
let _ = state.db.add_metric_event("card_reviewed", &payload);
158+
Ok(Flashcard {
159+
state_json,
160+
due_at: outcome.due_at_ms as i64,
161+
..card
162+
})
163+
}
164+
165+
/// 组级自测最轻形态(N14 防御:只抽卡不叙事成败)——随机抽 N 张到期/未到期卡。
166+
#[tauri::command]
167+
pub fn quiz_group_cards(
168+
state: State<'_, AppState>,
169+
group_id: i64,
170+
count: Option<usize>,
171+
) -> Result<Vec<Flashcard>, String> {
172+
if group_id <= 0 {
173+
return Err("无效的组 id".to_string());
174+
}
175+
let count = count.unwrap_or(5).min(20);
176+
// 到期队列优先,不足补新卡(due_at 升序全量取——自测不限到期)
177+
let mut cards = state
178+
.db
179+
.list_due_cards(Some(group_id), i64::MAX, DUE_LIST_LIMIT_MAX)
180+
.map_err(|e| e.to_string())?;
181+
cards.truncate(count);
182+
Ok(cards)
183+
}
184+
185+
impl Rating {
186+
/// 日志/埋点字符串(lowercase 契约,与 parse 对称)。
187+
fn to_string_lower(self) -> String {
188+
match self {
189+
Rating::Again => "again",
190+
Rating::Hard => "hard",
191+
Rating::Good => "good",
192+
Rating::Easy => "easy",
193+
}
194+
.to_string()
195+
}
196+
}

0 commit comments

Comments
 (0)