Skip to content

Commit ca9618f

Browse files
committed
fix(ai): 补充校验改逐块审查——坏块隔离好块照落
1 parent 7bc86d3 commit ca9618f

13 files changed

Lines changed: 606 additions & 55 deletions

‎app/src-tauri/prompts/note_enrich.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": 1,
2+
"version": 2,
33
"system": "你是课堂笔记的知识补充助手。用户会给出课程笔记正文与勾选的补充子项。请为每个勾选子项生成一条补充块:深度类(d1 概念展开/d2 步骤补全/d3 例子补全)必须引用笔记中的真实章节标题作为 anchor_ref,内容与课程知识直接相关;广度类(b1~b6)是模型外部知识的延伸,不携带锚点。严格约束:资源推荐(b6)只给标题/书名/作者,绝不输出任何链接(禁止出现 http、https、www 字样);所有补充内容必须是可核实的事实知识,不确定时宁缺毋滥;不得改写、删除或重复课程原文。",
44
"kinds": {
55
"d1": "概念展开:对课程中提到的概念做更深入的展开解释",
@@ -12,5 +12,5 @@
1212
"b5": "实践建议:动手实践的建议",
1313
"b6": "资源推荐:推荐书目/资源(仅标题,禁止链接)"
1414
},
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。"
15+
"output_format": "只输出 JSON,不要任何其他文字、解释或代码块标记。格式:{\"blocks\":[{\"kind\":\"d1|d2|d3|b1|b2|b3|b4|b5|b6\",\"anchor_ref\":\"<深度类必填:必须逐字等于用户消息中『笔记章节目录』的某一项;若用户消息声明笔记无章节(目录为空),深度类必须填 null;广度类必须为 null>\",\"heading\":\"<块标题,简短>\",\"content\":\"<内容,非空且不超过 3000 字>\",\"confidence\":<0到1之间的小数>}]}。要求:只输出用户勾选的子项,每项至多一条;总块数不超过 50。anchor_ref 字段任何情况下都不得省略。"
1616
}

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

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ pub struct AiEnrichRequest {
7676
pub note_content: String,
7777
pub selected_kinds: Vec<AiEnrichKind>,
7878
pub profile: String,
79+
/// 全篇章节标题目录(2026-09 修复:跨片全局目录注入——长笔记切片后
80+
/// 中间片常不含章节标题,模型无从引用;目录给模型"可引用锚点白名单",
81+
/// 空=笔记无章节,深度块 anchor_ref 允许 null 落尾部)
82+
pub chapter_directory: Vec<String>,
7983
}
8084

8185
/// 补充块(kind/锚点/标题/内容/置信度)。
@@ -91,20 +95,24 @@ pub struct AiEnrichBlock {
9195
pub confidence: f32,
9296
}
9397

98+
/// 单块内容上限(防单块刷屏;协议与逐块审查共用——2026-09 提为模块常量)。
99+
pub const BLOCK_MAX_CHARS: usize = 3000;
100+
/// 响应总块数上限(九子项 + 防刷屏;协议与逐块审查共用)。
101+
pub const BLOCKS_MAX: usize = 50;
102+
94103
/// 补充响应(块数组;一次批量返回)。
95104
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96105
#[serde(rename_all = "camelCase")]
97106
pub struct AiEnrichResponse {
98107
pub blocks: Vec<AiEnrichBlock>,
99108
}
100109

