Skip to content

Commit fe7cd55

Browse files
committed
feat(cards): 内容分型 action 卡最小版(REQ-199)
1 parent cdbfd3e commit fe7cd55

3 files changed

Lines changed: 101 additions & 10 deletions

File tree

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

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,25 @@
55
//! ② 碎片文本(多句拆分——首句线索,全文验证,提取优先)。
66
//! @ai-context: 提取优先纪律——front 是回忆线索不是答案展示;back 是验证材料;
77
//! 单句碎片无回忆结构 → 诚实不出卡(防"假燃料"微观形态)。
8+
//! @ai-context: 内容分型(v0.11.4 REQ-199,N13 续):碎片含步骤语义信号 →
9+
//! kind=action(front=动作名首句,back=步骤清单);无信号维持 fact。
10+
//! model 留接口不做——kind 白名单由数据层/命令层透传,前端按类渲染。
811
//! @ai-context: 纯函数无 IO;幂等去重在数据层(同组同 front 查重)。
912
10-
/// 生成的卡片候选(front/back;kind 恒 fact——内容分型预埋 N13)。
13+
/// 卡片类型白名单(fact=知识卡 / action=动作卡;model 留接口不做)。
14+
pub const KIND_FACT: &str = "fact";
15+
pub const KIND_ACTION: &str = "action";
16+
17+
/// 步骤语义元词(命中任一即判 action——中文教程/操作高频词)。
18+
const STEP_META_WORDS: [&str; 6] = ["第一步", "步骤", "做法", "流程", "操作步骤", "教程"];
19+
20+
/// 生成的卡片候选(front/back + 内容分型 kind)。
1121
#[derive(Debug, Clone, PartialEq, Eq)]
1222
pub struct CardCandidate {
1323
pub front: String,
1424
pub back: String,
25+
/// fact/action(model 留接口;notes 术语卡恒 fact)
26+
pub kind: String,
1527
}
1628

