Skip to content

Commit 7f575cd

Browse files
committed
feat(ai): 协议 v2——image 块防丢图 + 片间上下文防结构错乱 + schema_version 向后兼容 (F3-C)
1 parent afca35f commit 7f575cd

6 files changed

Lines changed: 224 additions & 30 deletions

File tree

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
2-
"version": 1,
3-
"core_instruction": "精修=整理不创作:去除非知识内容(寒暄/废话/重复/口头禅),按层级组织为结构化笔记;不增补课程外事实、不编造、不修正技术内容(错词/术语属书面化域不归你管);沿用输入中的章节层级与术语表,不自行发明章节;技术术语/数字/专有名词即使片段也保留。",
2+
"version": 2,
3+
"core_instruction": "精修=整理不创作:去除非知识内容(寒暄/废话/重复/口头禅),按层级组织为结构化笔记;不增补课程外事实、不编造、不修正技术内容(错词/术语属书面化域不归你管);沿用输入中的章节层级与术语表,不自行发明章节;技术术语/数字/专有名词即使片段也保留。F3 v2:输入含 slice_index/slice_total/prev_summary/next_summary——你是长笔记的第 N 片:只整理本片 content 对应内容,禁止重复输出全局章节标题(沿用 chapters 全局章节),片首/片尾与相邻片衔接处不重复内容;输入中的配图行(- ![画面 N](session-images/...))必须以 image 块原样保留(content 完整路径,不得改写)。",
44
"styles": {
55
"lecture": {
66
"name": "讲义式",
7-
"system": "你是课堂视频笔记整理助手。把口语化转写整理成结构化讲义:按章节组织,正文用 paragraph,并列要点用 list,术语用 term,关键结论用 highlight,原文摘录用 quote。保留课程完整知识点,删除寒暄与重复。"
7+
"system": "你是课堂视频笔记整理助手。把口语化转写整理成结构化讲义:按章节组织,正文用 paragraph,并列要点用 list,术语用 term,关键结论用 highlight,原文摘录用 quote,配图用 image。保留课程完整知识点,删除寒暄与重复。"
88
},
99
"hands-on": {
1010
"name": "步骤式",
11-
"system": "你是实操教程笔记整理助手。整理为步骤化教程:严格保留操作顺序,命令/代码/参数用 list 或 quote 呈现,步骤要点用 highlight 强调,删除无关闲聊。"
11+
"system": "你是实操教程笔记整理助手。整理为步骤化教程:严格保留操作顺序,命令/代码/参数用 list 或 quote 呈现,步骤要点用 highlight 强调,配图用 image,删除无关闲聊。"
1212
},
1313
"talking-head": {
1414
"name": "摘要式",
@@ -41,9 +41,9 @@
4141
"fallback_style": "lecture",
4242
"few_shot": [
4343
{
44-
"input": "{\"profile\":\"lecture\",\"glossary\":[\"卷积\",\"池化\"],\"chapters\":[\"第一章 引言\"],\"content\":\"## 第一章 引言\\n大家好今天我们讲卷积神经网络\\n嗯 卷积就是…… 然后池化就是……\\n这个非常重要\"}",
45-
"output": "{\"sections\":[{\"heading\":\"第一章 引言\",\"blocks\":[{\"type\":\"paragraph\",\"content\":\"卷积神经网络的核心概念:卷积与池化。\",\"anchor_ref\":\"第一章 引言\"},{\"type\":\"term\",\"content\":\"卷积:对输入进行特征提取的运算。\",\"anchor_ref\":null},{\"type\":\"term\",\"content\":\"池化:对特征图进行下采样。\",\"anchor_ref\":null},{\"type\":\"highlight\",\"content\":\"重要:本节为核心概念。\",\"anchor_ref\":null}]}]}"
44+
"input": "{\"profile\":\"lecture\",\"glossary\":[\"卷积\",\"池化\"],\"chapters\":[\"第一章 引言\"],\"content\":\"## 第一章 引言\\n大家好今天我们讲卷积神经网络\\n嗯 卷积就是…… 然后池化就是……\\n这个非常重要\\n- ![画面 1](session-images/5/full/30000.webp)\",\"sliceIndex\":1,\"sliceTotal\":1,\"prevSummary\":null,\"nextSummary\":null}",
45+
"output": "{\"schemaVersion\":2,\"sections\":[{\"heading\":\"第一章 引言\",\"blocks\":[{\"type\":\"paragraph\",\"content\":\"卷积神经网络的核心概念:卷积与池化。\",\"anchor_ref\":\"第一章 引言\"},{\"type\":\"term\",\"content\":\"卷积:对输入进行特征提取的运算。\",\"anchor_ref\":null},{\"type\":\"term\",\"content\":\"池化:对特征图进行下采样。\",\"anchor_ref\":null},{\"type\":\"highlight\",\"content\":\"重要:本节为核心概念。\",\"anchor_ref\":null},{\"type\":\"image\",\"content\":\"session-images/5/full/30000.webp\",\"anchor_ref\":null}]}]}"
4646
}
4747
],
48-
"output_format": "只输出 JSON,不要任何其他文字、解释或代码块标记。格式:{\"sections\":[{\"heading\":\"<章节标题,沿用输入章节或按内容归纳>\",\"blocks\":[{\"type\":\"paragraph|list|term|highlight|quote\",\"content\":\"<内容>\",\"anchor_ref\":\"<可空:引用的章节标题>\"}]}]}。要求:sections 非空;每节至少 1 个块;content 非空且不超过 4000 字;总块数不超过 200;type 只能取五个枚举值之一。"
48+
"output_format": "只输出 JSON,不要任何其他文字、解释或代码块标记。格式:{\"schemaVersion\":2,\"sections\":[{\"heading\":\"<章节标题,沿用输入章节或按内容归纳>\",\"blocks\":[{\"type\":\"paragraph|list|term|highlight|quote|image\",\"content\":\"<内容>\",\"anchor_ref\":\"<可空:引用的章节标题>\"}]}]}。要求:schemaVersion 必须为 2;sections 非空;每节至少 1 个块;content 非空且不超过 4000 字;总块数不超过 200;type 只能取六个枚举值之一;image 块 content 必须以 session-images/ 开头(配图原样保留,禁止改写路径);每节 image 块不超过 5 个。"
4949
}

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

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,23 +118,42 @@ impl AiMockAdapter {
118118
TextFilterResponse { decisions }
119119
}
120120

121-
/// 精修 mock(REQ-141):规则化整理——按输入章节组织 + 高亮标注;
121+
/// 精修 mock(REQ-141,F3 v2):规则化整理——按输入章节组织 + 高亮标注;
122122
/// 响应通过 AiRefineResponse::validate(持续验证精修协议校验链)。
123123
///
124124
/// @ai-context: 精修=整理不创作语义在 mock 中体现:只重组不新增事实
125-
/// (content 原样作为 paragraph,标 mock 高亮);M5 契约
126-
/// 测试/mock 全链路消费本方法。
125+
/// (content 原样作为 paragraph,标 mock 高亮);F3 v2:
126+
/// 输入配图行(`- ![画面 N](session-images/..)`)转 image 块
127+
/// 原样保留(验证 image 块校验/渲染链路);schema_version=2。
127128
pub fn refine(&self, request: &AiRefineRequest) -> AiRefineResponse {
128129
let headings: Vec<String> = if request.chapters.is_empty() {
129130
vec!["笔记".to_string()]
130131
} else {
131132
request.chapters.clone()
132133
};
134+
// 输入中的配图行 → image 块(原样路径保留——F3 v2 丢图回归)
135+
let images: Vec<AiRefineBlock> = request
136+
.content
137+
.lines()
138+
.filter(|l| l.trim_start().starts_with("- ![") && l.contains("session-images/"))
139+
.filter_map(|l| {
140+
// 提取 `](path)` 中的路径(配图行 `- ![alt](session-images/..)`)
141+
let path = l.split("](").nth(1)?.trim_end_matches(')').trim().to_string();
142+
if path.starts_with("session-images/") {
143+
Some(AiRefineBlock {
144+
block_type: AiRefineBlockType::Image,
145+
content: path,
146+
anchor_ref: None,
147+
})
148+
} else {
149+
None
150+
}
151+
})
152+
.collect();
133153
let sections: Vec<AiRefineSection> = headings
134154
.iter()
135-
.map(|heading| AiRefineSection {
136-
heading: heading.clone(),
137-
blocks: vec![
155+
.map(|heading| {
156+
let mut blocks = vec![
138157
AiRefineBlock {
139158
block_type: AiRefineBlockType::Paragraph,
140159
content: request.content.clone(),
@@ -145,10 +164,19 @@ impl AiMockAdapter {
145164
content: "(mock 精修)已整理,未增补课程外内容".to_string(),
146165
anchor_ref: None,
147166
},
148-
],
167+
];
168+
// F3 v2:配图块追加(验证 image 块校验 + to_markdown 渲染)
169+
blocks.extend(images.clone());
170+
AiRefineSection {
171+
heading: heading.clone(),
172+
blocks,
173+
}
149174
})
150175
.collect();
151-
AiRefineResponse { sections }
176+
AiRefineResponse {
177+
schema_version: crate::ai_refine_protocol::SCHEMA_VERSION_V2,
178+
sections,
179+
}
152180
}
153181

154182
/// 知识补充 mock(REQ-142):按勾选子项规则化产出合法块数组;

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,14 @@ impl NoteRefinePrompt {
7070
let mut profile_style = std::collections::HashMap::new();
7171
profile_style.insert("lecture".to_string(), "lecture".to_string());
7272
Self {
73-
version: 1,
74-
core_instruction: "精修=整理不创作:去噪 + 结构化,不增补课程外事实。".to_string(),
73+
version: 2,
74+
core_instruction: "精修=整理不创作:去噪 + 结构化,不增补课程外事实。"
75+
.to_string(),
7576
styles,
7677
profile_style,
7778
fallback_style: "lecture".to_string(),
7879
few_shot: Vec::new(),
79-
output_format: "只输出 JSON:{\"sections\":[{\"heading\":\"..\",\"blocks\":[{\"type\":\"paragraph|list|term|highlight|quote\",\"content\":\"..\",\"anchor_ref\":null}]}]}"
80+
output_format: "只输出 JSON:{\"schemaVersion\":2,\"sections\":[{\"heading\":\"..\",\"blocks\":[{\"type\":\"paragraph|list|term|highlight|quote|image\",\"content\":\"..\",\"anchor_ref\":null}]}]}"
8081
.to_string(),
8182
}
8283
}
@@ -122,15 +123,23 @@ impl AiNoteRefineAdapter {
122123
///
123124
/// @ai-context: 强校验在此层做(validate 失败 → Parse 错误 → 调用方
124125
/// 回退纯规则,非法响应不进入笔记管线——防御性编程铁律)。
126+
/// @ai-context: F3 v2:请求携带片间上下文(slice_index/total/prev/next
127+
/// summary)——提示词据此约束"只整理本片、沿用全局章节";
128+
/// 响应 schema_version=2(向后兼容:旧响应缺省 1 仍可解析)。
125129
pub fn refine(&self, request: &AiRefineRequest) -> Result<AiRefineResponse, AiClientError> {
126130
let system = self.prompt.build_system(&request.profile);
127131
let user = serde_json::to_string(request)
128132
.map_err(|e| AiClientError::Parse(format!("精修请求序列化失败: {}", e)))?;
129133
let v = self.client.chat_json(&system, &user)?;
130-
let resp: AiRefineResponse = serde_json::from_value(v)
134+
let mut resp: AiRefineResponse = serde_json::from_value(v)
131135
.map_err(|e| AiClientError::Parse(format!("精修响应结构非法: {}", e)))?;
132136
resp.validate()
133137
.map_err(|e| AiClientError::Parse(format!("精修响应校验失败(已丢弃回退): {}", e)))?;
138+
// F3 v2:v1 响应(缺省 1)升级标记为 v2(内容契约已校验通过——
139+
// 版本字段仅记录,不改变解析语义)
140+
if resp.schema_version == 1 {
141+
resp.schema_version = crate::ai_refine_protocol::SCHEMA_VERSION_V2;
142+
}
134143
Ok(resp)
135144
}
136145
}

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

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! 会话→笔记 AI 精修协议(REQ-141,v0.8.0 M2)。
1+
//! 会话→笔记 AI 精修协议(REQ-141,v0.8.0 M2;F3 协议 v2,2026-08-21)。
22
//!
33
//! @ai-context: 精修=整理不创作(与知识补充 REQ-142 严格区分):去非知识
44
//! 内容(寒暄/废话/重复)+ 层级结构化 + 不增补课程外事实——
@@ -8,7 +8,13 @@
88
//! (引用全局锚点 id 集)。
99
//! @ai-context: 输入=note_filter 规则草稿(markdown + 档案 + 术语表 + 章节
1010
//! 边界);输出=结构化块数组(paragraph/list/term/highlight/
11-
//! quote),to_markdown 供预览与落库(渲染器统一处理)。
11+
//! quote/image),to_markdown 供预览与落库(渲染器统一处理)。
12+
//! @ai-context: F3 协议 v2(2026-08-21):① schema_version(缺省=1,v2=2——
13+
//! 向后兼容:旧任务结果/缓存无 version 字段仍按 v1 解析);
14+
//! ② image 块类型(content=本地配图引用 session-images/ 相对
15+
//! 路径——解决精修丢图,AI 显式保留配图行);③ 片间上下文字段
16+
//! (slice_index/slice_total/prev_summary/next_summary——长笔记
17+
//! 切片时模型知道自己是第几片,防章节标题重复/结构错乱)。
1218
1319
use serde::{Deserialize, Serialize};
1420

@@ -18,6 +24,13 @@ const HEADING_MAX_CHARS: usize = 200;
1824
const BLOCK_MAX_CHARS: usize = 4000;
1925
/// 响应总块数上限(防刷屏——非法大响应直接丢弃)。
2026
const BLOCKS_TOTAL_MAX: usize = 200;
27+
/// 单节 image 块数上限(防配图刷屏——F3 v2)。
28+
const IMAGES_PER_SECTION_MAX: usize = 5;
29+
/// 片间摘要长度上限(prev/next summary 截断——防超长上下文撑爆提示词)。
30+
pub const SUMMARY_MAX_CHARS: usize = 200;
31+
32+
/// 协议版本(v2=1? 语义:缺省 1;本版响应显式写 2——见 AiRefineResponse)。
33+
pub const SCHEMA_VERSION_V2: u32 = 2;
2134

2235
/// 精修输入(规则草稿上下文——提示词参考,AI 不得增补课程外事实)。
2336
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -31,15 +44,42 @@ pub struct AiRefineRequest {
3144
pub glossary: Vec<String>,
3245
/// 章节边界标题(网课档案提供——AI 沿用层级,不自行发明章节)
3346
pub chapters: Vec<String>,
47+
/// F3 v2:本片序号(1 起)——模型据此知道自己是长笔记的第几片
48+
#[serde(default)]
49+
pub slice_index: usize,
50+
/// F3 v2:总片数(0=未知/单片——提示词按需说明)
51+
#[serde(default)]
52+
pub slice_total: usize,
53+
/// F3 v2:前片结尾摘要(衔接上下文——防片间断裂/重复开头)
54+
#[serde(default)]
55+
pub prev_summary: Option<String>,
56+
/// F3 v2:后片开头摘要(预告——防片尾截断感)
57+
#[serde(default)]
58+
pub next_summary: Option<String>,
3459
}
3560

3661
/// 精修响应(结构强校验:sections 非空、heading/blocks content 非空)。
3762
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3863
#[serde(rename_all = "camelCase")]
3964
pub struct AiRefineResponse {
65+
/// F3 v2:协议版本(缺省 1=旧响应;v2 显式=2——向后兼容解析)
66+
#[serde(default = "default_schema_version")]
67+
pub schema_version: u32,
4068
pub sections: Vec<AiRefineSection>,
4169
}
4270

71+
/// schema_version 缺省值(v1 响应无字段 → 1)。
72+
fn default_schema_version() -> u32 {
73+
1
74+
}
75+
76+
impl Default for AiRefineResponse {
77+
/// 旧构造路径默认 v1(兼容测试/旧调用方;适配器显式升级 v2)。
78+
fn default() -> Self {
79+
Self { schema_version: 1, sections: Vec::new() }
80+
}
81+
}
82+
4383
/// 单节(标题 + 块序列)。
4484
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4585
#[serde(rename_all = "camelCase")]
@@ -73,6 +113,9 @@ pub enum AiRefineBlockType {
73113
Highlight,
74114
/// 引用(原文摘录——精修=整理,引用不篡改)
75115
Quote,
116+
/// F3 v2:配图(content=本地配图引用 session-images/{sid}/{rel}——
117+
/// 精修保留规则版画面配图;校验路径前缀防注入)
118+
Image,
76119
}
77120

78121
impl AiRefineResponse {
@@ -81,6 +124,10 @@ impl AiRefineResponse {
81124
/// @ai-context: 校验失败 → 丢弃 AI 结果回退纯规则(防御性编程铁律——
82125
/// 非法响应不得进入笔记管线);anchor_ref 存在性由
83126
/// command 层带全局锚点集做(本层无输入上下文)。
127+
/// @ai-context: F3 v2:image 块 content 必须匹配 session-images/ 前缀
128+
/// (本地路径引用——防注入任意路径/URL);每节 image 块数
129+
/// ≤5(防配图刷屏);schema_version 不校验数值(1/2 均合法,
130+
/// 版本兼容由调用方处理)。
84131
pub fn validate(&self) -> Result<(), String> {
85132
if self.sections.is_empty() {
86133
return Err("精修响应缺少章节".to_string());
@@ -94,18 +141,29 @@ impl AiRefineResponse {
94141
if sec.blocks.is_empty() {
95142
return Err(format!("章节「{}」无内容块", heading));
96143
}
144+
let mut images_in_section = 0usize;
97145
for b in &sec.blocks {
98146
let content = b.content.trim();
99147
if content.is_empty() || content.chars().count() > BLOCK_MAX_CHARS {
100148
return Err("内容块为空或超长".to_string());
101149
}
150+
if b.block_type == AiRefineBlockType::Image {
151+
// F3 v2:配图必须引用本地会话图库(相对路径前缀校验)
152+
if !content.starts_with("session-images/") {
153+
return Err("image 块必须引用本地会话图库(session-images/ 前缀)".to_string());
154+
}
155+
images_in_section += 1;
156+
}
102157
if let Some(anchor) = &b.anchor_ref {
103158
if anchor.trim().is_empty() || anchor.chars().count() > 100 {
104159
return Err("锚点引用为空或超长".to_string());
105160
}
106161
}
107162
total += 1;
108163
}
164+
if images_in_section > IMAGES_PER_SECTION_MAX {
165+
return Err(format!("章节「{}」配图块超上限({} > {})", heading, images_in_section, IMAGES_PER_SECTION_MAX));
166+
}
109167
}
110168
if total > BLOCKS_TOTAL_MAX {
111169
return Err(format!("精修响应块数超上限({} > {})", total, BLOCKS_TOTAL_MAX));
@@ -116,7 +174,9 @@ impl AiRefineResponse {
116174
/// 渲染为 Markdown(纯函数:标题层级 + 块渲染——预览/落库统一出口)。
117175
///
118176
/// @ai-context: 渲染规则:paragraph=正文段;list=每行 "- " 前缀;term=
119-
/// "- **内容**";highlight=加粗;quote=引用块"> "。
177+
/// "- **内容**";highlight=加粗;quote=引用块"> ";
178+
/// image=原样配图行 `- ![画面](path)`(F3 v2——与规则版
179+
/// 画面要点行同形态,前端渲染器统一处理)。
120180
pub fn to_markdown(&self) -> String {
121181
let mut out = String::new();
122182
for sec in &self.sections {
@@ -145,6 +205,11 @@ impl AiRefineResponse {
145205
}
146206
out.push('\n');
147207
}
208+
AiRefineBlockType::Image => {
209+
// F3 v2:配图行(content=session-images/{sid}/{rel}——
210+
// 原样输出;与规则版画面要点行同形态供前端渲染)
211+
out.push_str(&format!("- ![画面]({})\n", b.content.trim()));
212+
}
148213
}
149214
}
150215
}

0 commit comments

Comments
 (0)