|
| 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(¬e.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(¤t) } 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