Skip to content

Commit 80f1c38

Browse files
committed
feat(cards): FSRS 调度器与组到闪卡生成纯函数
1 parent 3b82cc5 commit 80f1c38

4 files changed

Lines changed: 389 additions & 0 deletions

File tree

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! 组→闪卡生成纯函数(v0.11.2;本地规则版先行,AI 生成为可选增强)。
2+
//!
3+
//! @ai-context: 两个直供源(v4 路线图 Step 4):
4+
//! ① 笔记词汇表块(structure_note.rs ## 词汇表 行)——术语卡;
5+
//! ② 碎片文本(多句拆分——首句线索,全文验证,提取优先)。
6+
//! @ai-context: 提取优先纪律——front 是回忆线索不是答案展示;back 是验证材料;
7+
//! 单句碎片无回忆结构 → 诚实不出卡(防"假燃料"微观形态)。
8+
//! @ai-context: 纯函数无 IO;幂等去重在数据层(同组同 front 查重)。
9+
10+
/// 生成的卡片候选(front/back;kind 恒 fact——内容分型预埋 N13)。
11+
#[derive(Debug, Clone, PartialEq, Eq)]
12+
pub struct CardCandidate {
13+
pub front: String,
14+
pub back: String,
15+
}
16+
17+
/// 从笔记正文提取卡片候选(词汇表块术语 → front;术语上下文句 → back)。
18+
///
19+
/// @ai-context: 词汇表行格式(structure_note.rs 产出):
20+
/// `- [03:21] 术语(画面 ×3 / 语音 ×2)` 或 `- 术语(...)`;
21+
/// back 取正文中含术语的首个非标题行(上下文即释义);
22+
/// 正文无命中 → 词汇表行自身兜底(有卡总比无卡强,但诚实简陋)。
23+
pub fn cards_from_note(content: &str) -> Vec<CardCandidate> {
24+
let mut out = Vec::new();
25+
let mut in_glossary = false;
26+
for line in content.lines() {
27+
let trimmed = line.trim();
28+
if trimmed.starts_with("## ") {
29+
in_glossary = trimmed == "## 词汇表";
30+
continue;
31+
}
32+
if !in_glossary || !trimmed.starts_with("- ") {
33+
continue;
34+
}
35+
let Some(term) = parse_glossary_term(trimmed) else { continue };
36+
if term.is_empty() {
37+
continue;
38+
}
39+
let back = find_term_context(content, &term).unwrap_or_else(|| trimmed.to_string());
40+
out.push(CardCandidate { front: term, back });
41+
}
42+
out
43+
}
44+
45+
/// 解析词汇表行的术语(剥时间戳锚点与频次统计尾注)。
46+
fn parse_glossary_term(line: &str) -> Option<String> {
47+
let body = line.strip_prefix("- ")?.trim();
48+
// 剥时间戳锚点 "[03:21] "
49+
let body = if body.starts_with('[') {
50+
body.find(']').map(|i| body[i + 1..].trim()).unwrap_or(body)
51+
} else {
52+
body
53+
};
54+
// 截断频次尾注 "(画面 ×N / 语音 ×N)"(中文括号口径,structure_note 产出)
55+
let term = match body.find('(') {
56+
Some(i) => &body[..i],
57+
None => body,
58+
};
59+
Some(term.trim().to_string())
60+
}
61+
62+
/// 正文中术语的首个上下文行(跳过标题/词汇表块/空行)。
63+
fn find_term_context(content: &str, term: &str) -> Option<String> {
64+
let mut in_glossary = false;
65+
for line in content.lines() {
66+
let trimmed = line.trim();
67+
if trimmed.starts_with("## ") {
68+
in_glossary = trimmed == "## 词汇表";
69+
continue;
70+
}
71+
if in_glossary || trimmed.is_empty() || trimmed.starts_with('#') {
72+
continue;
73+
}
74+
if trimmed.contains(term) {
75+
return Some(trimmed.to_string());
76+
}
77+
}
78+
None
79+
}
80+
81+
/// 从碎片文本提取卡片候选(多句才有回忆结构;单句 → None 诚实不出卡)。
82+
///
83+
/// @ai-context: front=首句(线索),back=全文(验证)——碎片卡质量上限受
84+
/// 碎片本身约束(v4 契约一:碎片身份诚实,不冒充课程)。
85+
pub fn card_from_fragment(text: &str) -> Option<CardCandidate> {
86+
let sentences: Vec<&str> = text
87+
.split(|c| "。!?;\n".contains(c))
88+
.map(|s| s.trim())
89+
.filter(|s| !s.is_empty())
90+
.collect();
91+
if sentences.len() < 2 {
92+
return None;
93+
}
94+
let front = sentences[0].chars().take(60).collect::<String>();
95+
Some(CardCandidate { front, back: text.trim().to_string() })
96+
}
97+
98+
#[cfg(test)]
99+
#[path = "card_generate_tests.rs"]
100+
mod tests;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//! card_generate 单测(AAA 模式;golden 用例覆盖两类卡源)。
2+
3+
use crate::card_generate::{card_from_fragment, cards_from_note};
4+
5+
/// 真实笔记形态(structure_note 产出口径:章节 + 正文 + 词汇表块)。
6+
const NOTE_WITH_GLOSSARY: &str = "# 微积分精讲\n\n## 第一章 极限 [00:00]\n\n\
7+
极限是微积分的基础概念,描述函数的趋近行为。\n\n\
8+
## 词汇表\n\n\
9+
- [01:23] 极限(画面 ×3 / 语音 ×5)\n\
10+
- [05:10] 导数(画面 ×2 / 语音 ×4)\n\
11+
- 孤立术语(画面 ×1 / 语音 ×1)\n";
12+
13+
#[test]
14+
fn note_glossary_terms_become_cards() {
15+
// Act
16+
let cards = cards_from_note(NOTE_WITH_GLOSSARY);
17+
// Assert:三术语各一卡,front=术语
18+
assert_eq!(cards.len(), 3);
19+
assert_eq!(cards[0].front, "极限");
20+
assert_eq!(cards[1].front, "导数");
21+
assert_eq!(cards[2].front, "孤立术语");
22+
}
23+
24+
#[test]
25+
fn back_uses_context_sentence_when_present() {
26+
// Act
27+
let cards = cards_from_note(NOTE_WITH_GLOSSARY);
28+
// Assert:back=正文含术语的上下文句(不是词汇表行本身)
29+
assert!(cards[0].back.contains("趋近行为"), "应取正文上下文: {}", cards[0].back);
30+
assert!(!cards[0].back.starts_with("- "));
31+
}
32+
33+
#[test]
34+
fn back_falls_back_to_glossary_line_without_context() {
35+
// Act:孤立术语正文无命中
36+
let cards = cards_from_note(NOTE_WITH_GLOSSARY);
37+
// Assert:兜底为词汇表行(诚实简陋但有卡)
38+
assert!(cards[2].back.starts_with("- 孤立术语"));
39+
}
40+
41+
#[test]
42+
fn note_without_glossary_yields_no_cards() {
43+
// Arrange:无词汇表块的笔记(口播类)
44+
let content = "# 闲聊\n\n今天聊点轻松的,没有结构。";
45+
// Act/Assert:诚实零卡
46+
assert!(cards_from_note(content).is_empty());
47+
}
48+
49+
#[test]
50+
fn multi_sentence_fragment_becomes_card() {
51+
// Arrange:两句话的碎片
52+
let text = "眼影晕染要用松软的刷子。少量多次才不会显脏。";
53+
// Act
54+
let card = card_from_fragment(text);
55+
// Assert:首句线索 + 全文验证
56+
let card = card.expect("多句碎片应出卡");
57+
assert_eq!(card.front, "眼影晕染要用松软的刷子");
58+
assert!(card.back.contains("少量多次"));
59+
}
60+
61+
#[test]
62+
fn single_sentence_fragment_no_card() {
63+
// Arrange/Act:单句碎片无回忆结构
64+
let card = card_from_fragment("只有一句话的碎片");
65+
// Assert:诚实不出卡(防假燃料)
66+
assert!(card.is_none());
67+
}
68+
69+
#[test]
70+
fn fragment_front_truncated_to_limit() {
71+
// Arrange:超长首句
72+
let long = "长".repeat(100);
73+
let text = format!("{}。第二句验证材料。", long);
74+
// Act
75+
let card = card_from_fragment(&text).expect("出卡");
76+
// Assert:front 上限 60 字符(UI 可读性)
77+
assert!(card.front.chars().count() <= 60);
78+
}

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

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//! 间隔重复调度器(v0.11.2;FSRS-6 主实现,ADR-018)。
2+
//!
3+
//! @ai-context: fsrs crate(Anki 23.10+ 默认调度器算法)——DSR 三变量模型,
4+
//! 按目标留存率(0.9)反解间隔;纯算法零网络,契合本地优先。
5+
//! 裁决记录:SM-2 手写兜底的条件(依赖引入受限)未触发——crate
6+
//! 引入成功,不保留双实现(YAGNI;接口稳定可后补)。
7+
//! @ai-context: 弹性承诺纪律(v4 路线图裁决 4):调度纯函数无 streak 概念,
8+
//! 欠账卡仅按 due_at 排序呈现——不追债不清零(N10 归零暴政防御)。
9+
//! @ai-context: 纯逻辑层(输入状态+评分→输出新状态+到期时刻),DB/UI 无关;
10+
//! CardState 以 JSON 落 flashcards.state_json(版本演进容忍缺字段)。
11+
12+
use fsrs::{MemoryState, FSRS};
13+
use serde::{Deserialize, Serialize};
14+
15+
/// 目标留存率(FSRS 默认推荐值;个性化调参为复习数据积累后的远期扩展)。
16+
pub const DESIRED_RETENTION: f32 = 0.9;
17+
/// 一天的毫秒数。
18+
const DAY_MS: u64 = 86_400_000;
19+
20+
/// 复习评分(四档——提取优先:先回忆再看后评分)。
21+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22+
#[serde(rename_all = "lowercase")]
23+
pub enum Rating {
24+
Again,
25+
Hard,
26+
Good,
27+
Easy,
28+
}
29+
30+
impl Rating {
31+
/// 解析前端评分字符串(非法值 → None,诚实不猜)。
32+
pub fn parse(s: &str) -> Option<Rating> {
33+
match s {
34+
"again" => Some(Rating::Again),
35+
"hard" => Some(Rating::Hard),
36+
"good" => Some(Rating::Good),
37+
"easy" => Some(Rating::Easy),
38+
_ => None,
39+
}
40+
}
41+
}
42+
43+
/// 卡片记忆状态(序列化落库;serde(default) 容忍旧格式演进)。
44+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45+
#[serde(rename_all = "camelCase", default)]
46+
pub struct CardState {
47+
/// FSRS 稳定性(天——记忆可维持时长的度量)
48+
pub stability: f32,
49+
/// FSRS 难度(1-10)
50+
pub difficulty: f32,
51+
/// 累计复习次数
52+
pub reps: u32,
53+
/// 遗忘次数(Again 计数——弹性承诺下仅统计不惩罚)
54+
pub lapses: u32,
55+
/// 上次复习时刻(Unix 毫秒;新卡 0)
56+
pub last_review_ms: u64,
57+
}
58+
59+
impl Default for CardState {
60+
fn default() -> Self {
61+
Self { stability: 0.0, difficulty: 0.0, reps: 0, lapses: 0, last_review_ms: 0 }
62+
}
63+
}
64+
65+
/// 调度输出(新状态 + 间隔天数 + 到期时刻)。
66+
#[derive(Debug, Clone, PartialEq)]
67+
pub struct ScheduleOutcome {
68+
pub next: CardState,
69+
pub interval_days: f32,
70+
pub due_at_ms: u64,
71+
}
72+
73+
/// 调度主入口(纯函数):新卡 state=None,复习卡 Some。
74+
///
75+
/// @ai-context: fsrs.next_states 对四个评分一次算全(Again/Hard/Good/Easy),
76+
/// 取本次评分分支;days_elapsed=距上次复习天数(新卡 0)——
77+
/// 欠账卡按真实过期天数演化状态(FSRS 正确处理延迟复习)。
78+
pub fn schedule(state: Option<&CardState>, rating: Rating, now_ms: u64) -> ScheduleOutcome {
79+
// 默认参数恒有效(FSRS::default 同款 expect 口径)
80+
let fsrs = FSRS::new(&[]).expect("FSRS-6 默认参数恒有效");
81+
let (mem, days_elapsed) = match state {
82+
Some(s) if s.reps > 0 => (
83+
Some(MemoryState { stability: s.stability, difficulty: s.difficulty }),
84+
(now_ms.saturating_sub(s.last_review_ms) / DAY_MS) as u32,
85+
),
86+
_ => (None, 0),
87+
};
88+
let states = fsrs
89+
.next_states(mem, DESIRED_RETENTION, days_elapsed)
90+
.expect("有限状态输入恒有效(fsrs 内部 validate)");
91+
let item = match rating {
92+
Rating::Again => states.again,
93+
Rating::Hard => states.hard,
94+
Rating::Good => states.good,
95+
Rating::Easy => states.easy,
96+
};
97+
let base = state.cloned().unwrap_or_default();
98+
// 间隔下限 1 天(无学习步设计——当日重学靠 Again 后次日再现,简化 UI)
99+
let interval_days = item.interval.max(1.0);
100+
let due_at_ms = now_ms + (interval_days.round() as u64).max(1) * DAY_MS;
101+
ScheduleOutcome {
102+
next: CardState {
103+
stability: item.memory.stability,
104+
difficulty: item.memory.difficulty,
105+
reps: base.reps + 1,
106+
lapses: base.lapses + if rating == Rating::Again { 1 } else { 0 },
107+
last_review_ms: now_ms,
108+
},
109+
interval_days,
110+
due_at_ms,
111+
}
112+
}
113+
114+
#[cfg(test)]
115+
#[path = "scheduler_tests.rs"]
116+
mod tests;
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//! scheduler 单测(AAA 模式;FSRS-6 行为契约)。
2+
3+
use crate::scheduler::{schedule, CardState, Rating};
4+
5+
/// 基准时刻(固定,单测可重现)。
6+
const NOW_MS: u64 = 1_755_900_000_000;
7+
8+
#[test]
9+
fn new_card_good_produces_positive_interval() {
10+
// Arrange/Act:新卡首次 Good
11+
let out = schedule(None, Rating::Good, NOW_MS);
12+
// Assert:状态初始化合理,到期在未来
13+
assert!(out.next.stability > 0.0, "新卡 Good 应有正稳定性");
14+
assert!(out.next.difficulty > 0.0);
15+
assert_eq!(out.next.reps, 1);
16+
assert_eq!(out.next.lapses, 0);
17+
assert!(out.interval_days >= 1.0, "间隔下限 1 天");
18+
assert!(out.due_at_ms > NOW_MS);
19+
}
20+
21+
#[test]
22+
fn rating_monotonicity_easy_over_good_over_again() {
23+
// Arrange/Act:同一新卡四种评分
24+
let again = schedule(None, Rating::Again, NOW_MS);
25+
let good = schedule(None, Rating::Good, NOW_MS);
26+
let easy = schedule(None, Rating::Easy, NOW_MS);
27+
// Assert:评分越高间隔越长(Again 最短——FSRS 核心契约)
28+
assert!(easy.interval_days > good.interval_days, "Easy 间隔应长于 Good");
29+
assert!(good.interval_days >= again.interval_days, "Good 间隔不短于 Again");
30+
}
31+
32+
#[test]
33+
fn again_increments_lapses() {
34+
// Arrange:复习过的卡
35+
let first = schedule(None, Rating::Good, NOW_MS);
36+
// Act:遗忘一次
37+
let out = schedule(Some(&first.next), Rating::Again, NOW_MS + 86_400_000);
38+
// Assert:lapses+1,reps 累计
39+
assert_eq!(out.next.lapses, 1);
40+
assert_eq!(out.next.reps, 2);
41+
}
42+
43+
#[test]
44+
fn successful_review_grows_stability() {
45+
// Arrange:首次 Good 后按时复习再 Good
46+
let first = schedule(None, Rating::Good, NOW_MS);
47+
// Act:到期时刻复习
48+
let second = schedule(Some(&first.next), Rating::Good, first.due_at_ms);
49+
// Assert:稳定性增长(间隔效应——FSRS 核心收益)
50+
assert!(
51+
second.next.stability > first.next.stability,
52+
"成功复习应增长稳定性: {} → {}",
53+
first.next.stability,
54+
second.next.stability
55+
);
56+
assert!(second.interval_days >= first.interval_days);
57+
}
58+
59+
#[test]
60+
fn overdue_card_uses_real_elapsed_days() {
61+
// Arrange:欠账 30 天的卡(弹性承诺——不追债不清零,按真实间隔演化)
62+
let first = schedule(None, Rating::Good, NOW_MS);
63+
let overdue_at = first.due_at_ms + 30 * 86_400_000;
64+
// Act
65+
let out = schedule(Some(&first.next), Rating::Good, overdue_at);
66+
// Assert:状态仍演化成功(不 panic 不清零),last_review 更新为当前
67+
assert!(out.next.stability > 0.0);
68+
assert_eq!(out.next.last_review_ms, overdue_at);
69+
assert_eq!(out.next.lapses, 0, "按时评分 Good 不计遗忘");
70+
}
71+
72+
#[test]
73+
fn card_state_json_roundtrip() {
74+
// Arrange:落库格式(flashcards.state_json)序列化往返
75+
let state = CardState {
76+
stability: 12.5,
77+
difficulty: 5.2,
78+
reps: 3,
79+
lapses: 1,
80+
last_review_ms: NOW_MS,
81+
};
82+
// Act
83+
let json = serde_json::to_string(&state).expect("serialize");
84+
let back: CardState = serde_json::from_str(&json).expect("deserialize");
85+
// Assert
86+
assert_eq!(back, state);
87+
}
88+
89+
#[test]
90+
fn rating_parse_accepts_contract_values() {
91+
// Arrange/Act/Assert:四档契约值 + 非法值诚实 None
92+
assert_eq!(Rating::parse("again"), Some(Rating::Again));
93+
assert_eq!(Rating::parse("easy"), Some(Rating::Easy));
94+
assert_eq!(Rating::parse("unknown"), None);
95+
}

0 commit comments

Comments
 (0)