|
| 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