|
| 1 | +//! 精修结构回归评测(F3-E golden,2026-08-21;REQ-147 扩展)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 内置小样本集(fixtures/refine_golden/samples.json——3 个典型 |
| 4 | +//! 档案:网课讲义式(含章节/术语/配图)、实操步骤式(含配图)、 |
| 5 | +//! 口播摘要式(无配图))→ mock 适配器全链路(不联网)→ |
| 6 | +//! 协议校验 + to_markdown → 断言结构不漂移: |
| 7 | +//! ① schema_version=2;② 章节沿用(不发明新章节); |
| 8 | +//! ③ 配图经 image 块保留(丢图回归护栏);④ 块类型合法集合。 |
| 9 | +//! @ai-context: 提示词 golden 冒烟:固定样本断言输出结构——提示词改动 |
| 10 | +//! (note_refine.json)破坏契约时立即红(防漂移护栏)。 |
| 11 | +
|
| 12 | +use serde::Deserialize; |
| 13 | + |
| 14 | +use crate::ai_mock::AiMockAdapter; |
| 15 | +use crate::ai_refine_protocol::{ |
| 16 | + AiRefineBlockType, AiRefineRequest, AiRefineResponse, SCHEMA_VERSION_V2, |
| 17 | +}; |
| 18 | + |
| 19 | +/// 样本集结构(fixtures/refine_golden/samples.json)。 |
| 20 | +#[derive(Debug, Deserialize)] |
| 21 | +struct GoldenSet { |
| 22 | + samples: Vec<GoldenSample>, |
| 23 | +} |
| 24 | + |
| 25 | +#[derive(Debug, Deserialize)] |
| 26 | +struct GoldenSample { |
| 27 | + name: String, |
| 28 | + profile: String, |
| 29 | + glossary: Vec<String>, |
| 30 | + chapters: Vec<String>, |
| 31 | + content: String, |
| 32 | + expect: GoldenExpect, |
| 33 | +} |
| 34 | + |
| 35 | +#[derive(Debug, Deserialize)] |
| 36 | +#[serde(rename_all = "camelCase")] |
| 37 | +struct GoldenExpect { |
| 38 | + schema_version: u32, |
| 39 | + min_sections: usize, |
| 40 | + #[serde(default)] |
| 41 | + chapter_set: Vec<String>, |
| 42 | + must_have_types: Vec<String>, |
| 43 | + #[serde(default)] |
| 44 | + images_preserved: Vec<String>, |
| 45 | + no_new_chapters: bool, |
| 46 | +} |
| 47 | + |
| 48 | +/// 内置样本集(编译期捆绑——随仓库维护,防路径漂移)。 |
| 49 | +fn bundled_golden() -> GoldenSet { |
| 50 | + let raw = include_str!("../fixtures/refine_golden/samples.json"); |
| 51 | + serde_json::from_str(raw).expect("golden 样本集必须可解析(开发期错误)") |
| 52 | +} |
| 53 | + |
| 54 | +/// 跑通全链路:请求 → mock 精修 → 校验 → markdown(样本级回归)。 |
| 55 | +fn run_sample(s: &GoldenSample) -> AiRefineResponse { |
| 56 | + let req = AiRefineRequest { |
| 57 | + content: s.content.clone(), |
| 58 | + profile: s.profile.clone(), |
| 59 | + glossary: s.glossary.clone(), |
| 60 | + chapters: s.chapters.clone(), |
| 61 | + slice_index: 1, |
| 62 | + slice_total: 1, |
| 63 | + prev_summary: None, |
| 64 | + next_summary: None, |
| 65 | + }; |
| 66 | + let resp = AiMockAdapter.refine(&req); |
| 67 | + // 协议强校验必须先通过(非法响应不得进入笔记管线) |
| 68 | + resp.validate() |
| 69 | + .unwrap_or_else(|e| panic!("样本「{}」mock 响应校验失败: {}", s.name, e)); |
| 70 | + resp |
| 71 | +} |
| 72 | + |
| 73 | +#[test] |
| 74 | +fn golden_all_samples_validate_and_render_v2() { |
| 75 | + for s in bundled_golden().samples { |
| 76 | + let resp = run_sample(&s); |
| 77 | + // ① schema_version = 2(协议 v2 契约) |
| 78 | + assert_eq!(resp.schema_version, SCHEMA_VERSION_V2, "样本「{}」必须 v2", s.name); |
| 79 | + // ② sections 非空 + 数量下限 |
| 80 | + assert!(resp.sections.len() >= s.expect.min_sections, "样本「{}」章节不足", s.name); |
| 81 | + // ③ 渲染不 panic、非空 |
| 82 | + let md = resp.to_markdown(); |
| 83 | + assert!(!md.trim().is_empty(), "样本「{}」markdown 为空", s.name); |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +#[test] |
| 88 | +fn golden_chapters_follow_input_not_invented() { |
| 89 | + for s in bundled_golden().samples { |
| 90 | + if !s.expect.no_new_chapters { |
| 91 | + continue; |
| 92 | + } |
| 93 | + let resp = run_sample(&s); |
| 94 | + // 章节标题集合 ⊆ 输入章节(精修=整理不创作——不发明章节) |
| 95 | + for sec in &resp.sections { |
| 96 | + let in_input = s.expect.chapter_set.iter().any(|c| sec.heading.contains(c.as_str())) |
| 97 | + || s.chapters.iter().any(|c| sec.heading.contains(c.as_str())); |
| 98 | + assert!(in_input, "样本「{}」发明了章节: {}", s.name, sec.heading); |
| 99 | + } |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +#[test] |
| 104 | +fn golden_images_preserved_via_image_blocks() { |
| 105 | + // 丢图回归护栏:规则版配图行 → image 块原样保留(路径不丢失/不改写) |
| 106 | + for s in bundled_golden().samples { |
| 107 | + if s.expect.images_preserved.is_empty() { |
| 108 | + continue; |
| 109 | + } |
| 110 | + let resp = run_sample(&s); |
| 111 | + let md = resp.to_markdown(); |
| 112 | + for img in &s.expect.images_preserved { |
| 113 | + assert!( |
| 114 | + md.contains(img.as_str()), |
| 115 | + "样本「{}」配图 {} 丢失(丢图回归)", |
| 116 | + s.name, |
| 117 | + img |
| 118 | + ); |
| 119 | + } |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +#[test] |
| 124 | +fn golden_block_types_legal_and_required() { |
| 125 | + for s in bundled_golden().samples { |
| 126 | + let resp = run_sample(&s); |
| 127 | + let all: Vec<String> = resp |
| 128 | + .sections |
| 129 | + .iter() |
| 130 | + .flat_map(|sec| sec.blocks.iter()) |
| 131 | + .map(|b| match b.block_type { |
| 132 | + AiRefineBlockType::Paragraph => "paragraph".to_string(), |
| 133 | + AiRefineBlockType::List => "list".to_string(), |
| 134 | + AiRefineBlockType::Term => "term".to_string(), |
| 135 | + AiRefineBlockType::Highlight => "highlight".to_string(), |
| 136 | + AiRefineBlockType::Quote => "quote".to_string(), |
| 137 | + AiRefineBlockType::Image => "image".to_string(), |
| 138 | + }) |
| 139 | + .collect(); |
| 140 | + for need in &s.expect.must_have_types { |
| 141 | + assert!(all.contains(need), "样本「{}」缺必需块类型 {}", s.name, need); |
| 142 | + } |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +#[test] |
| 147 | +fn golden_prompt_template_parses_with_v2_contract() { |
| 148 | + // 提示词 golden 冒烟:模板必须解析且含 v2 契约(image 块 + schemaVersion) |
| 149 | + // ——note_refine.json 改动破坏契约时立即红(防漂移) |
| 150 | + let prompt = crate::ai_note_refine::NoteRefinePrompt::bundled(); |
| 151 | + assert!(prompt.version >= 2, "提示词模板必须升级到 v2"); |
| 152 | + let sys = prompt.build_system("lecture"); |
| 153 | + assert!(sys.contains("image"), "v2 提示词必须声明 image 块类型"); |
| 154 | + assert!(sys.contains("schemaVersion"), "v2 提示词必须声明 schemaVersion"); |
| 155 | + assert!(sys.contains("slice"), "v2 提示词必须声明片间上下文约束"); |
| 156 | +} |
0 commit comments