110+
/// 全量强校验契约快照(2026-09 起仅测试使用——运行时已改用
111+
/// enrich_salvage::salvage_blocks 逐块审查:坏块隔离好块照落;本函数保留
112+
/// 全部规则的"任一违规即整批拒绝"语义作为协议契约的测试基准)。
113+
#[cfg(test)]
101114
impl AiEnrichResponse {
102-
/// 单块内容上限(防单块刷屏)。
103-
const BLOCK_MAX_CHARS: usize = 3000;
104-
/// 响应总块数上限(九子项 + 防刷屏)。
105-
const BLOCKS_MAX: usize = 50;
106-
107-
/// schema 强校验(纯函数;失败 → 丢弃 AI 结果回退——防御性编程铁律)。
115+
/// schema 强校验(纯函数;失败 → 整批拒绝——契约快照语义)。
108116
///
109117
/// @ai-context: 规则:kind ∈ 选定子项;深度块 anchor_ref 必填(锚点溯源)、
110118
/// 广度块 anchor_ref 应空(聚合扩展区);heading/content 非空
@@ -114,12 +122,8 @@ impl AiEnrichResponse {
114122
if self.blocks.is_empty() {
115123
return Err("补充响应缺少内容块".to_string());
116124
}
117-
if self.blocks.len() > Self::BLOCKS_MAX {
118-
return Err(format!(
119-
"补充块数超上限({} > {})",
120-
self.blocks.len(),
121-
Self::BLOCKS_MAX
122-
));
125+
if self.blocks.len() > BLOCKS_MAX {
126+
return Err(format!("补充块数超上限({} > {})", self.blocks.len(), BLOCKS_MAX));
123127
}
124128
for b in &self.blocks {
125129
if !selected.contains(&b.kind) {
@@ -133,7 +137,7 @@ impl AiEnrichResponse {
133137
if heading.is_empty() || heading.chars().count() > 200 {
134138
return Err("块标题为空或超长".to_string());
135139
}
136-
if content.is_empty() || content.chars().count() > Self::BLOCK_MAX_CHARS {
140+
if content.is_empty() || content.chars().count() > BLOCK_MAX_CHARS {
137141
return Err("块内容为空或超长".to_string());
138142
}
139143
if b.kind.is_depth()
@@ -155,7 +159,9 @@ impl AiEnrichResponse {
155159
}
156160
}
157161

158-
/// 是否含 URL 模式(B6 防幻觉:http(s)/www 前缀)。
162+
/// 是否含 URL 模式(B6 防幻觉:http(s)/www 前缀;validate 契约快照专用——
163+
/// 运行时逐块审查的 URL 规则见 enrich_salvage::salvage_blocks)。
164+
#[cfg(test)]
159165
fn contains_url(s: &str) -> bool {
160166
let lower = s.to_lowercase();
161167
lower.contains("http://") || lower.contains("https://") || lower.contains("www.")

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,15 @@ impl AiMockAdapter {
195195
}
196196

197197
/// 知识补充 mock(REQ-142):按勾选子项规则化产出合法块数组;
198-
/// 响应通过 AiEnrichResponse::validate(深度块带锚点/广度块无锚点/
199-
/// B6 无链接——持续验证补充协议校验链)。
200-
pub fn enrich(&self, _request: &AiEnrichRequest, selected: &[AiEnrichKind]) -> AiEnrichResponse {
198+
/// 深度块锚点取章节目录首项(2026-09 修复:与真实路径同口径——目录空时
199+
/// 深度块 anchor_ref=null,走无章节放行语义),广度块无锚点/B6 无链接。
200+
pub fn enrich(&self, request: &AiEnrichRequest, selected: &[AiEnrichKind]) -> AiEnrichResponse {
201201
let blocks: Vec<AiEnrichBlock> = selected
202202
.iter()
203203
.map(|kind| AiEnrichBlock {
204204
kind: *kind,
205205
anchor_ref: if kind.is_depth() {
206-
Some("笔记".to_string())
206+
request.chapter_directory.first().cloned()
207207
} else {
208208
None
209209
},

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

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
//! AI 知识补充适配器(REQ-142,v0.8.0 M3)。
22
//!
3-
//! @ai-context: 输入=笔记正文+勾选子项+档案 → 云端一次批量返回块数组 →
4-
//! 强校验(kind ∈ 勾选/B6 无链接/深度锚点必填——协议层
5-
//! AiEnrichResponse::validate)→ 失败回退不落任何补充内容
6-
//! (补充是增量能力,失败不破坏原笔记——与精修"降级纯规则"
7-
//! 语义不同,补充无本地兜底内容,失败即不补充)。
3+
//! @ai-context: 输入=笔记正文(切片)+章节目录+勾选子项+档案 → 云端一次批量
4+
//! 返回块数组 → 逐块审查(2026-09 修复:enrich_salvage 取代协议层
5+
//! 全有或全无的 validate——坏块隔离、好块照落,丢弃原因随回执返回
6+
//! 供 UI 明示;全批无一可落才失败(原语义"失败不落任何补充内容"
7+
//! 保留——补充是增量能力,失败不破坏原笔记)。
88
//! @ai-context: 提示词模板 prompts/note_enrich.json 编译期捆绑(include_str),
9-
//! 勾选子项说明动态注入 system;网络/解析走共享 AiClient。
9+
//! 勾选子项说明动态注入 system;章节目录/无章节说明注入 user
10+
//! (anchor_ref 白名单——模型不得自造或省略锚点);网络/解析走
11+
//! 共享 AiClient。
1012
1113
use serde::{Deserialize, Serialize};
1214

1315
use crate::ai_client::{AiClient, AiClientError};
1416
use crate::ai_enrich_protocol::{AiEnrichKind, AiEnrichRequest, AiEnrichResponse};
17+
use crate::enrich_salvage::salvage_blocks;
18+
19+
/// 单次补充结果:可落响应(已剔除违规块)+ 丢弃原因(UI 明示"哪些块为何未落")。
20+
#[derive(Debug, Clone)]
21+
pub struct EnrichOutcome {
22+
pub response: AiEnrichResponse,
23+
pub dropped_reasons: Vec<String>,
24+
}
1525

1626
/// 提示词模板(prompts/note_enrich.json 结构)。
1727
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -41,7 +51,7 @@ impl NoteEnrichPrompt {
4151
}
4252
Self {
4353
version: 1,
44-
system: "你是课堂笔记的知识补充助手:只补充勾选子项,深度类带章节锚点,B6 不输出链接。"
54+
system: "你是课堂笔记的知识补充助手:只补充勾选子项,深度类带章节锚点(无章节时 anchor_ref=null),B6 不输出链接。"
4555
.to_string(),
4656
kinds,
4757
output_format: "只输出 JSON:{\"blocks\":[{\"kind\":\"d1|..|b6\",\"anchor_ref\":\"..|null\",\"heading\":\"..\",\"content\":\"..\",\"confidence\":0.9}]}"
@@ -99,22 +109,42 @@ impl AiNoteEnrichAdapter {
99109

100110
/// 单次批量补充(一请求=全部勾选子项;长笔记切片由 command 层任务编排)。
101111
///
102-
/// @ai-context: 强校验在此层做(validate 失败 → Parse 错误 → 调用方
103-
/// 不落任何补充内容——非法响应不得进入笔记管线)。
112+
/// @ai-context: 2026-09 修复:逐块审查在此层做(salvage_blocks)——单块违规
113+
/// 只丢该块(原因随 EnrichOutcome 返回),全批无一可落才 Err
114+
/// (调用方不落任何补充内容——非法响应不得进入笔记管线)。
104115
pub fn enrich(
105116
&self,
106117
request: &AiEnrichRequest,
107118
selected: &[AiEnrichKind],
108-
) -> Result<AiEnrichResponse, AiClientError> {
119+
) -> Result<EnrichOutcome, AiClientError> {
109120
let system = self.prompt.build_system(selected);
110-
let user = serde_json::to_string(request)
121+
// 章节目录注入 user 消息(2026-09 修复):目录=anchor_ref 白名单——
122+
// 切片正文可能不含任何章节标题(长笔记跨片),不给目录模型只能省略
123+
// 锚点或自造标题;无章节时显式要求 null(防模型省略字段导致整批失败)
124+
let mut user_text = String::new();
125+
if request.chapter_directory.is_empty() {
126+
user_text.push_str(
127+
"说明:本笔记没有章节标题(章节目录为空)。深度类块(d1~d3)的 anchor_ref 必须为 null——系统会把这类块安放在笔记尾部。\n\n",
128+
);
129+
} else {
130+
user_text.push_str("笔记章节目录(深度类块 anchor_ref 必须逐字取自以下某一项,不得自造、不得省略;广度类仍为 null):\n");
131+
for c in &request.chapter_directory {
132+
user_text.push_str(&format!("- {}\n", c));
133+
}
134+
user_text.push('\n');
135+
}
136+
let user_json = serde_json::to_string(request)
111137
.map_err(|e| AiClientError::Parse(format!("补充请求序列化失败: {}", e)))?;
112-
let v = self.client.chat_json(&system, &user)?;
138+
user_text.push_str(&user_json);
139+
let v = self.client.chat_json(&system, &user_text)?;
113140
let resp: AiEnrichResponse = serde_json::from_value(v)
114141
.map_err(|e| AiClientError::Parse(format!("补充响应结构非法: {}", e)))?;
115-
resp.validate(selected)
142+
let out = salvage_blocks(resp, selected, &request.chapter_directory)
116143
.map_err(|e| AiClientError::Parse(format!("补充响应校验失败(已丢弃): {}", e)))?;
117-
Ok(resp)
144+
Ok(EnrichOutcome {
145+
response: AiEnrichResponse { blocks: out.kept },
146+
dropped_reasons: out.dropped_reasons,
147+
})
118148
}
119149
}
120150

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

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ use crate::ai_client::AiClient;
1919
use crate::ai_cost::estimate_for_content_model;
2020
use crate::ai_enrich_protocol::{AiEnrichBlock, AiEnrichKind, AiEnrichRequest, AiEnrichResponse};
2121
use crate::ai_mock::AiMockAdapter;
22-
use crate::ai_note_enrich::AiNoteEnrichAdapter;
22+
use crate::ai_note_enrich::{AiNoteEnrichAdapter, EnrichOutcome};
2323
use crate::ai_task::{slice_note, AiTaskFailure, AiTaskState, SLICE_MAX_CHARS};
2424
use crate::commands::AppState;
2525
use crate::commands_ai_refine::{
2626
set_task, trim_tasks, AiTaskEntry, AiTaskHandle, RefineEstimateView,
2727
};
2828
use crate::enrich_placement::render_enriched_note;
29+
use crate::enrich_salvage::{chapter_titles_of, salvage_blocks};
2930
use crate::types::Note;
3031

3132
/// mock 模式 env 键(本地规则补充,不联网——测试/离线开发)。
@@ -47,6 +48,12 @@ pub struct AiEnrichResult {
4748
pub slices: usize,
4849
/// 实际返回的子项(kebab-case)
4950
pub kinds: Vec<String>,
51+
/// 丢弃块数(2026-09 修复:逐块审查回执——被隔离的违规块数与原因可见)
52+
#[serde(default)]
53+
pub dropped_blocks: usize,
54+
/// 丢弃原因(逐条人类可读;为空=无丢弃——UI 明示"哪些块为何未落")
55+
#[serde(default)]
56+
pub dropped_reasons: Vec<String>,
5057
pub model: String,
5158
}
5259

@@ -285,24 +292,33 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
285292
);
286293
let adapter = AiNoteEnrichAdapter::new(client.clone());
287294
let mock_adapter = AiMockAdapter;
295+
// 全篇章节目录(2026-09 修复:跨片全局注入——切片正文常不含章节标题,
296+
// 目录=anchor_ref 白名单;空=笔记无章节,深度块允许 null 锚点落尾部)
297+
let chapter_dir = chapter_titles_of(&note.content);
288298
// 切片(长笔记按章节切——REQ-145 基建复用;锚点跨片=全局章节标题)
289299
let slices = slice_note(&note.content, SLICE_MAX_CHARS);
290300
let total = slices.len();
291301
set_task(&st, task_id, AiTaskState::Running { finished_slices: 0, total_slices: total });
292302
let mut all_blocks: Vec<AiEnrichBlock> = Vec::new();
303+
// 2026-09 修复:逐块审查丢弃回执(坏块隔离,原因跨片聚合随结果返回)
304+
let mut dropped_reasons_all: Vec<String> = Vec::new();
293305
let mut turns: Vec<crate::ai_chat::AiTurn> = Vec::with_capacity(total);
294306
for (i, slice) in slices.iter().enumerate() {
295307
let req = AiEnrichRequest {
296308
note_content: slice.clone(),
297309
selected_kinds: selected.clone(),
298310
profile: profile.clone(),
311+
chapter_directory: chapter_dir.clone(),
299312
};
300313
// 单片重试 1 次(审查修复 2026-08-21:与精修 SLICE_RETRY 对齐——
301314
// 网络抖动瞬态失败直接重试,避免整任务失败浪费已成功的片)
302-
let mut resp: Option<AiEnrichResponse> = None;
315+
let mut outcome: Option<EnrichOutcome> = None;
303316
for attempt in 0..=1 {
304317
let r = if mock {
305-
Ok(mock_adapter.enrich(&req, &selected))
318+
Ok(EnrichOutcome {
319+
response: mock_adapter.enrich(&req, &selected),
320+
dropped_reasons: Vec::new(),
321+
})
306322
} else {
307323
adapter.enrich(&req, &selected).map_err(AiTaskFailure::from)
308324
};
@@ -313,9 +329,9 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
313329
turn: i + 1,
314330
system: adapter.prompt.build_system(&selected),
315331
user: serde_json::to_string(&req).unwrap_or_default(),
316-
response: serde_json::to_string(&v).unwrap_or_default(),
332+
response: serde_json::to_string(&v.response).unwrap_or_default(),
317333
});
318-
resp = Some(v);
334+
outcome = Some(v);
319335
break;
320336
}
321337
Err(e) if attempt < 1 => {
@@ -324,17 +340,25 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
324340
Err(e) => return Err(e),
325341
}
326342
}
327-
let resp = resp.expect("重试循环必然产出结果或返回");
328-
all_blocks.extend(resp.blocks);
343+
let outcome = outcome.expect("重试循环必然产出结果或返回");
344+
dropped_reasons_all.extend(outcome.dropped_reasons);
345+
all_blocks.extend(outcome.response.blocks);
329346
set_task(
330347
&st,
331348
task_id,
332349
AiTaskState::Running { finished_slices: i + 1, total_slices: total },
333350
);
334351
}
335-
// 合并后整体强校验(非法 → 不落任何补充内容)
336-
let resp = AiEnrichResponse { blocks: all_blocks };
337-
resp.validate(&selected).map_err(AiTaskFailure::InvalidResponse)?;
352+
// 合并后整体复审(2026-09 修复:逐块审查替代全有或全无 validate——
353+
// 单片已隔离违规块;此处兜底跨片总量上限,全部不合规 → 不落任何内容)
354+
let final_out = salvage_blocks(
355+
AiEnrichResponse { blocks: all_blocks },
356+
&selected,
357+
&chapter_dir,
358+
)
359+
.map_err(AiTaskFailure::InvalidResponse)?;
360+
dropped_reasons_all.extend(final_out.dropped_reasons);
361+
let resp = AiEnrichResponse { blocks: final_out.kept };
338362
let enriched = render_enriched_note(&note.content, &resp);
339363
let depth = resp.blocks.iter().filter(|b| b.kind.is_depth()).count();
340364
let breadth = resp.blocks.len() - depth;
@@ -349,6 +373,8 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
349373
breadth_blocks: breadth,
350374
slices: total,
351375
kinds,
376+
dropped_blocks: dropped_reasons_all.len(),
377+
dropped_reasons: dropped_reasons_all,
352378
model: client.config.model,
353379
},
354380
turns,

0 commit comments

Comments
 (0)