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//! 内容(寒暄/废话/重复)+ 层级结构化 + 不增补课程外事实——
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
1319use serde:: { Deserialize , Serialize } ;
1420
@@ -18,6 +24,13 @@ const HEADING_MAX_CHARS: usize = 200;
1824const BLOCK_MAX_CHARS : usize = 4000 ;
1925/// 响应总块数上限(防刷屏——非法大响应直接丢弃)。
2026const 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" ) ]
3964pub 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
78121impl 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=原样配图行 `- `(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