Skip to content

Commit f91d802

Browse files
committed
feat(knowledge): 体系基建 commands
1 parent 7d06509 commit f91d802

6 files changed

Lines changed: 1338 additions & 0 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//! 知识体系命令层共享校验(v0.13.1 REQ-202~205 系统层)。
2+
//!
3+
//! @ai-context: 本文件收敛命令层入参校验统一契约(AGENTS.md §6/§4):id>0、
4+
//! 白名单(kind/type/status/target_type)、名称归一化(trim + 连续空白
5+
//! 折叠为一个空格——规格 §二 不可变约束:概念/体系/节点名落库前归一,
6+
//! 使 `find_concept_by_name` 查重命中同一书写)、name/text 非空且 ≤2000。
7+
//! @ai-context: 校验全部纯函数、无 DB、无时间调用,供 commands_knowledge_systems
8+
//! (体系/问题树)与 commands_knowledge_core(概念/模型/引用/审计)复用。
9+
//! @ai-context: 线宽豁免登记:原单文件 commands_knowledge.rs(18 命令 + 校验 + 测试)
10+
//! 预估超 300 行,按规格 §四拆 commands_knowledge_systems.rs 与
11+
//! commands_knowledge_core.rs,本文件仅收敛公共校验(各拆分文件 ≤300 行)。
12+
//! @ai-context: 入参出参契约——name/text 归一化后 ≤2000 字符;status/target_type
13+
//! 白名单拒绝任意字符串入库(Tauri IPC 入参校验红线)。
14+
15+
use crate::db_knowledge_links::LinkTarget;
16+
17+
/// 名称/文本最大长度(防超长字符串污染 DB 与 IPC,规格 §四 统一 ≤2000)。
18+
pub(crate) const NAME_MAX_CHARS: usize = 2000;
19+
20+
/// 体系类型白名单(global 全库唯一;domain 挂 global)。
21+
const KIND_WHITELIST: [&str; 2] = ["global", "domain"];
22+
/// 节点类型白名单(问题树三态:问题/场景/领域入口)。
23+
const NODE_WHITELIST: [&str; 3] = ["question", "scenario", "domain_entry"];
24+
/// 实体通用状态白名单(system/node/model)。
25+
const COMMON_STATUS: [&str; 3] = ["active", "watching", "archived"];
26+
/// 概念状态白名单(概念三态,与通用态差异在 active→core)。
27+
const CONCEPT_STATUS: [&str; 3] = ["core", "watching", "archived"];
28+
29+
/// 校验 id>0(所有 id 入参统一前置——防 0/负 id 触发空查询或错误级联)。
30+
pub(crate) fn require_id(id: i64) -> Result<(), String> {
31+
if id > 0 {
32+
Ok(())
33+
} else {
34+
Err(format!("无效的 id: {}", id))
35+
}
36+
}
37+
38+
/// 校验体系类型白名单(kind ∈ {global, domain})。
39+
pub(crate) fn require_kind(kind: &str) -> Result<(), String> {
40+
if KIND_WHITELIST.contains(&kind) {
41+
Ok(())
42+
} else {
43+
Err(format!("不支持的体系类型: {}(支持: {})", kind, KIND_WHITELIST.join("/")))
44+
}
45+
}
46+
47+
/// 校验节点类型白名单(type ∈ {question, scenario, domain_entry})。
48+
pub(crate) fn require_node_type(t: &str) -> Result<(), String> {
49+
if NODE_WHITELIST.contains(&t) {
50+
Ok(())
51+
} else {
52+
Err(format!("不支持的节点类型: {}(支持: {})", t, NODE_WHITELIST.join("/")))
53+
}
54+
}
55+
56+
/// 校验实体状态白名单:system/node/model 取 active/watching/archived,
57+
/// concept 取 core/watching/archived(概念以核心/观望/归档三态区分)。
58+
pub(crate) fn require_status(entity: &str, st: &str) -> Result<(), String> {
59+
let list: &[&str] = if entity == "concept" { &CONCEPT_STATUS } else { &COMMON_STATUS };
60+
if list.contains(&st) {
61+
Ok(())
62+
} else {
63+
Err(format!("不支持的状态: {}(支持: {})", st, list.join("/")))
64+
}
65+
}
66+
67+
/// 名称/文本归一化:trim + 连续空白折叠为一个空格;空/超长报中文业务错误。
68+
///
69+
/// @ai-context: 名称归一化在命令层执行后落库(spec §二 不可变约束)——同一概念的
70+
/// 不同书写(多余空格/换行)视为同一名称;`find_concept_by_name` 用
71+
/// 归一化名查重,保证概念名全库唯一。split_whitespace 天然处理 trim
72+
/// 与任意空白折叠。
73+
/// @param raw - 原始名称/文本
74+
/// @param label - 业务术语("体系名称"/"概念名称"/"节点内容"…),用于错误文案
75+
pub(crate) fn normalize_text(raw: &str, label: &str) -> Result<String, String> {
76+
let norm = raw.split_whitespace().collect::<Vec<_>>().join(" ");
77+
if norm.is_empty() {
78+
return Err(format!("{}不能为空", label));
79+
}
80+
if norm.chars().count() > NAME_MAX_CHARS {
81+
return Err(format!("{}超长(上限 {} 字符)", label, NAME_MAX_CHARS));
82+
}
83+
Ok(norm)
84+
}
85+
86+
/// 校验引用目标类型白名单并解析为枚举(note_group/note/flashcard/fragment)。
87+
pub(crate) fn parse_target_type(s: &str) -> Result<LinkTarget, String> {
88+
match s {
89+
"note_group" => Ok(LinkTarget::NoteGroup),
90+
"note" => Ok(LinkTarget::Note),
91+
"flashcard" => Ok(LinkTarget::Flashcard),
92+
"fragment" => Ok(LinkTarget::Fragment),
93+
_ => Err(format!("不支持的目标类型: {}(支持: note_group/note/flashcard/fragment)", s)),
94+
}
95+
}
96+
97+
/// 校验并归一化学科 JSON:解析为 Vec<String>,过滤空白项,要求 ≥1 非空,再序列化落库。
98+
///
99+
/// @ai-context: 前端传 JSON 数组字符串;解析后校验 ≥1 非空学科(模型跨学科命题
100+
/// 至少一门学科支撑),空数组/全空白/非法 JSON 拒绝;存储态规范为
101+
/// serde_json 紧凑序列化(db 层存 JSON 文本,解析由调用方)。
102+
pub(crate) fn normalize_disciplines(json: &str) -> Result<String, String> {
103+
let parsed: Vec<String> = serde_json::from_str(json)
104+
.map_err(|_| format!("学科列表格式错误: {}", json))?;
105+
let trimmed: Vec<String> = parsed
106+
.into_iter()
107+
.map(|s| s.split_whitespace().collect::<Vec<_>>().join(" "))
108+
.filter(|s| !s.is_empty())
109+
.collect();
110+
if trimmed.is_empty() {
111+
return Err("学科列表至少一个非空学科".to_string());
112+
}
113+
serde_json::to_string(&trimmed).map_err(|e| format!("学科序列化失败: {}", e))
114+
}
115+
116+
/// 命令层单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
117+
#[cfg(test)]
118+
#[path = "commands_knowledge_tests.rs"]
119+
mod tests;

0 commit comments

Comments
 (0)