@@ -193,45 +193,70 @@ impl AiRefineResponse {
193193 /// image=原样配图行 `- `(F3 v2——与规则版
194194 /// 画面要点行同形态,前端渲染器统一处理)。
195195 pub fn to_markdown ( & self ) -> String {
196- let mut out = String :: new ( ) ;
197- for sec in & self . sections {
198- out. push_str ( & format ! ( "## {}\n \n " , sec. heading. trim( ) ) ) ;
199- for b in & sec. blocks {
200- match b. block_type {
201- AiRefineBlockType :: Paragraph => {
202- out. push_str ( b. content . trim ( ) ) ;
203- out. push_str ( "\n \n " ) ;
204- }
205- AiRefineBlockType :: List => {
206- for line in b. content . lines ( ) . map ( |l| l. trim ( ) ) . filter ( |l| !l. is_empty ( ) ) {
207- out. push_str ( & format ! ( "- {}\n " , line) ) ;
208- }
209- out. push ( '\n' ) ;
210- }
211- AiRefineBlockType :: Term => {
212- out. push_str ( & format ! ( "- **{}**\n " , b. content. trim( ) ) ) ;
213- }
214- AiRefineBlockType :: Highlight => {
215- out. push_str ( & format ! ( "**{}**\n \n " , b. content. trim( ) ) ) ;
216- }
217- AiRefineBlockType :: Quote => {
218- for line in b. content . lines ( ) . map ( |l| l. trim ( ) ) . filter ( |l| !l. is_empty ( ) ) {
219- out. push_str ( & format ! ( "> {}\n " , line) ) ;
220- }
221- out. push ( '\n' ) ;
196+ render_sections ( & self . sections ) . trim ( ) . to_string ( )
197+ }
198+ }
199+
200+ /// 章节序列 → Markdown(纯函数;to_markdown 与流式 Delta 渲染共用同一出口——
201+ /// REQ-290① 增量帧按前缀渲染,文本与终稿逐字节一致)。
202+ pub fn render_sections ( sections : & [ AiRefineSection ] ) -> String {
203+ let mut out = String :: new ( ) ;
204+ for sec in sections {
205+ out. push_str ( & format ! ( "## {}\n \n " , sec. heading. trim( ) ) ) ;
206+ for b in & sec. blocks {
207+ match b. block_type {
208+ AiRefineBlockType :: Paragraph => {
209+ out. push_str ( b. content . trim ( ) ) ;
210+ out. push_str ( "\n \n " ) ;
211+ }
212+ AiRefineBlockType :: List => {
213+ for line in b. content . lines ( ) . map ( |l| l. trim ( ) ) . filter ( |l| !l. is_empty ( ) ) {
214+ out. push_str ( & format ! ( "- {}\n " , line) ) ;
222215 }
223- AiRefineBlockType :: Image => {
224- // F3 v2:配图行(content=session-images/{sid}/{rel}——
225- // 原样输出;与规则版画面要点行同形态供前端渲染)
226- out. push_str ( & format ! ( "- \n " , b. content. trim( ) ) ) ;
216+ out. push ( '\n' ) ;
217+ }
218+ AiRefineBlockType :: Term => {
219+ out. push_str ( & format ! ( "- **{}**\n " , b. content. trim( ) ) ) ;
220+ }
221+ AiRefineBlockType :: Highlight => {
222+ out. push_str ( & format ! ( "**{}**\n \n " , b. content. trim( ) ) ) ;
223+ }
224+ AiRefineBlockType :: Quote => {
225+ for line in b. content . lines ( ) . map ( |l| l. trim ( ) ) . filter ( |l| !l. is_empty ( ) ) {
226+ out. push_str ( & format ! ( "> {}\n " , line) ) ;
227227 }
228+ out. push ( '\n' ) ;
229+ }
230+ AiRefineBlockType :: Image => {
231+ // F3 v2:配图行(content=session-images/{sid}/{rel}——
232+ // 原样输出;与规则版画面要点行同形态供前端渲染)
233+ out. push_str ( & format ! ( "- \n " , b. content. trim( ) ) ) ;
228234 }
229235 }
230236 }
231- out. trim ( ) . to_string ( )
232237 }
238+ out
233239}
234240
241+ /// 单行 NDJSON 节解析(REQ-290① 流式):每节=与终稿数组元素同构的 JSON 对象
242+ /// (heading + blocks);非对象行/解析失败 → None(由上层按整包回退解析)。
243+ pub fn parse_section_ndjson_line ( line : & str ) -> Option < AiRefineSection > {
244+ let t = line. trim ( ) ;
245+ if t. is_empty ( ) || t. starts_with ( '[' ) || t. starts_with ( ']' ) || t. starts_with ( ',' ) {
246+ return None ;
247+ }
248+ let v: serde_json:: Value = serde_json:: from_str ( t) . ok ( ) ?;
249+ let obj = v. as_object ( ) ?;
250+ if !obj. contains_key ( "heading" ) || !obj. contains_key ( "blocks" ) {
251+ return None ;
252+ }
253+ serde_json:: from_value :: < AiRefineSection > ( v) . ok ( )
254+ }
255+
256+ /// 流式模式输出要求(附加在 system 尾部——逐节 JSONL;无法遵守时整体 JSON
257+ /// 数组亦可——解析端自动兼容回退,见 adapter)。
258+ pub const NDJSON_SYSTEM_SUFFIX : & str = "【流式输出要求】请逐节输出整理结果:每一节单独输出一行 JSON(对象含 heading 与 blocks 字段,字段格式与前述格式要求完全一致),节与节之间只换行、不写数组括号/逗号/多余说明;若必须一次性输出,则输出完整 JSON 数组(系统可自动兼容解析)。" ;
259+
235260/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
236261#[ cfg( test) ]
237262#[ path = "ai_refine_protocol_tests.rs" ]
@@ -241,3 +266,30 @@ mod tests;
241266#[ cfg( test) ]
242267#[ path = "refine_golden_tests.rs" ]
243268mod golden_tests;
269+
270+ #[ cfg( test) ]
271+ mod ndjson_parse_tests {
272+ use super :: parse_section_ndjson_line;
273+
274+ #[ test]
275+ fn parses_single_section_object_line ( ) {
276+ let line = r#"{"heading":"第一节","blocks":[]}"# ;
277+ let sec = parse_section_ndjson_line ( line) . expect ( "应解析出节" ) ;
278+ assert_eq ! ( sec. heading, "第一节" ) ;
279+ assert ! ( sec. blocks. is_empty( ) ) ;
280+ }
281+
282+ #[ test]
283+ fn ignores_array_wrappers_and_empty_lines ( ) {
284+ assert ! ( parse_section_ndjson_line( "[" ) . is_none( ) ) ;
285+ assert ! ( parse_section_ndjson_line( "]" ) . is_none( ) ) ;
286+ assert ! ( parse_section_ndjson_line( "," ) . is_none( ) ) ;
287+ assert ! ( parse_section_ndjson_line( " " ) . is_none( ) ) ;
288+ }
289+
290+ #[ test]
291+ fn ignores_non_section_objects_and_garbage ( ) {
292+ assert ! ( parse_section_ndjson_line( r#"{"a":1}"# ) . is_none( ) ) ;
293+ assert ! ( parse_section_ndjson_line( "not json {" ) . is_none( ) ) ;
294+ }
295+ }
0 commit comments