Skip to content

Commit 0390d3c

Browse files
committed
feat(knowledge): 模型卡创建与升格 commands
1 parent eb7cfec commit 0390d3c

5 files changed

Lines changed: 635 additions & 1 deletion

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
//! 概念模型卡命令层(v0.13.2 REQ-206~207 系统层)。
2+
//!
3+
//! @ai-context: 概念双面体单向升格——组内 model 卡(记忆面 front=概念名、back=三问)升格为
4+
//! 体系内概念(思辨面);只引用不收纳(knowledge_links(target_type=flashcard)),
5+
//! 不新增 flashcards.concept_id 列。本层只做参数校验、编排数据层/纯函数、错误映射;
6+
//! `fn xxx_inner(db, ...)` 为纯函数(:memory: 可测),薄 command 壳只取 state.db。
7+
//! @ai-context: 线宽豁免登记(AGENTS.md §3 300-600 带):本文件承载创建/组列表两命令 + 共享
8+
//! 卡面/时刻工具,≤300 行;卡→概念升格(四分支 + PromoteResult)拆至
9+
//! commands_knowledge_cards_promote.rs(同一命令域,避免单文件超限)。
10+
11+
use tauri::State;
12+
13+
use crate::commands::AppState;
14+
use crate::commands_knowledge::{normalize_text, require_id, NAME_MAX_CHARS};
15+
use crate::db::Db;
16+
use crate::db_flashcards::NewFlashcard;
17+
use crate::knowledge_card::format_model_card_back;
18+
use crate::scheduler::CardState;
19+
use crate::types::Flashcard;
20+
21+
/// 组卡类型白名单(list_group_cards 的 kind 过滤;事实/动作/概念模型卡)。
22+
const KIND_WHITELIST: [&str; 3] = ["fact", "action", "model"];
23+
24+
/// 当前时刻(Unix 毫秒;新卡 due_at 统一口径——与 commands_flashcards 相同)。
25+
///
26+
/// @ai-context: 新 model 卡 due_at=now(立即可复习——首次学习也是提取优先)。
27+
fn now_ms() -> u64 {
28+
std::time::SystemTime::now()
29+
.duration_since(std::time::UNIX_EPOCH)
30+
.map(|d| d.as_millis() as u64)
31+
.unwrap_or(0)
32+
}
33+
34+
/// 卡面可选字段:trim + 空串转 None;非空字段 ≤2000 字符校验(超长报错)。
35+
///
36+
/// @ai-context: 卡面契约"缺问 → None"(非"有但为空"——前端按占位渲染);超长拒绝防污染 DB。
37+
fn card_field(v: Option<String>) -> Result<Option<String>, String> {
38+
match v {
39+
Some(s) => {
40+
let t = s.trim().to_string();
41+
if t.is_empty() {
42+
Ok(None)
43+
} else if t.chars().count() > NAME_MAX_CHARS {
44+
Err(format!("卡面字段超长(上限 {} 字符)", NAME_MAX_CHARS))
45+
} else {
46+
Ok(Some(t))
47+
}
48+
}
49+
None => Ok(None),
50+
}
51+
}
52+
53+
/// 在组内建概念模型卡(kind='model';幂等——同组同 front 已有则返回既有卡)。
54+
///
55+
/// @ai-context: front=归一化概念名、back=三问契约(format_model_card_back 组合);组仍是唯一
56+
/// 容器(spec §一)——卡挂组内,升格后才进体系。
57+
#[tauri::command]
58+
pub fn create_model_card(
59+
state: State<'_, AppState>,
60+
group_id: i64,
61+
name: String,
62+
essence: Option<String>,
63+
boundary: Option<String>,
64+
relation: Option<String>,
65+
) -> Result<Flashcard, String> {
66+
create_model_card_inner(&state.db, group_id, name, essence, boundary, relation)
67+
}
68+
69+
/// 组卡列表(kind 过滤可选;模型卡列表数据源)。
70+
#[tauri::command]
71+
pub fn list_group_cards(
72+
state: State<'_, AppState>,
73+
group_id: i64,
74+
kind: Option<String>,
75+
) -> Result<Vec<Flashcard>, String> {
76+
list_group_cards_inner(&state.db, group_id, kind)
77+
}
78+
79+
/// 建概念模型卡(组存在 + 名归一化 + 卡面契约 + 幂等复用)。
80+
pub(crate) fn create_model_card_inner(
81+
db: &Db,
82+
group_id: i64,
83+
name: String,
84+
essence: Option<String>,
85+
boundary: Option<String>,
86+
relation: Option<String>,
87+
) -> Result<Flashcard, String> {
88+
require_id(group_id)?;
89+
if db.get_group(group_id).map_err(|e| e.to_string())?.is_none() {
90+
return Err(format!("笔记组不存在: {}", group_id));
91+
}
92+
let name = normalize_text(&name, "概念名")?;
93+
// 幂等:同组同 front(归一化名)已有 → 返回既有卡(不新建,防 UI 双击双卡)
94+
if let Some(existing) = db.find_card_by_front(group_id, &name).map_err(|e| e.to_string())? {
95+
return Ok(existing);
96+
}
97+
let essence = card_field(essence)?;
98+
let boundary = card_field(boundary)?;
99+
let relation = card_field(relation)?;
100+
let back = format_model_card_back(essence.as_deref(), boundary.as_deref(), relation.as_deref());
101+
let state_json = serde_json::to_string(&CardState::default()).unwrap_or_default();
102+
db.create_card(&NewFlashcard {
103+
group_id,
104+
note_id: None,
105+
fragment_id: None,
106+
front: name,
107+
back,
108+
kind: "model".to_string(),
109+
state_json,
110+
due_at: now_ms() as i64,
111+
})
112+
.map_err(|e| e.to_string())
113+
}
114+
115+
/// 组卡列表(require_id + kind 白名单校验)。
116+
pub(crate) fn list_group_cards_inner(
117+
db: &Db,
118+
group_id: i64,
119+
kind: Option<String>,
120+
) -> Result<Vec<Flashcard>, String> {
121+
require_id(group_id)?;
122+
let kind = kind.as_deref().filter(|k| !k.trim().is_empty());
123+
if let Some(k) = kind {
124+
if !KIND_WHITELIST.contains(&k) {
125+
return Err(format!("不支持的卡片类型: {}(支持: {})", k, KIND_WHITELIST.join("/")));
126+
}
127+
}
128+
db.list_cards_by_group(group_id, kind).map_err(|e| e.to_string())
129+
}
130+
131+
/// 命令层单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
132+
#[cfg(test)]
133+
#[path = "commands_knowledge_cards_tests.rs"]
134+
mod tests;
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
//! 概念模型卡→概念升格命令层(v0.13.2 REQ-207 系统层;commands_knowledge_cards 拆出)。
2+
//!
3+
//! @ai-context: 卡→概念升格四分支(Create/Merge/Hint/Already)——组内 model 卡经 promote_rules
4+
//! 决策升格为体系内概念(思辨面);只引用不收纳(knowledge_links(target_type=flashcard));
5+
//! 锚点为卡背独立行 `→ 概念「name」`(back_has_anchor 幂等防重复回链)。
6+
//! @ai-context: 线宽豁免登记:本文件承载升格命令 + PromoteResult + 四分支编排(同一命令域),
7+
//! 自 commands_knowledge_cards.rs 拆出以保持各文件 ≤300 行(AGENTS.md §3)。
8+
9+
use tauri::State;
10+
11+
use serde::Serialize;
12+
13+
use crate::commands::AppState;
14+
use crate::commands_knowledge::{normalize_text, require_id};
15+
use crate::commands_knowledge_core::link_knowledge_target_inner;
16+
use crate::db::Db;
17+
use crate::knowledge_card::{back_has_anchor, parse_model_card_back};
18+
use crate::knowledge_pure::{PromoteDecision, PromoteInput};
19+
use crate::types::{KnowledgeConcept, KnowledgeLink, KnowledgeSystem, NewKnowledgeConcept};
20+
21+
/// 升格结果(camelCase 序列化回传前端)。
22+
///
23+
/// @ai-context: action ∈ created/merged/hinted/already;decision 透传 promote_rules 决策;
24+
/// concept=落库/命中的概念;link=新建引用(created/merged)或 None
25+
/// (hinted/already——未落库/既有引用)。
26+
#[derive(Debug, Clone, Serialize)]
27+
#[serde(rename_all = "camelCase")]
28+
pub struct PromoteResult {
29+
pub action: String,
30+
pub decision: PromoteDecision,
31+
pub concept: KnowledgeConcept,
32+
pub link: Option<KnowledgeLink>,
33+
}
34+
35+
/// 卡→概念升格(Create/Merge/Hint/Already 四分支)。
36+
///
37+
/// @ai-context: target_system_id 缺省取全局体系(无全局 → 引导先建);前置校验卡存在且
38+
/// kind='model'(非概念卡拒绝);空名在 normalize_text 拒空(promote_rules panic 前置)。
39+
#[tauri::command]
40+
pub fn promote_card_to_concept(
41+
state: State<'_, AppState>,
42+
card_id: i64,
43+
target_system_id: Option<i64>,
44+
) -> Result<PromoteResult, String> {
45+
promote_card_to_concept_inner(&state.db, card_id, target_system_id)
46+
}
47+
48+
/// 卡→概念升格(规格 §四 5 步:卡校验→目标体系→AlreadyLinked 免重复→promote_rules→分派)。
49+
pub(crate) fn promote_card_to_concept_inner(
50+
db: &Db,
51+
card_id: i64,
52+
target_system_id: Option<i64>,
53+
) -> Result<PromoteResult, String> {
54+
require_id(card_id)?;
55+
let card = db
56+
.get_card(card_id)
57+
.map_err(|e| e.to_string())?
58+
.ok_or_else(|| format!("卡片不存在: {}", card_id))?;
59+
if card.kind != "model" {
60+
return Err("仅概念卡(model 卡)可纳入体系".to_string());
61+
}
62+
let target = resolve_target_system(db, target_system_id)?;
63+
// AlreadyLinked 免重复:目标体系内该卡已有闪卡引用 → 直接返回既有关联(不重跑)
64+
let links = db
65+
.list_knowledge_links(target.id, None, None, None)
66+
.map_err(|e| e.to_string())?;
67+
if let Some(link) = links.iter().find(|l| {
68+
l.target_type == "flashcard" && l.target_id == card_id && l.concept_id.is_some()
69+
}) {
70+
let cid = link.concept_id.ok_or_else(|| "引用缺少概念".to_string())?;
71+
let concept = db
72+
.get_knowledge_concept(cid)
73+
.map_err(|e| e.to_string())?
74+
.ok_or_else(|| format!("概念不存在: {}", cid))?;
75+
return Ok(PromoteResult {
76+
action: "already".to_string(),
77+
decision: PromoteDecision::AlreadyLinked { concept_id: cid },
78+
concept,
79+
link: Some(link.clone()),
80+
});
81+
}
82+
// 组装 PromoteInput:name 归一化前置(拒空——promote_rules 空名 panic 前置)
83+
let name = normalize_text(&card.front, "概念名")?;
84+
let existing: Vec<(i64, String, i64)> = db
85+
.find_concept_by_name(&name)
86+
.map_err(|e| e.to_string())?
87+
.map(|c| (c.id, c.name, c.system_id))
88+
.into_iter()
89+
.collect();
90+
let input = PromoteInput {
91+
card_name: name.clone(),
92+
existing,
93+
target_system_id: target.id,
94+
};
95+
let decision = crate::knowledge_pure::promote_rules(&input);
96+
match decision {
97+
PromoteDecision::Create => {
98+
let parsed = parse_model_card_back(&card.back);
99+
let concept = db
100+
.add_knowledge_concept(&NewKnowledgeConcept {
101+
system_id: target.id,
102+
name: name.clone(),
103+
essence: parsed.essence,
104+
boundary: parsed.boundary,
105+
relation: parsed.relation,
106+
})
107+
.map_err(|e| {
108+
let msg = e.to_string();
109+
if msg.contains("UNIQUE") {
110+
"概念已存在,请合并".to_string()
111+
} else {
112+
msg
113+
}
114+
})?;
115+
let link = link_knowledge_target_inner(
116+
db, target.id, None, Some(concept.id), None, "flashcard".to_string(), card_id,
117+
)?;
118+
append_anchor(db, card_id, &card.back, &name)?;
119+
record_promoted_metric(db, card_id, concept.id);
120+
Ok(PromoteResult { action: "created".to_string(), decision, concept, link: Some(link) })
121+
}
122+
PromoteDecision::Merge { concept_id } => {
123+
let concept = db
124+
.get_knowledge_concept(concept_id)
125+
.map_err(|e| e.to_string())?
126+
.ok_or_else(|| format!("概念不存在: {}", concept_id))?;
127+
let link = link_knowledge_target_inner(
128+
db, target.id, None, Some(concept.id), None, "flashcard".to_string(), card_id,
129+
)?;
130+
append_anchor(db, card_id, &card.back, &name)?;
131+
record_promoted_metric(db, card_id, concept.id);
132+
Ok(PromoteResult { action: "merged".to_string(), decision, concept, link: Some(link) })
133+
}
134+
PromoteDecision::CrossSystemHint { concept_id, other_system_id: _ } => {
135+
// 仅提示不落库(v0.13.4 交叉点数据源);无 link/无 metric/无锚点追加
136+
let concept = db
137+
.get_knowledge_concept(concept_id)
138+
.map_err(|e| e.to_string())?
139+
.ok_or_else(|| format!("概念不存在: {}", concept_id))?;
140+
Ok(PromoteResult { action: "hinted".to_string(), decision, concept, link: None })
141+
}
142+
PromoteDecision::AlreadyLinked { concept_id } => {
143+
// 防御分支(步骤 3 已拦截,基本不达;仍透传既有关联)
144+
let concept = db
145+
.get_knowledge_concept(concept_id)
146+
.map_err(|e| e.to_string())?
147+
.ok_or_else(|| format!("概念不存在: {}", concept_id))?;
148+
let link = db
149+
.list_knowledge_links(target.id, None, None, None)
150+
.map_err(|e| e.to_string())?
151+
.into_iter()
152+
.find(|l| {
153+
l.target_type == "flashcard"
154+
&& l.target_id == card_id
155+
&& l.concept_id == Some(concept_id)
156+
});
157+
Ok(PromoteResult { action: "already".to_string(), decision, concept, link })
158+
}
159+
}
160+
}
161+
162+
/// 目标体系解析:传则校验存在性;未传取全局体系(无→引导先建全局体系)。
163+
fn resolve_target_system(db: &Db, target_system_id: Option<i64>) -> Result<KnowledgeSystem, String> {
164+
match target_system_id {
165+
Some(tid) => {
166+
require_id(tid)?;
167+
db.get_knowledge_system(tid)
168+
.map_err(|e| e.to_string())?
169+
.ok_or_else(|| format!("体系不存在: {}", tid))
170+
}
171+
None => db
172+
.find_global_system()
173+
.map_err(|e| e.to_string())?
174+
.ok_or_else(|| "请先创建全局体系".to_string()),
175+
}
176+
}
177+
178+
/// 升格锚点追加(幂等:back_has_anchor 已锚定则不二次追加)。
179+
fn append_anchor(db: &Db, card_id: i64, back: &str, name: &str) -> Result<(), String> {
180+
if back_has_anchor(back) {
181+
return Ok(());
182+
}
183+
let new_back = format!("{}\n→ 概念「{}」", back, name);
184+
db.update_card_back(card_id, &new_back).map_err(|e| e.to_string())?;
185+
Ok(())
186+
}
187+
188+
/// 埋 concept_promoted 指标(best-effort——与 card_reviewed/fragment_upgraded 同口径,
189+
/// Payload 带 cardId/conceptId 供前端与后续体系统计)。
190+
fn record_promoted_metric(db: &Db, card_id: i64, concept_id: i64) {
191+
let payload = serde_json::json!({ "cardId": card_id, "conceptId": concept_id }).to_string();
192+
let _ = db.add_metric_event("concept_promoted", &payload);
193+
}

0 commit comments

Comments
 (0)