Skip to content

Commit 5de14a4

Browse files
committed
feat(knowledge): 决策与应用(v0.13.3)——决策/应用一表两面命令与概念面板接入
1 parent e78f03b commit 5de14a4

23 files changed

Lines changed: 2522 additions & 26 deletions
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
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

Comments
 (0)