Skip to content

Commit eb7cfec

Browse files
committed
feat(knowledge): 模型卡卡面纯函数与数据层
1 parent 1845bb2 commit eb7cfec

5 files changed

Lines changed: 433 additions & 0 deletions

File tree

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,65 @@ impl Db {
209209
Ok(count)
210210
})
211211
}
212+
213+
/// 列组内卡片(kind 过滤可选;ORDER BY id ASC)。
214+
///
215+
/// @ai-context: 组仍是唯一容器(spec §一)——model 卡在组内,本方法是组卡列表
216+
/// 与"模型卡列表"两处的数据源(kind=Some("model"))。排序按 id 升序
217+
/// 稳定(创建序,避免依赖插入顺序)。
218+
/// @ai-context: kind 为补值级增量(kind TEXT 无 CHECK;'model' 新增补值,命令层
219+
/// 透传):过滤时传 None 取全组卡,传 Some 按 kind 过滤。
220+
pub fn list_cards_by_group(&self, group_id: i64, kind: Option<&str>) -> Result<Vec<Flashcard>> {
221+
self.with_conn(|conn| {
222+
let mut sql =
223+
format!("SELECT {} FROM flashcards WHERE group_id = ?1", CARD_COLUMNS);
224+
if kind.is_some() {
225+
sql.push_str(" AND kind = ?2");
226+
}
227+
sql.push_str(" ORDER BY id ASC");
228+
let mut stmt = conn.prepare(&sql)?;
229+
let rows = match kind {
230+
Some(k) => stmt.query_map(params![group_id, k], row_to_card)?,
231+
None => stmt.query_map(params![group_id], row_to_card)?,
232+
};
233+
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
234+
})
235+
}
236+
237+
/// 同组同 front 幂等取卡(已有 card_front_exists 是 bool;本方法取全卡用于升格)。
238+
///
239+
/// @ai-context: 升格/免重复判据要拿到既有卡本体(front/kind/back 等)而不是布尔:
240+
/// create_model_card 幂等(同组同 front 已有则复用)依赖本方法取整卡。
241+
/// @ai-context: 同组同 front 理论上唯一(生成幂等键),故 `LIMIT 1`;未命中 → None。
242+
pub fn find_card_by_front(&self, group_id: i64, front: &str) -> Result<Option<Flashcard>> {
243+
self.with_conn(|conn| {
244+
let mut stmt = conn.prepare(&format!(
245+
"SELECT {} FROM flashcards WHERE group_id = ?1 AND front = ?2 LIMIT 1",
246+
CARD_COLUMNS
247+
))?;
248+
let mut rows = stmt.query_map(params![group_id, front], row_to_card)?;
249+
match rows.next() {
250+
Some(Ok(c)) => Ok(Some(c)),
251+
Some(Err(e)) => Err(e.into()),
252+
None => Ok(None),
253+
}
254+
})
255+
}
256+
257+
/// 更新卡背面 back(升格锚点追加写回)。
258+
///
259+
/// @ai-context: 升格在 Create/Merge 分支把"→ 概念「name」"独立锚点行追加到 back
260+
/// (以 back_has_anchor 幂等判定先查后写)——本方法只写 back,不回填
261+
/// 其他列,保持零 schema/零破坏。无该 id → false。
262+
pub fn update_card_back(&self, id: i64, back: &str) -> Result<bool> {
263+
self.with_conn(|conn| {
264+
let affected = conn.execute(
265+
"UPDATE flashcards SET back = ?1 WHERE id = ?2",
266+
params![back, id],
267+
)?;
268+
Ok(affected > 0)
269+
})
270+
}
212271
}
213272

