|
| 1 | +//! 概念双面体——模型卡卡面纯函数(v0.13.2 REQ-206)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 概念双面体(记忆面×思辨面)单向升格——组内 model 卡(front=概念名、 |
| 4 | +//! back=三问)经升格成为体系内概念。本文件定义卡背三问的纯函数契约: |
| 5 | +//! format(compose)与 parse 互为逆;back_has_anchor 判定是否已写回链锚点。 |
| 6 | +//! @ai-context: 锚点=独立行 `→ 概念「name」`,整行前缀判定(行首匹配)——防字段值 |
| 7 | +//! 中部出现"→ 概念「…」"被误判为已锚定(规范 §三),也防升格重复回链。 |
| 8 | +//! @ai-context: M1 原子层 API 供 M2 command 层接入用(机制先行);v0.13.2 升格命令 |
| 9 | +//! 已接线(commands_knowledge_cards*),无 dead_code 残留。 |
| 10 | +
|
| 11 | +/// 卡背三问(概念双面体:本质 / 边界 / 联系)。 |
| 12 | +/// |
| 13 | +/// @ai-context: 三问对应概念三问一用卡片的三个思辨面——本质(它是什么)、 |
| 14 | +/// 边界(适用范围/限制)、联系(与之关联的概念/体系)。任一问缺省 |
| 15 | +/// 表示该卡尚未填写(不代表"无此问"),故用 Option。 |
| 16 | +#[derive(Debug, Clone, PartialEq, Default)] |
| 17 | +pub struct ModelCardBack { |
| 18 | + /// 本质(What——它是什么)。 |
| 19 | + pub essence: Option<String>, |
| 20 | + /// 边界(Where——适用范围/限制)。 |
| 21 | + pub boundary: Option<String>, |
| 22 | + /// 联系(How——与之关联的概念/体系)。 |
| 23 | + pub relation: Option<String>, |
| 24 | +} |
| 25 | + |
| 26 | +/// 卡背字段标签(内部枚举,无公开语义)。 |
| 27 | +#[derive(Clone, Copy)] |
| 28 | +enum Field { |
| 29 | + Essence, |
| 30 | + Boundary, |
| 31 | + Relation, |
| 32 | +} |
| 33 | + |
| 34 | +/// 行首标签判定。 |
| 35 | +/// |
| 36 | +/// @ai-context: full-width `:` 为主,半角 `:` 容错——但只在行首(trim 后)匹配, |
| 37 | +/// 字段值中部出现同名词(如先写"本质"再展开)不误判为标签行。 |
| 38 | +/// 返回 (字段, 标签后内容)。 |
| 39 | +fn split_label(line: &str) -> Option<(Field, &str)> { |
| 40 | + let t = line.trim_start(); |
| 41 | + for (full, half, field) in [ |
| 42 | + ("本质:", "本质:", Field::Essence), |
| 43 | + ("边界:", "边界:", Field::Boundary), |
| 44 | + ("联系:", "联系:", Field::Relation), |
| 45 | + ] { |
| 46 | + if let Some(rest) = t.strip_prefix(full) { |
| 47 | + return Some((field, rest)); |
| 48 | + } |
| 49 | + if let Some(rest) = t.strip_prefix(half) { |
| 50 | + return Some((field, rest)); |
| 51 | + } |
| 52 | + } |
| 53 | + None |
| 54 | +} |
| 55 | + |
| 56 | +/// 把字段行拼回值:保留内部换行,trim 首尾空白;空值 → None。 |
| 57 | +/// |
| 58 | +/// @ai-context: 契约约定"缺标签/缺字段/空字段 → None"——空问不是"有但为空",而是 |
| 59 | +/// 信息缺失(缺这一问),落库后前端按 None 渲染占位而非空串。 |
| 60 | +fn join_trim(lines: &[String]) -> Option<String> { |
| 61 | + let joined = lines.join("\n"); |
| 62 | + let trimmed = joined.trim(); |
| 63 | + if trimmed.is_empty() { |
| 64 | + None |
| 65 | + } else { |
| 66 | + Some(trimmed.to_string()) |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +/// 解析卡背文本为三问(三问契约的 parse 侧)。 |
| 71 | +/// |
| 72 | +/// @ai-context: 标签行(行首 `本质:`/`边界:`/`联系:`)是字段值起点,标签后内容为 |
| 73 | +/// 该字段首行;后续非标签行并入当前字段直到下一标签(保留内部换行)。 |
| 74 | +/// @ai-context: 同名标签后出现者覆盖(后来者胜——重写卡面时旧值作废);首尾空白经 |
| 75 | +/// join_trim 折叠。与 format 互为逆(解析后再格式化幂等)。 |
| 76 | +pub fn parse_model_card_back(back_text: &str) -> ModelCardBack { |
| 77 | + let mut essence: Vec<String> = Vec::new(); |
| 78 | + let mut boundary: Vec<String> = Vec::new(); |
| 79 | + let mut relation: Vec<String> = Vec::new(); |
| 80 | + let mut current: Option<Field> = None; |
| 81 | + |
| 82 | + for line in back_text.lines() { |
| 83 | + match split_label(line) { |
| 84 | + Some((field, rest)) => { |
| 85 | + let target: &mut Vec<String> = match field { |
| 86 | + Field::Essence => { |
| 87 | + essence.clear(); |
| 88 | + &mut essence |
| 89 | + } |
| 90 | + Field::Boundary => { |
| 91 | + boundary.clear(); |
| 92 | + &mut boundary |
| 93 | + } |
| 94 | + Field::Relation => { |
| 95 | + relation.clear(); |
| 96 | + &mut relation |
| 97 | + } |
| 98 | + }; |
| 99 | + target.push(rest.to_string()); |
| 100 | + current = Some(field); |
| 101 | + } |
| 102 | + None => match current { |
| 103 | + Some(Field::Essence) => essence.push(line.to_string()), |
| 104 | + Some(Field::Boundary) => boundary.push(line.to_string()), |
| 105 | + Some(Field::Relation) => relation.push(line.to_string()), |
| 106 | + None => {} |
| 107 | + }, |
| 108 | + } |
| 109 | + } |
| 110 | + |
| 111 | + ModelCardBack { |
| 112 | + essence: join_trim(&essence), |
| 113 | + boundary: join_trim(&boundary), |
| 114 | + relation: join_trim(&relation), |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +/// 组合三问为卡背文本(三问契约的 compose 侧)。 |
| 119 | +/// |
| 120 | +/// @ai-context: 空字段留 `标签:` 空行(占位不省略)——保证三行契约稳定,parse 可幂等 |
| 121 | +/// 往返;与 create_model_card(M2)的卡面契约一致。 |
| 122 | +pub fn format_model_card_back( |
| 123 | + essence: Option<&str>, |
| 124 | + boundary: Option<&str>, |
| 125 | + relation: Option<&str>, |
| 126 | +) -> String { |
| 127 | + format!( |
| 128 | + "本质:{}\n边界:{}\n联系:{}", |
| 129 | + essence.unwrap_or(""), |
| 130 | + boundary.unwrap_or(""), |
| 131 | + relation.unwrap_or("") |
| 132 | + ) |
| 133 | +} |
| 134 | + |
| 135 | +/// 判定卡背是否已写回链锚点(升格锚点=独立行前缀 `→ 概念「`)。 |
| 136 | +/// |
| 137 | +/// @ai-context: 整行前缀判定——只有 trim 后整行以 `→ 概念「` 开头才算已锚定;字段值 |
| 138 | +/// 中部出现该标记(如联系里提"→ 概念「凯利」")不算,防误判为已回链。 |
| 139 | +/// 升格追加锚点时以此幂等(已锚定则不二次追加,见 spec §三)。 |
| 140 | +pub fn back_has_anchor(back_text: &str) -> bool { |
| 141 | + back_text.lines().any(|l| l.trim().starts_with("→ 概念「")) |
| 142 | +} |
| 143 | + |
| 144 | +/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3;golden 先行 TDD)。 |
| 145 | +#[cfg(test)] |
| 146 | +#[path = "knowledge_card_tests.rs"] |
| 147 | +mod tests; |
0 commit comments