Skip to content

Commit b4e7e79

Browse files
committed
feat(ai): v0.8.0 M3 知识补充——AiEnrich 九子项/混合落位/信任标注/B6无链接 (REQ-142)
1 parent fcd09c4 commit b4e7e79

19 files changed

Lines changed: 1342 additions & 16 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,20 @@
33
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 与 [SemVer](https://semver.org/lang/zh-CN/)。
44
各版本的深度版本文档见 [docs/versions/](docs/versions/)。
55

6-
## [0.8.0] - 2026-08(AI 接入,M1/M2 开发中)
6+
## [0.8.0] - 2026-08(AI 接入,M1/M2/M3 开发中)
7+
8+
### 新增(M3 知识补充,REQ-142)
9+
10+
- **知识补充入口**(笔记详情页「✨ 知识补充」,与精修语义分开):九子项勾选面板(深度 D1 概念展开/D2 步骤补全/D3 例子补全 + 广度 B1 前置知识/B2 进阶/B3 横向关联/B4 对比辨析/B5 实践建议/B6 资源推荐;**记忆上次选择** localStorage)
11+
- **AiEnrich 协议**:一次批量返回块数组(省请求)+ schema 强校验(kind ∈ 勾选子项/深度块锚点必填/广度块不带锚点/**B6 块禁止 URL——仅标题不输出链接**,防幻觉约束)
12+
- **混合落位**:深度块按 anchor_ref 就近插入引用章节之下(引用风边框 + "AI 展开"徽标 + 溯源锚点;同锚点合并);广度块聚合笔记尾部"扩展区"("AI 补充·非课程内容·需核实"徽标 + 需核实声明 + 各自成节);锚点未命中追加尾部不丢块
13+
- **删除与撤销**:采纳=update_note 覆盖内容;撤销=base 还原(删除无残留);重补=新版本(M4 版本管理消费)
14+
- **切片复用**:长笔记按章节切片(REQ-145 基建复用,进度按片上报);任务注册表/事件/容量守卫与精修共用(AiTaskEntry.result 泛化为 JSON);`AI_ENRICH_MOCK=1` 离线开发路径
15+
- 提示词模板 `prompts/note_enrich.json`(九子项说明动态注入,B6 无链接约束在 system 层)
16+
17+
### 测试
18+
19+
- 单测 +17(全量 1253 通过):九子项分类/label · validate 强校验(未请求子项/深度缺锚点/广度带锚点/B6 含 URL 拒绝/置信度越界/超量)· 混合落位(深度就近/广度聚合/同锚点合并/锚点未命中不丢块)· 提示词模板解析/勾选子项注入
720

821
### 新增(M2 会话→笔记 AI 精修,REQ-141/145 + REQ-143 基础版)
922

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"version": 1,
3+
"system": "你是课堂笔记的知识补充助手。用户会给出课程笔记正文与勾选的补充子项。请为每个勾选子项生成一条补充块:深度类(d1 概念展开/d2 步骤补全/d3 例子补全)必须引用笔记中的真实章节标题作为 anchor_ref,内容与课程知识直接相关;广度类(b1~b6)是模型外部知识的延伸,不携带锚点。严格约束:资源推荐(b6)只给标题/书名/作者,绝不输出任何链接(禁止出现 http、https、www 字样);所有补充内容必须是可核实的事实知识,不确定时宁缺毋滥;不得改写、删除或重复课程原文。",
4+
"kinds": {
5+
"d1": "概念展开:对课程中提到的概念做更深入的展开解释",
6+
"d2": "步骤补全:对课程中的步骤/流程补全缺失的细节",
7+
"d3": "例子补全:为课程概念补充具体例子",
8+
"b1": "前置知识:学习本节需要的前置知识",
9+
"b2": "进阶方向:进一步学习的进阶方向",
10+
"b3": "横向关联:与相关领域/概念的横向关联",
11+
"b4": "对比辨析:与相似概念的对比辨析",
12+
"b5": "实践建议:动手实践的建议",
13+
"b6": "资源推荐:推荐书目/资源(仅标题,禁止链接)"
14+
},
15+
"output_format": "只输出 JSON,不要任何其他文字、解释或代码块标记。格式:{\"blocks\":[{\"kind\":\"d1|d2|d3|b1|b2|b3|b4|b5|b6\",\"anchor_ref\":\"<深度类必填:笔记中的章节标题;广度类必须为 null>\",\"heading\":\"<块标题,简短>\",\"content\":\"<内容,非空且不超过 3000 字>\",\"confidence\":<0到1之间的小数>}]}。要求:只输出用户勾选的子项,每项至多一条;总块数不超过 50。"
16+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//! ai_enrich_protocol.rs 单测(AAA 模式)。
2+
//!
3+
//! @ai-context: 覆盖:九子项枚举(深度/广度分类/label)、validate 强校验
4+
//! (未请求子项/深度缺锚点/广度带锚点/B6 含 URL/置信度越界/
5+
//! 空块/超量)。
6+
7+
use crate::ai_enrich_protocol::{AiEnrichBlock, AiEnrichKind, AiEnrichResponse};
8+
9+
fn block(kind: AiEnrichKind, anchor: Option<&str>, content: &str) -> AiEnrichBlock {
10+
AiEnrichBlock {
11+
kind,
12+
anchor_ref: anchor.map(|s| s.to_string()),
13+
heading: format!("{}标题", kind.label()),
14+
content: content.to_string(),
15+
confidence: 0.9,
16+
}
17+
}
18+
19+
#[test]
20+
fn kinds_classification_and_labels() {
21+
assert!(AiEnrichKind::D1.is_depth() && !AiEnrichKind::D1.is_breadth());
22+
assert!(AiEnrichKind::B6.is_breadth() && !AiEnrichKind::B6.is_depth());
23+
assert_eq!(AiEnrichKind::all().len(), 9);
24+
assert_eq!(AiEnrichKind::B6.label(), "资源推荐");
25+
assert_eq!(AiEnrichKind::D2.label(), "步骤补全");
26+
}
27+
28+
#[test]
29+
fn validate_accepts_legal_mixed_response() {
30+
let selected = vec![AiEnrichKind::D1, AiEnrichKind::B1];
31+
let resp = AiEnrichResponse {
32+
blocks: vec![
33+
block(AiEnrichKind::D1, Some("第三章"), "概念补充内容"),
34+
block(AiEnrichKind::B1, None, "前置知识内容"),
35+
],
36+
};
37+
assert!(resp.validate(&selected).is_ok());
38+
}
39+
40+
#[test]
41+
fn validate_rejects_unrequested_kind() {
42+
// 只请求了 D1——返回 B1 → 拒绝(协议契约)
43+
let selected = vec![AiEnrichKind::D1];
44+
let resp = AiEnrichResponse { blocks: vec![block(AiEnrichKind::B1, None, "x")] };
45+
assert!(resp.validate(&selected).is_err());
46+
}
47+
48+
#[test]
49+
fn validate_rejects_depth_block_without_anchor() {
50+
let selected = vec![AiEnrichKind::D1];
51+
let resp = AiEnrichResponse { blocks: vec![block(AiEnrichKind::D1, None, "缺锚点")] };
52+
assert!(resp.validate(&selected).is_err());
53+
}
54+
55+
#[test]
56+
fn validate_rejects_breadth_block_with_anchor() {
57+
let selected = vec![AiEnrichKind::B1];
58+
let resp = AiEnrichResponse { blocks: vec![block(AiEnrichKind::B1, Some("章节"), "x")] };
59+
assert!(resp.validate(&selected).is_err());
60+
}
61+
62+
#[test]
63+
fn validate_rejects_b6_with_url() {
64+
// B6 防幻觉:content 含 URL → 拒绝(仅标题不输出链接)
65+
let selected = vec![AiEnrichKind::B6];
66+
let resp = AiEnrichResponse { blocks: vec![block(AiEnrichKind::B6, None, "推荐《X》https://example.com/book")] };
67+
assert!(resp.validate(&selected).is_err(), "B6 含 URL 必须拒绝");
68+
// www 前缀同样拒绝
69+
let resp2 = AiEnrichResponse { blocks: vec![block(AiEnrichKind::B6, None, "www.xxx.com 的资源")] };
70+
assert!(resp2.validate(&selected).is_err());
71+
// 无链接 → 通过
72+
let ok = AiEnrichResponse { blocks: vec![block(AiEnrichKind::B6, None, "推荐《卷积神经网络》作者:XXX")] };
73+
assert!(ok.validate(&selected).is_ok());
74+
}
75+
76+
#[test]
77+
fn validate_rejects_empty_or_overflow() {
78+
let selected: Vec<AiEnrichKind> = AiEnrichKind::all().to_vec();
79+
assert!(AiEnrichResponse { blocks: vec![] }.validate(&selected).is_err());
80+
// 超量(51 块)
81+
let blocks: Vec<AiEnrichBlock> = (0..51)
82+
.map(|i| block(AiEnrichKind::B1, None, &format!("内容{}", i)))
83+
.collect();
84+
assert!(AiEnrichResponse { blocks }.validate(&selected).is_err());
85+
// 置信度越界
86+
let mut b = block(AiEnrichKind::B1, None, "x");
87+
b.confidence = 1.5;
88+
assert!(AiEnrichResponse { blocks: vec![b] }.validate(&selected).is_err());
89+
// 空内容
90+
let mut b = block(AiEnrichKind::B1, None, " ");
91+
b.confidence = 0.5;
92+
assert!(AiEnrichResponse { blocks: vec![b] }.validate(&selected).is_err());
93+
}

‎app/src-tauri/src/ai_mock.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::ai_protocol::{
1515
use crate::ai_refine_protocol::{
1616
AiRefineBlock, AiRefineBlockType, AiRefineRequest, AiRefineResponse, AiRefineSection,
1717
};
18+
use crate::ai_enrich_protocol::{AiEnrichBlock, AiEnrichKind, AiEnrichRequest, AiEnrichResponse};
1819

1920
/// mock 适配器(无状态;后续云端适配器实现同一 trait)。
2021
pub struct AiMockAdapter;
@@ -149,6 +150,27 @@ impl AiMockAdapter {
149150
.collect();
150151
AiRefineResponse { sections }
151152
}
153+
154+
/// 知识补充 mock(REQ-142):按勾选子项规则化产出合法块数组;
155+
/// 响应通过 AiEnrichResponse::validate(深度块带锚点/广度块无锚点/
156+
/// B6 无链接——持续验证补充协议校验链)。
157+
pub fn enrich(&self, _request: &AiEnrichRequest, selected: &[AiEnrichKind]) -> AiEnrichResponse {
158+
let blocks: Vec<AiEnrichBlock> = selected
159+
.iter()
160+
.map(|kind| AiEnrichBlock {
161+
kind: *kind,
162+
anchor_ref: if kind.is_depth() {
163+
Some("笔记".to_string())
164+
} else {
165+
None
166+
},
167+
heading: format!("{}(mock)", kind.label()),
168+
content: format!("{} 的 mock 补充内容", kind.label()),
169+
confidence: 0.9,
170+
})
171+
.collect();
172+
AiEnrichResponse { blocks }
173+
}
152174
}
153175

154176
/// 口头禅/寒暄特征(mock 判定用):短句且含填充词或问候前缀。

0 commit comments

Comments
 (0)