|
| 1 | +//! 决策/应用命令层(v0.13.3 REQ-208~210 系统层;决策日志 + 记一次使用"一表两面")。 |
| 2 | +//! |
| 3 | +//! @ai-context: 本层只做参数校验、调用数据层、错误映射(AGENTS.md §6);编排 `fn xxx_inner(db, …)` |
| 4 | +//! 为纯函数(:memory: 可测),薄 `#[tauri::command]` 壳只取 state.db 调 inner。一表两面: |
| 5 | +//! kind 区分 decision(思辨面)/application(学习面·记一次使用),不双表双记、只记"我的决策"。 |
| 6 | +//! @ai-context: used_refs 结构契约在 knowledge_pure::validate_decision_input(纯函数);本层其后做 |
| 7 | +//! **存在性校验**(require_refs_exist——引用 id 不存在→"引用不存在: kind/id")与 application |
| 8 | +//! 挂载规则(概念/体系至少一;概念模式需引用该概念;体系模式需 ≥1 证据引用)。指标 best-effort。 |
| 9 | +//! @ai-context: log_application 事务遵循 promote_fragment_to_note 手法——db 层 create_application_tx |
| 10 | +//! 单方法内显式事务(插行 + 可选 set_concept_applied),任一步失败整体回滚,杜绝半态。 |
| 11 | +
|
| 12 | +use tauri::State; |
| 13 | + |
| 14 | +use crate::commands::AppState; |
| 15 | +use crate::commands_knowledge::{normalize_text, require_id}; |
| 16 | +use crate::db::{unix_seconds, Db}; |
| 17 | +use crate::knowledge_pure::validate_decision_input; |
| 18 | +use crate::types::{KnowledgeDecision, NewKnowledgeDecision}; |
| 19 | + |
| 20 | +/// used_refs 规范化 JSON 的解析结果(本层存在性校验用;键域与 validate_decision_input 一致, |
| 21 | +/// snake_case——纯函数存储态即 snake_case,与 UsedRefs 类型的 camelCase 序列化无关)。 |
| 22 | +struct ParsedRefs { |
| 23 | + node_ids: Vec<i64>, |
| 24 | + concept_ids: Vec<i64>, |
| 25 | + model_ids: Vec<i64>, |
| 26 | + group_id: Option<i64>, |
| 27 | + card_id: Option<i64>, |
| 28 | + note_id: Option<i64>, |
| 29 | + fragment_id: Option<i64>, |
| 30 | +} |
| 31 | + |
| 32 | +/// 解析规范化 used_refs JSON(snake_case 键)为结构;纯函数已保证形状与键合法。 |
| 33 | +fn parse_refs(normalized: &str) -> Result<ParsedRefs, String> { |
| 34 | + let v: serde_json::Value = serde_json::from_str(normalized).map_err(|_| "引用格式错误".to_string())?; |
| 35 | + let obj = v.as_object().ok_or_else(|| "引用格式错误".to_string())?; |
| 36 | + let ids = |key: &str| -> Vec<i64> { |
| 37 | + obj.get(key) |
| 38 | + .and_then(|x| x.as_array()) |
| 39 | + .map(|a| a.iter().filter_map(|n| n.as_i64()).collect()) |
| 40 | + .unwrap_or_default() |
| 41 | + }; |
| 42 | + let one = |key: &str| -> Option<i64> { obj.get(key).and_then(|x| x.as_i64()) }; |
| 43 | + Ok(ParsedRefs { |
| 44 | + node_ids: ids("node_ids"), |
| 45 | + concept_ids: ids("concept_ids"), |
| 46 | + model_ids: ids("model_ids"), |
| 47 | + group_id: one("group_id"), |
| 48 | + card_id: one("card_id"), |
| 49 | + note_id: one("note_id"), |
| 50 | + fragment_id: one("fragment_id"), |
| 51 | + }) |
| 52 | +} |
| 53 | + |
| 54 | +/// 校验 used_refs 中所有引用目标存在(实体/证据;任一缺失报"引用不存在: kind/id")。 |
| 55 | +/// |
| 56 | +/// @ai-context: 结构契约(validate_decision_input)成立后才查询——此处是**证据契约** |
| 57 | +/// (防挂空引用膨胀;引用必填但还需真实存在)。实体走 get_knowledge_node/ |
| 58 | +/// concept/model,证据走 get_group/get_card/get_note/get_fragment。 |
| 59 | +fn require_refs_exist(db: &Db, refs: &ParsedRefs) -> Result<(), String> { |
| 60 | + for nid in &refs.node_ids { |
| 61 | + if db.get_knowledge_node(*nid).map_err(|e| e.to_string())?.is_none() { |
| 62 | + return Err(format!("引用不存在: node/{}", nid)); |
| 63 | + } |
| 64 | + } |
| 65 | + for cid in &refs.concept_ids { |
| 66 | + if db.get_knowledge_concept(*cid).map_err(|e| e.to_string())?.is_none() { |
| 67 | + return Err(format!("引用不存在: concept/{}", cid)); |
| 68 | + } |
| 69 | + } |
| 70 | + for mid in &refs.model_ids { |
| 71 | + if db.get_knowledge_model(*mid).map_err(|e| e.to_string())?.is_none() { |
| 72 | + return Err(format!("引用不存在: model/{}", mid)); |
| 73 | + } |
| 74 | + } |
| 75 | + if let Some(g) = refs.group_id { |
| 76 | + if db.get_group(g).map_err(|e| e.to_string())?.is_none() { |
| 77 | + return Err(format!("引用不存在: group/{}", g)); |
| 78 | + } |
| 79 | + } |
| 80 | + if let Some(c) = refs.card_id { |
| 81 | + if db.get_card(c).map_err(|e| e.to_string())?.is_none() { |
| 82 | + return Err(format!("引用不存在: card/{}", c)); |
| 83 | + } |
| 84 | + } |
| 85 | + if let Some(n) = refs.note_id { |
| 86 | + if db.get_note(n).map_err(|e| e.to_string())?.is_none() { |
| 87 | + return Err(format!("引用不存在: note/{}", n)); |
| 88 | + } |
| 89 | + } |
| 90 | + if let Some(f) = refs.fragment_id { |
| 91 | + if db.get_fragment(f).map_err(|e| e.to_string())?.is_none() { |
| 92 | + return Err(format!("引用不存在: fragment/{}", f)); |
| 93 | + } |
| 94 | + } |
| 95 | + Ok(()) |
| 96 | +} |
| 97 | + |
| 98 | +/// 可选文本字段 trim + 空串转 None(四行法可选字段,避免存空字符串/纯空白)。 |
| 99 | +fn opt_trim(v: Option<String>) -> Option<String> { |
| 100 | + v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) |
| 101 | +} |
| 102 | + |
| 103 | +/// 记一条决策(思辨面;kind 硬置 decision;引用必填且须真实存在)。 |
| 104 | +/// |
| 105 | +/// @ai-context: 只记"我的决策"——content 为决策内容,四行法(expectation/actual/reflection) |
| 106 | +/// 可选;体系必在;question_id 可选挂问题树节点(若给须存在)。used_refs 经 |
| 107 | +/// 纯函数规范化 + 存在性校验后落库(防挂空引用膨胀);decision_logged 审计计数。 |
| 108 | +#[tauri::command] |
| 109 | +#[allow(clippy::too_many_arguments)] // 字段 = 命令入参契约(决策四行法 + 引用),非冗余设计 |
| 110 | +pub fn log_decision( |
| 111 | + state: State<'_, AppState>, |
| 112 | + system_id: i64, |
| 113 | + question_id: Option<i64>, |
| 114 | + content: String, |
| 115 | + expectation: Option<String>, |
| 116 | + actual: Option<String>, |
| 117 | + reflection: Option<String>, |
| 118 | + used_refs: String, |
| 119 | +) -> Result<KnowledgeDecision, String> { |
| 120 | + log_decision_inner(&state.db, system_id, question_id, content, expectation, actual, reflection, used_refs) |
| 121 | +} |
| 122 | + |
| 123 | +/// 记一条应用"记一次使用"(学习面;kind=application;概念/体系挂载规则见下)。 |
| 124 | +/// |
| 125 | +/// @ai-context: 挂载规则(决策 R-A)——concept_id 与 system_id **至少其一**(空应用拒绝); |
| 126 | +/// 概念模式:concept 须存在,system_id 若同时传且 ≠ concept.system_id 拒绝, |
| 127 | +/// used_refs 须含 concept_ids=[concept_id];体系模式(仅 system_id):used_refs 须含 |
| 128 | +/// ≥1 证据引用(group/card/note/fragment)。事务在 db 层 create_application_tx |
| 129 | +/// (插行 + 可选 set_concept_applied 同步原子),application_logged 审计计数。 |
| 130 | +#[tauri::command] |
| 131 | +#[allow(clippy::too_many_arguments)] // 字段 = 命令入参契约(挂载 + 四行法 + 引用),非冗余设计 |
| 132 | +pub fn log_application( |
| 133 | + state: State<'_, AppState>, |
| 134 | + concept_id: Option<i64>, |
| 135 | + system_id: Option<i64>, |
| 136 | + content: String, |
| 137 | + expectation: Option<String>, |
| 138 | + actual: Option<String>, |
| 139 | + reflection: Option<String>, |
| 140 | + used_refs: String, |
| 141 | +) -> Result<KnowledgeDecision, String> { |
| 142 | + log_application_inner(&state.db, concept_id, system_id, content, expectation, actual, reflection, used_refs) |
| 143 | +} |
| 144 | + |
| 145 | +/// 列出决策/应用(system_id/kind 过滤可选;limit 默认 50、须 1~500)。 |
| 146 | +/// |
| 147 | +/// @ai-context: 一表两面合表返回,前端按 kind 分 tab;kind ∈ {decision, application} 白名单; |
| 148 | +/// limit 为条数上限(非法 → 报错,防任意大查询)。 |
| 149 | +#[tauri::command] |
| 150 | +pub fn list_decisions( |
| 151 | + state: State<'_, AppState>, |
| 152 | + system_id: Option<i64>, |
| 153 | + kind: Option<String>, |
| 154 | + limit: Option<i64>, |
| 155 | +) -> Result<Vec<KnowledgeDecision>, String> { |
| 156 | + list_decisions_inner(&state.db, system_id, kind, limit) |
| 157 | +} |
| 158 | + |
| 159 | +/// 读单条决策/应用(不存在报错)。 |
| 160 | +#[tauri::command] |
| 161 | +pub fn get_decision(state: State<'_, AppState>, id: i64) -> Result<KnowledgeDecision, String> { |
| 162 | + get_decision_inner(&state.db, id) |
| 163 | +} |
| 164 | + |
| 165 | +/// 删单条决策/应用(幂等——不存在返回 false 不报错)。 |
| 166 | +#[tauri::command] |
| 167 | +pub fn delete_decision(state: State<'_, AppState>, id: i64) -> Result<bool, String> { |
| 168 | + delete_decision_inner(&state.db, id) |
| 169 | +} |
| 170 | + |
| 171 | +/// 记决策编排(校验 + 规范化 + 存在性 + 落库 + 审计计数)。 |
| 172 | +#[allow(clippy::too_many_arguments)] // 字段 = 命令入参契约,与命令壳对齐 |
| 173 | +pub(crate) fn log_decision_inner( |
| 174 | + db: &Db, |
| 175 | + system_id: i64, |
| 176 | + question_id: Option<i64>, |
| 177 | + content: String, |
| 178 | + expectation: Option<String>, |
| 179 | + actual: Option<String>, |
| 180 | + reflection: Option<String>, |
| 181 | + used_refs: String, |
| 182 | +) -> Result<KnowledgeDecision, String> { |
| 183 | + require_id(system_id)?; |
| 184 | + if db.get_knowledge_system(system_id).map_err(|e| e.to_string())?.is_none() { |
| 185 | + return Err(format!("体系不存在: {}", system_id)); |
| 186 | + } |
| 187 | + if let Some(qid) = question_id { |
| 188 | + require_id(qid)?; |
| 189 | + if db.get_knowledge_node(qid).map_err(|e| e.to_string())?.is_none() { |
| 190 | + return Err(format!("引用不存在: node/{}", qid)); |
| 191 | + } |
| 192 | + } |
| 193 | + let content = normalize_text(&content, "决策内容")?; |
| 194 | + let normalized = validate_decision_input(&used_refs, "decision")?; |
| 195 | + let parsed = parse_refs(&normalized)?; |
| 196 | + require_refs_exist(db, &parsed)?; |
| 197 | + let rec = db |
| 198 | + .create_decision(&NewKnowledgeDecision { |
| 199 | + kind: "decision".to_string(), |
| 200 | + system_id: Some(system_id), |
| 201 | + question_id, |
| 202 | + used_refs: normalized, |
| 203 | + content, |
| 204 | + expectation: opt_trim(expectation), |
| 205 | + actual: opt_trim(actual), |
| 206 | + reflection: opt_trim(reflection), |
| 207 | + }) |
| 208 | + .map_err(|e| e.to_string())?; |
| 209 | + let payload = serde_json::json!({ "systemId": system_id, "questionId": question_id }).to_string(); |
| 210 | + let _ = db.add_metric_event("decision_logged", &payload); |
| 211 | + Ok(rec) |
| 212 | +} |
| 213 | + |
| 214 | +/// 记应用编排(挂载规则 + 校验 + 存在性 + 事务落库 + 审计计数)。 |
| 215 | +#[allow(clippy::too_many_arguments)] // 字段 = 命令入参契约,与命令壳对齐 |
| 216 | +pub(crate) fn log_application_inner( |
| 217 | + db: &Db, |
| 218 | + concept_id: Option<i64>, |
| 219 | + system_id: Option<i64>, |
| 220 | + content: String, |
| 221 | + expectation: Option<String>, |
| 222 | + actual: Option<String>, |
| 223 | + reflection: Option<String>, |
| 224 | + used_refs: String, |
| 225 | +) -> Result<KnowledgeDecision, String> { |
| 226 | + if concept_id.is_none() && system_id.is_none() { |
| 227 | + return Err("应用记录必须挂概念或体系".to_string()); |
| 228 | + } |
| 229 | + if let Some(cid) = concept_id { |
| 230 | + require_id(cid)?; |
| 231 | + } |
| 232 | + if let Some(sid) = system_id { |
| 233 | + require_id(sid)?; |
| 234 | + } |
| 235 | + let content = normalize_text(&content, "应用内容")?; |
| 236 | + let normalized = validate_decision_input(&used_refs, "application")?; |
| 237 | + let parsed = parse_refs(&normalized)?; |
| 238 | + |
| 239 | + let mut applied_concept_id: Option<i64> = None; |
| 240 | + let final_system_id: Option<i64>; |
| 241 | + if let Some(cid) = concept_id { |
| 242 | + let concept = db |
| 243 | + .get_knowledge_concept(cid) |
| 244 | + .map_err(|e| e.to_string())? |
| 245 | + .ok_or_else(|| format!("概念不存在: {}", cid))?; |
| 246 | + if !parsed.concept_ids.contains(&cid) { |
| 247 | + return Err("应用记录需引用该概念".to_string()); |
| 248 | + } |
| 249 | + if let Some(sid) = system_id { |
| 250 | + if sid != concept.system_id { |
| 251 | + return Err("应用概念不属于该体系".to_string()); |
| 252 | + } |
| 253 | + } |
| 254 | + applied_concept_id = Some(cid); |
| 255 | + final_system_id = Some(concept.system_id); |
| 256 | + } else { |
| 257 | + let sid = system_id.ok_or_else(|| "应用记录必须挂概念或体系".to_string())?; |
| 258 | + if db.get_knowledge_system(sid).map_err(|e| e.to_string())?.is_none() { |
| 259 | + return Err(format!("体系不存在: {}", sid)); |
| 260 | + } |
| 261 | + if parsed.group_id.is_none() |
| 262 | + && parsed.card_id.is_none() |
| 263 | + && parsed.note_id.is_none() |
| 264 | + && parsed.fragment_id.is_none() |
| 265 | + { |
| 266 | + return Err("体系级应用需证据引用".to_string()); |
| 267 | + } |
| 268 | + final_system_id = Some(sid); |
| 269 | + } |
| 270 | + |
| 271 | + require_refs_exist(db, &parsed)?; |
| 272 | + let now = unix_seconds(); |
| 273 | + let rec = db |
| 274 | + .create_application_tx( |
| 275 | + &NewKnowledgeDecision { |
| 276 | + kind: "application".to_string(), |
| 277 | + system_id: final_system_id, |
| 278 | + question_id: None, |
| 279 | + used_refs: normalized, |
| 280 | + content, |
| 281 | + expectation: opt_trim(expectation), |
| 282 | + actual: opt_trim(actual), |
| 283 | + reflection: opt_trim(reflection), |
| 284 | + }, |
| 285 | + applied_concept_id, |
| 286 | + now, |
| 287 | + ) |
| 288 | + .map_err(|e| e.to_string())?; |
| 289 | + let payload = serde_json::json!({ "conceptId": concept_id, "systemId": final_system_id }).to_string(); |
| 290 | + let _ = db.add_metric_event("application_logged", &payload); |
| 291 | + Ok(rec) |
| 292 | +} |
| 293 | + |
| 294 | +/// 列出决策/应用编排(kind 白名单 + limit 范围)。 |
| 295 | +pub(crate) fn list_decisions_inner( |
| 296 | + db: &Db, |
| 297 | + system_id: Option<i64>, |
| 298 | + kind: Option<String>, |
| 299 | + limit: Option<i64>, |
| 300 | +) -> Result<Vec<KnowledgeDecision>, String> { |
| 301 | + if let Some(sid) = system_id { |
| 302 | + require_id(sid)?; |
| 303 | + } |
| 304 | + if let Some(k) = kind.as_deref() { |
| 305 | + if k != "decision" && k != "application" { |
| 306 | + return Err(format!("不支持的类型: {}(支持: decision/application)", k)); |
| 307 | + } |
| 308 | + } |
| 309 | + let limit = limit.unwrap_or(50); |
| 310 | + if limit <= 0 || limit > 500 { |
| 311 | + return Err(format!("limit 需在 1~500 之间: {}", limit)); |
| 312 | + } |
| 313 | + db.list_decisions(system_id, kind.as_deref(), limit as usize).map_err(|e| e.to_string()) |
| 314 | +} |
| 315 | + |
| 316 | +/// 读单条编排(不存在报错)。 |
| 317 | +pub(crate) fn get_decision_inner(db: &Db, id: i64) -> Result<KnowledgeDecision, String> { |
| 318 | + require_id(id)?; |
| 319 | + db.get_decision(id).map_err(|e| e.to_string())?.ok_or_else(|| format!("决策不存在: {}", id)) |
| 320 | +} |
| 321 | + |
| 322 | +/// 删单条编排(幂等)。 |
| 323 | +pub(crate) fn delete_decision_inner(db: &Db, id: i64) -> Result<bool, String> { |
| 324 | + require_id(id)?; |
| 325 | + db.delete_decision(id).map_err(|e| e.to_string()) |
| 326 | +} |
| 327 | + |
| 328 | +/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。 |
| 329 | +#[cfg(test)] |
| 330 | +#[path = "commands_knowledge_decisions_tests.rs"] |
| 331 | +mod tests; |
0 commit comments