214273
/// 把 rusqlite 行映射为 Flashcard。

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,71 @@ fn metric_events_record_and_count() {
156156
assert_eq!(db.count_metric_events("fragment_upgraded").expect("f"), 1);
157157
assert_eq!(db.count_metric_events("group_settled").expect("s"), 0);
158158
}
159+
160+
/// 指定 kind 的卡(kind 过滤/模型卡数据源测试用;区别于默认 fact 的 card())。
161+
fn card_kind(group_id: i64, front: &str, kind: &str) -> NewFlashcard {
162+
NewFlashcard {
163+
group_id,
164+
note_id: None,
165+
fragment_id: None,
166+
front: front.to_string(),
167+
back: format!("{} 的释义", front),
168+
kind: kind.to_string(),
169+
state_json: "{}".to_string(),
170+
due_at: 1000,
171+
}
172+
}
173+
174+
#[test]
175+
fn list_cards_by_group_filters_by_kind() {
176+
// Arrange:同组建 fact + model 两卡;按创建序(id 升序)应稳定排
177+
let db = mem_db();
178+
let gid = make_group(&db);
179+
db.create_card(&card(gid, "极限", 1000)).expect("fact");
180+
db.create_card(&card_kind(gid, "安全边际", "model")).expect("model");
181+
db.create_card(&card_kind(gid, "复利", "model")).expect("model2");
182+
// Act:无 kind 过滤 / model 过滤 / fact 过滤 / 不存在 kind
183+
let all = db.list_cards_by_group(gid, None).expect("all");
184+
let models = db.list_cards_by_group(gid, Some("model")).expect("model");
185+
let facts = db.list_cards_by_group(gid, Some("fact")).expect("fact");
186+
// Assert:ORDER BY id 升序;kind 过滤正确;他组不受影响
187+
assert_eq!(all.len(), 3);
188+
assert_eq!(all[0].front, "极限");
189+
assert_eq!(all[1].front, "安全边际");
190+
assert_eq!(all[2].front, "复利");
191+
assert_eq!(models.len(), 2);
192+
assert!(models.iter().all(|c| c.kind == "model"));
193+
assert_eq!(facts.len(), 1);
194+
assert_eq!(facts[0].front, "极限");
195+
assert_eq!(facts[0].kind, "fact");
196+
assert_eq!(db.list_cards_by_group(9999, None).expect("other").len(), 0);
197+
}
198+
199+
#[test]
200+
fn find_card_by_front_hit_and_miss() {
201+
// Arrange:同组建一卡(同组同 front 幂等取卡——升格判据)
202+
let db = mem_db();
203+
let gid = make_group(&db);
204+
db.create_card(&card_kind(gid, "安全边际", "model")).expect("create");
205+
// Act/Assert:命中返回整卡;未命中返回 None
206+
let hit = db.find_card_by_front(gid, "安全边际").expect("hit").expect("应命中");
207+
assert_eq!(hit.front, "安全边际");
208+
assert_eq!(hit.kind, "model");
209+
assert!(db.find_card_by_front(gid, "不存在").expect("miss").is_none());
210+
}
211+
212+
#[test]
213+
fn update_card_back_roundtrip() {
214+
// Arrange:建卡后升格追加锚点再写回
215+
let db = mem_db();
216+
let gid = make_group(&db);
217+
let c = db.create_card(&card(gid, "极限", 1000)).expect("create");
218+
// Act:把锚点独立行追加到 back 并写回
219+
let new_back = format!("{}\n→ 概念「安全边际」", c.back);
220+
let updated = db.update_card_back(c.id, &new_back).expect("update");
221+
// Assert:往返命中;不存在的 id 返回 false(无该行可更新)
222+
assert!(updated);
223+
let fetched = db.get_card(c.id).expect("get").expect("exists");
224+
assert_eq!(fetched.back, new_back);
225+
assert!(!db.update_card_back(9999, "x").expect("miss"));
226+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//! 概念双面体——模型卡卡面纯函数(v0.13.2 REQ-206)。
2+
//!
3+
//! @ai-context: 概念双面体(记忆面×思辨面)单向升格——组内 model 卡(front=概念名、
4+
//! back=三问)经升格成为体系内概念。本文件定义卡背三问的纯函数契约:
5+
//! format(compose)与 parse 互为逆;back_has_anchor 判定是否已写回链锚点。
6+
//! @ai-context: 锚点=独立行 `→ 概念「name」`,整行前缀判定(行首匹配)——防字段值
7+
//! 中部出现"→ 概念「…」"被误判为已锚定(规范 §三),也防升格重复回链。
8+
//! @ai-context: M1 原子层 API 供 M2 command 层接入用(机制先行);v0.13.2 升格命令
9+
//! 已接线(commands_knowledge_cards*),无 dead_code 残留。
10+
11+
/// 卡背三问(概念双面体:本质 / 边界 / 联系)。
12+
///
13+
/// @ai-context: 三问对应概念三问一用卡片的三个思辨面——本质(它是什么)、
14+
/// 边界(适用范围/限制)、联系(与之关联的概念/体系)。任一问缺省
15+
/// 表示该卡尚未填写(不代表"无此问"),故用 Option。
16+
#[derive(Debug, Clone, PartialEq, Default)]
17+
pub struct ModelCardBack {
18+
/// 本质(What——它是什么)。
19+
pub essence: Option<String>,
20+
/// 边界(Where——适用范围/限制)。
21+
pub boundary: Option<String>,
22+
/// 联系(How——与之关联的概念/体系)。
23+
pub relation: Option<String>,
24+
}
25+
26+
/// 卡背字段标签(内部枚举,无公开语义)。
27+
#[derive(Clone, Copy)]
28+
enum Field {
29+
Essence,
30+
Boundary,
31+
Relation,
32+
}
33+
34+
/// 行首标签判定。
35+
///
36+
/// @ai-context: full-width `:` 为主,半角 `:` 容错——但只在行首(trim 后)匹配,
37+
/// 字段值中部出现同名词(如先写"本质"再展开)不误判为标签行。
38+
/// 返回 (字段, 标签后内容)。
39+
fn split_label(line: &str) -> Option<(Field, &str)> {
40+
let t = line.trim_start();
41+
for (full, half, field) in [
42+
("本质:", "本质:", Field::Essence),
43+
("边界:", "边界:", Field::Boundary),
44+
("联系:", "联系:", Field::Relation),
45+
] {
46+
if let Some(rest) = t.strip_prefix(full) {
47+
return Some((field, rest));
48+
}
49+
if let Some(rest) = t.strip_prefix(half) {
50+
return Some((field, rest));
51+
}
52+
}
53+
None
54+
}
55+
56+
/// 把字段行拼回值:保留内部换行,trim 首尾空白;空值 → None。
57+
///
58+
/// @ai-context: 契约约定"缺标签/缺字段/空字段 → None"——空问不是"有但为空",而是
59+
/// 信息缺失(缺这一问),落库后前端按 None 渲染占位而非空串。
60+
fn join_trim(lines: &[String]) -> Option<String> {
61+
let joined = lines.join("\n");
62+
let trimmed = joined.trim();
63+
if trimmed.is_empty() {
64+
None
65+
} else {
66+
Some(trimmed.to_string())
67+
}
68+
}
69+
70+
/// 解析卡背文本为三问(三问契约的 parse 侧)。
71+
///
72+
/// @ai-context: 标签行(行首 `本质:`/`边界:`/`联系:`)是字段值起点,标签后内容为
73+
/// 该字段首行;后续非标签行并入当前字段直到下一标签(保留内部换行)。
74+
/// @ai-context: 同名标签后出现者覆盖(后来者胜——重写卡面时旧值作废);首尾空白经
75+
/// join_trim 折叠。与 format 互为逆(解析后再格式化幂等)。
76+
pub fn parse_model_card_back(back_text: &str) -> ModelCardBack {
77+
let mut essence: Vec<String> = Vec::new();
78+
let mut boundary: Vec<String> = Vec::new();
79+
let mut relation: Vec<String> = Vec::new();
80+
let mut current: Option<Field> = None;
81+
82+
for line in back_text.lines() {
83+
match split_label(line) {
84+
Some((field, rest)) => {
85+
let target: &mut Vec<String> = match field {
86+
Field::Essence => {
87+
essence.clear();
88+
&mut essence
89+
}
90+
Field::Boundary => {
91+
boundary.clear();
92+
&mut boundary
93+
}
94+
Field::Relation => {
95+
relation.clear();
96+
&mut relation
97+
}
98+
};
99+
target.push(rest.to_string());
100+
current = Some(field);
101+
}
102+
None => match current {
103+
Some(Field::Essence) => essence.push(line.to_string()),
104+
Some(Field::Boundary) => boundary.push(line.to_string()),
105+
Some(Field::Relation) => relation.push(line.to_string()),
106+
None => {}
107+
},
108+
}
109+
}
110+
111+
ModelCardBack {
112+
essence: join_trim(&essence),
113+
boundary: join_trim(&boundary),
114+
relation: join_trim(&relation),
115+
}
116+
}
117+
118+
/// 组合三问为卡背文本(三问契约的 compose 侧)。
119+
///
120+
/// @ai-context: 空字段留 `标签:` 空行(占位不省略)——保证三行契约稳定,parse 可幂等
121+
/// 往返;与 create_model_card(M2)的卡面契约一致。
122+
pub fn format_model_card_back(
123+
essence: Option<&str>,
124+
boundary: Option<&str>,
125+
relation: Option<&str>,
126+
) -> String {
127+
format!(
128+
"本质:{}\n边界:{}\n联系:{}",
129+
essence.unwrap_or(""),
130+
boundary.unwrap_or(""),
131+
relation.unwrap_or("")
132+
)
133+
}
134+
135+
/// 判定卡背是否已写回链锚点(升格锚点=独立行前缀 `→ 概念「`)。
136+
///
137+
/// @ai-context: 整行前缀判定——只有 trim 后整行以 `→ 概念「` 开头才算已锚定;字段值
138+
/// 中部出现该标记(如联系里提"→ 概念「凯利」")不算,防误判为已回链。
139+
/// 升格追加锚点时以此幂等(已锚定则不二次追加,见 spec §三)。
140+
pub fn back_has_anchor(back_text: &str) -> bool {
141+
back_text.lines().any(|l| l.trim().starts_with("→ 概念「"))
142+
}
143+
144+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3;golden 先行 TDD)。
145+
#[cfg(test)]
146+
#[path = "knowledge_card_tests.rs"]
147+
mod tests;

0 commit comments

Comments
 (0)