1729
/// 从笔记正文提取卡片候选(词汇表块术语 → front;术语上下文句 → back)。
@@ -37,7 +49,8 @@ pub fn cards_from_note(content: &str) -> Vec<CardCandidate> {
3749
continue;
3850
}
3951
let back = find_term_context(content, &term).unwrap_or_else(|| trimmed.to_string());
40-
out.push(CardCandidate { front: term, back });
52+
// 术语卡恒 fact(词汇表是知识不是操作——内容分型不误伤)
53+
out.push(CardCandidate { front: term, back, kind: KIND_FACT.to_string() });
4154
}
4255
out
4356
}
@@ -82,6 +95,10 @@ fn find_term_context(content: &str, term: &str) -> Option<String> {
8295
///
8396
/// @ai-context: front=首句(线索),back=全文(验证)——碎片卡质量上限受
8497
/// 碎片本身约束(v4 契约一:碎片身份诚实,不冒充课程)。
98+
/// @ai-context: 内容分型(REQ-199)——步骤信号命中 → action 卡:front=动作名
99+
/// (首句线索),back=步骤清单(按句号拆分编号,回忆步骤序列);
100+
/// 无信号维持 fact(back=全文验证)。单句含信号仍不出卡——
101+
/// 步骤清单需要多句才有回忆结构,防假燃料优先。
85102
pub fn card_from_fragment(text: &str) -> Option<CardCandidate> {
86103
let sentences: Vec<&str> = text
87104
.split(|c| "。!?;\n".contains(c))
@@ -92,7 +109,32 @@ pub fn card_from_fragment(text: &str) -> Option<CardCandidate> {
92109
return None;
93110
}
94111
let front = sentences[0].chars().take(60).collect::<String>();
95-
Some(CardCandidate { front, back: text.trim().to_string() })
112+
let is_action = has_step_signal(text);
113+
let back = if is_action {
114+
// 步骤清单:编号列出全部句子(保留原文顺序,回忆动作序列)
115+
sentences
116+
.iter()
117+
.enumerate()
118+
.map(|(i, s)| format!("{}. {}", i + 1, s))
119+
.collect::<Vec<_>>()
120+
.join("\n")
121+
} else {
122+
text.trim().to_string()
123+
};
124+
Some(CardCandidate {
125+
front,
126+
back,
127+
kind: if is_action { KIND_ACTION } else { KIND_FACT }.to_string(),
128+
})
129+
}
130+
131+
/// 步骤语义信号判定(REQ-199):步骤元词命中,或"先…然后"成对出现。
132+
///
133+
/// @ai-context: "先/然后"单现不算信号(普通叙事高频词,误判会把知识卡
134+
/// 伪装成动作卡);成对出现才隐含操作顺序。
135+
fn has_step_signal(text: &str) -> bool {
136+
STEP_META_WORDS.iter().any(|w| text.contains(w))
137+
|| (text.contains("先") && text.contains("然后"))
96138
}
97139

98140
#[cfg(test)]

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

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
//! card_generate 单测(AAA 模式;golden 用例覆盖两类卡源)。
1+
//! card_generate 单测(AAA 模式;golden 用例覆盖两类卡源 + 内容分型 REQ-199)。
22
3-
use crate::card_generate::{card_from_fragment, cards_from_note};
3+
use crate::card_generate::{card_from_fragment, cards_from_note, KIND_ACTION, KIND_FACT};
44

55
/// 真实笔记形态(structure_note 产出口径:章节 + 正文 + 词汇表块)。
66
const NOTE_WITH_GLOSSARY: &str = "# 微积分精讲\n\n## 第一章 极限 [00:00]\n\n\
@@ -14,11 +14,12 @@ const NOTE_WITH_GLOSSARY: &str = "# 微积分精讲\n\n## 第一章 极限 [00:0
1414
fn note_glossary_terms_become_cards() {
1515
// Act
1616
let cards = cards_from_note(NOTE_WITH_GLOSSARY);
17-
// Assert:三术语各一卡,front=术语
17+
// Assert:三术语各一卡,front=术语,kind 恒 fact(词汇表是知识不是操作)
1818
assert_eq!(cards.len(), 3);
1919
assert_eq!(cards[0].front, "极限");
2020
assert_eq!(cards[1].front, "导数");
2121
assert_eq!(cards[2].front, "孤立术语");
22+
assert!(cards.iter().all(|c| c.kind == KIND_FACT));
2223
}
2324

2425
#[test]
@@ -48,14 +49,15 @@ fn note_without_glossary_yields_no_cards() {
4849

4950
#[test]
5051
fn multi_sentence_fragment_becomes_card() {
51-
// Arrange:两句话的碎片
52+
// Arrange:两句话的碎片(无步骤信号)
5253
let text = "眼影晕染要用松软的刷子。少量多次才不会显脏。";
5354
// Act
5455
let card = card_from_fragment(text);
55-
// Assert:首句线索 + 全文验证
56+
// Assert:首句线索 + 全文验证 + kind 恒 fact
5657
let card = card.expect("多句碎片应出卡");
5758
assert_eq!(card.front, "眼影晕染要用松软的刷子");
5859
assert!(card.back.contains("少量多次"));
60+
assert_eq!(card.kind, KIND_FACT);
5961
}
6062

6163
#[test]
@@ -76,3 +78,50 @@ fn fragment_front_truncated_to_limit() {
7678
// Assert:front 上限 60 字符(UI 可读性)
7779
assert!(card.front.chars().count() <= 60);
7880
}
81+
82+
// ── 内容分型(REQ-199):步骤信号 → action 卡 ──
83+
84+
#[test]
85+
fn step_signal_fragment_becomes_action_card() {
86+
// Arrange:含"第一步/第二步"步骤元词的碎片
87+
let text = "修眉的做法。第一步用眉梳梳顺。第二步按眉骨定三点。第三步连点成线。";
88+
// Act
89+
let card = card_from_fragment(text).expect("出卡");
90+
// Assert:kind=action;front=首句(动作名);back=编号步骤清单(全句入列)
91+
assert_eq!(card.kind, KIND_ACTION);
92+
assert_eq!(card.front, "修眉的做法");
93+
assert!(card.back.starts_with("1. 修眉的做法"));
94+
assert!(card.back.contains("2. 第一步用眉梳梳顺"));
95+
assert!(card.back.contains("3. 第二步按眉骨定三点"));
96+
}
97+
98+
#[test]
99+
fn pair_signal_xi_ranhou_counts_as_action() {
100+
// Arrange:"先…然后"成对出现(隐含操作顺序,无元词)
101+
let text = "补妆先吸走浮粉。然后用粉饼按压。最后定妆喷雾收尾。";
102+
// Act
103+
let card = card_from_fragment(text).expect("出卡");
104+
// Assert:成对信号 → action
105+
assert_eq!(card.kind, KIND_ACTION);
106+
assert!(card.back.contains("3. 最后定妆喷雾收尾"));
107+
}
108+
109+
#[test]
110+
fn lone_ranhou_is_not_action_signal() {
111+
// Arrange:单"然后"无"先"——普通叙事不是操作流程
112+
let text = "昨天学了新技巧。然后去吃了午饭。今天继续练习。";
113+
// Act
114+
let card = card_from_fragment(text).expect("出卡");
115+
// Assert:维持 fact(防叙事误判伪装动作卡)
116+
assert_eq!(card.kind, KIND_FACT);
117+
// back 维持全文验证(非编号清单)
118+
assert!(!card.back.starts_with("1. "));
119+
}
120+
121+
#[test]
122+
fn single_sentence_with_signal_still_no_card() {
123+
// Arrange:单句含步骤信号——无多句结构,步骤清单无从谈起
124+
let card = card_from_fragment("第一步先打开软件");
125+
// Assert:诚实不出卡(防假燃料优先于分型)
126+
assert!(card.is_none());
127+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ pub fn generate_group_cards(state: State<'_, AppState>, group_id: i64) -> Result
5757
fragment_id: None,
5858
front: cand.front,
5959
back: cand.back,
60-
kind: "fact".to_string(),
60+
kind: cand.kind.clone(),
6161
state_json: new_state.clone(),
6262
due_at: now as i64,
6363
})
@@ -80,7 +80,7 @@ pub fn generate_group_cards(state: State<'_, AppState>, group_id: i64) -> Result
8080
fragment_id: Some(frag.id),
8181
front: cand.front,
8282
back: cand.back,
83-
kind: "fact".to_string(),
83+
kind: cand.kind.clone(),
8484
state_json: new_state.clone(),
8585
due_at: now as i64,
8686
})

0 commit comments

Comments
 (0)