|
| 1 | +//! 知识补充协议(REQ-142,v0.8.0 M3)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 补充=生成新内容(与精修"整理不创作"严格区分——补充是模型 |
| 4 | +//! 外部知识,幻觉风险高):九子项(深度 D1-D3 + 广度 B1-B6), |
| 5 | +//! 一次批量返回块数组(省请求);schema 强校验(kind 枚举/内容 |
| 6 | +//! 非空/深度块锚点必填/B6 无链接——防幻觉约束,规划 §M3 第 2 点)。 |
| 7 | +//! @ai-context: 混合落位(深度就近插入+锚点溯源、广度聚合扩展区)与渲染 |
| 8 | +//! 纯函数在 enrich_placement.rs(本模块只定义协议与校验)。 |
| 9 | +
|
| 10 | +use serde::{Deserialize, Serialize}; |
| 11 | + |
| 12 | +/// 补充子项(九项:深度 3 + 广度 6;kebab-case serde 契约)。 |
| 13 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 14 | +#[serde(rename_all = "kebab-case")] |
| 15 | +pub enum AiEnrichKind { |
| 16 | + /// 深度:概念展开(就近插入引用章节之下) |
| 17 | + D1, |
| 18 | + /// 深度:步骤补全 |
| 19 | + D2, |
| 20 | + /// 深度:例子补全 |
| 21 | + D3, |
| 22 | + /// 广度:前置知识(聚合笔记尾部扩展区) |
| 23 | + B1, |
| 24 | + /// 广度:进阶方向 |
| 25 | + B2, |
| 26 | + /// 广度:横向关联 |
| 27 | + B3, |
| 28 | + /// 广度:对比辨析 |
| 29 | + B4, |
| 30 | + /// 广度:实践建议 |
| 31 | + B5, |
| 32 | + /// 广度:资源推荐(仅标题不输出链接——防幻觉约束) |
| 33 | + B6, |
| 34 | +} |
| 35 | + |
| 36 | +impl AiEnrichKind { |
| 37 | + /// 全部九子项(勾选面板顺序)。 |
| 38 | + pub fn all() -> [AiEnrichKind; 9] { |
| 39 | + [ |
| 40 | + AiEnrichKind::D1, AiEnrichKind::D2, AiEnrichKind::D3, |
| 41 | + AiEnrichKind::B1, AiEnrichKind::B2, AiEnrichKind::B3, |
| 42 | + AiEnrichKind::B4, AiEnrichKind::B5, AiEnrichKind::B6, |
| 43 | + ] |
| 44 | + } |
| 45 | + |
| 46 | + /// 深度类(就近插入引用章节之下——锚点必填)。 |
| 47 | + pub fn is_depth(self) -> bool { |
| 48 | + matches!(self, AiEnrichKind::D1 | AiEnrichKind::D2 | AiEnrichKind::D3) |
| 49 | + } |
| 50 | + |
| 51 | + /// 广度类(聚合笔记尾部扩展区——锚点应空)。 |
| 52 | + pub fn is_breadth(self) -> bool { |
| 53 | + !self.is_depth() |
| 54 | + } |
| 55 | + |
| 56 | + /// 前端展示名。 |
| 57 | + pub fn label(self) -> &'static str { |
| 58 | + match self { |
| 59 | + AiEnrichKind::D1 => "概念展开", |
| 60 | + AiEnrichKind::D2 => "步骤补全", |
| 61 | + AiEnrichKind::D3 => "例子补全", |
| 62 | + AiEnrichKind::B1 => "前置知识", |
| 63 | + AiEnrichKind::B2 => "进阶方向", |
| 64 | + AiEnrichKind::B3 => "横向关联", |
| 65 | + AiEnrichKind::B4 => "对比辨析", |
| 66 | + AiEnrichKind::B5 => "实践建议", |
| 67 | + AiEnrichKind::B6 => "资源推荐", |
| 68 | + } |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +/// 补充请求(一次批量返回全部选定子项)。 |
| 73 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 74 | +#[serde(rename_all = "camelCase")] |
| 75 | +pub struct AiEnrichRequest { |
| 76 | + pub note_content: String, |
| 77 | + pub selected_kinds: Vec<AiEnrichKind>, |
| 78 | + pub profile: String, |
| 79 | +} |
| 80 | + |
| 81 | +/// 补充块(kind/锚点/标题/内容/置信度)。 |
| 82 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 83 | +#[serde(rename_all = "camelCase")] |
| 84 | +pub struct AiEnrichBlock { |
| 85 | + pub kind: AiEnrichKind, |
| 86 | + /// 锚点(深度块必填:引用真实章节标题;广度块应空) |
| 87 | + pub anchor_ref: Option<String>, |
| 88 | + pub heading: String, |
| 89 | + pub content: String, |
| 90 | + /// 置信度 0.0-1.0(低置信块渲染时标注"需核实") |
| 91 | + pub confidence: f32, |
| 92 | +} |
| 93 | + |
| 94 | +/// 补充响应(块数组;一次批量返回)。 |
| 95 | +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 96 | +#[serde(rename_all = "camelCase")] |
| 97 | +pub struct AiEnrichResponse { |
| 98 | + pub blocks: Vec<AiEnrichBlock>, |
| 99 | +} |
| 100 | + |
| 101 | +impl AiEnrichResponse { |
| 102 | + /// 单块内容上限(防单块刷屏)。 |
| 103 | + const BLOCK_MAX_CHARS: usize = 3000; |
| 104 | + /// 响应总块数上限(九子项 + 防刷屏)。 |
| 105 | + const BLOCKS_MAX: usize = 50; |
| 106 | + |
| 107 | + /// schema 强校验(纯函数;失败 → 丢弃 AI 结果回退——防御性编程铁律)。 |
| 108 | + /// |
| 109 | + /// @ai-context: 规则:kind ∈ 选定子项;深度块 anchor_ref 必填(锚点溯源)、 |
| 110 | + /// 广度块 anchor_ref 应空(聚合扩展区);heading/content 非空 |
| 111 | + /// 有界;B6 块 content 禁止 URL 模式(仅标题不输出链接—— |
| 112 | + /// 防幻觉约束,规划验收);confidence ∈ [0,1]。 |
| 113 | + pub fn validate(&self, selected: &[AiEnrichKind]) -> Result<(), String> { |
| 114 | + if self.blocks.is_empty() { |
| 115 | + return Err("补充响应缺少内容块".to_string()); |
| 116 | + } |
| 117 | + if self.blocks.len() > Self::BLOCKS_MAX { |
| 118 | + return Err(format!( |
| 119 | + "补充块数超上限({} > {})", |
| 120 | + self.blocks.len(), |
| 121 | + Self::BLOCKS_MAX |
| 122 | + )); |
| 123 | + } |
| 124 | + for b in &self.blocks { |
| 125 | + if !selected.contains(&b.kind) { |
| 126 | + return Err(format!("返回了未请求的子项: {:?}", b.kind)); |
| 127 | + } |
| 128 | + if !(0.0..=1.0).contains(&b.confidence) { |
| 129 | + return Err(format!("置信度越界: {}", b.confidence)); |
| 130 | + } |
| 131 | + let heading = b.heading.trim(); |
| 132 | + let content = b.content.trim(); |
| 133 | + if heading.is_empty() || heading.chars().count() > 200 { |
| 134 | + return Err("块标题为空或超长".to_string()); |
| 135 | + } |
| 136 | + if content.is_empty() || content.chars().count() > Self::BLOCK_MAX_CHARS { |
| 137 | + return Err("块内容为空或超长".to_string()); |
| 138 | + } |
| 139 | + if b.kind.is_depth() |
| 140 | + && b.anchor_ref.as_deref().map(str::trim).unwrap_or("").is_empty() |
| 141 | + { |
| 142 | + return Err(format!( |
| 143 | + "深度块「{}」缺少锚点引用(必须溯源到章节)", |
| 144 | + b.kind.label() |
| 145 | + )); |
| 146 | + } |
| 147 | + if b.kind.is_breadth() && b.anchor_ref.is_some() { |
| 148 | + return Err(format!("广度块「{}」不应携带锚点(聚合扩展区)", b.kind.label())); |
| 149 | + } |
| 150 | + if b.kind == AiEnrichKind::B6 && contains_url(content) { |
| 151 | + return Err("B6 资源推荐禁止输出链接(仅标题——防幻觉约束)".to_string()); |
| 152 | + } |
| 153 | + } |
| 154 | + Ok(()) |
| 155 | + } |
| 156 | +} |
| 157 | + |
| 158 | +/// 是否含 URL 模式(B6 防幻觉:http(s)/www 前缀)。 |
| 159 | +fn contains_url(s: &str) -> bool { |
| 160 | + let lower = s.to_lowercase(); |
| 161 | + lower.contains("http://") || lower.contains("https://") || lower.contains("www.") |
| 162 | +} |
| 163 | + |
| 164 | +/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。 |
| 165 | +#[cfg(test)] |
| 166 | +#[path = "ai_enrich_protocol_tests.rs"] |
| 167 | +mod tests; |
0 commit comments