Skip to content

Commit b75db82

Browse files
committed
feat(ai): golden 结构回归评测——内置样本集 mock 全链路 + 提示词防漂移 (F3-E)
1 parent 790f964 commit b75db82

4 files changed

Lines changed: 225 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"samples": [
3+
{
4+
"name": "lecture-basic",
5+
"profile": "lecture",
6+
"glossary": ["卷积", "池化"],
7+
"chapters": ["第一章 引言", "第二章 原理"],
8+
"content": "## 第一章 引言\n大家好今天我们讲卷积神经网络\n嗯 卷积就是特征提取 然后池化就是下采样\n这个非常重要\n## 第二章 原理\n卷积核滑动窗口 池化取最大值\n- ![画面 1](session-images/5/full/30000.webp)",
9+
"expect": {
10+
"schemaVersion": 2,
11+
"minSections": 1,
12+
"chapterSet": ["第一章 引言", "第二章 原理"],
13+
"mustHaveTypes": ["paragraph", "image"],
14+
"imagesPreserved": ["session-images/5/full/30000.webp"],
15+
"noNewChapters": true
16+
}
17+
},
18+
{
19+
"name": "hands-on-steps",
20+
"profile": "hands-on",
21+
"glossary": ["npm", "vite"],
22+
"chapters": [],
23+
"content": "第一步安装 npm install\n第二步启动 npm run dev\n注意路径不要有中文\n- ![画面 1](session-images/7/full/1000.webp)",
24+
"expect": {
25+
"schemaVersion": 2,
26+
"minSections": 1,
27+
"chapterSet": [],
28+
"mustHaveTypes": ["list", "image"],
29+
"imagesPreserved": ["session-images/7/full/1000.webp"],
30+
"noNewChapters": false
31+
}
32+
},
33+
{
34+
"name": "talking-head-summary",
35+
"profile": "talking-head",
36+
"glossary": ["熵", "信息量"],
37+
"chapters": [],
38+
"content": "大家好今天我们聊聊熵这个概念\n熵就是不确定性的度量\n信息量越大 熵越小 对吧\n所以我们要追求确定性",
39+
"expect": {
40+
"schemaVersion": 2,
41+
"minSections": 1,
42+
"chapterSet": [],
43+
"mustHaveTypes": ["term", "highlight"],
44+
"imagesPreserved": [],
45+
"noNewChapters": false
46+
}
47+
}
48+
]
49+
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,21 @@ impl AiMockAdapter {
165165
anchor_ref: None,
166166
},
167167
];
168+
// 档案 → 补充块类型(golden 回归覆盖档案映射:
169+
// hands-on=步骤 list、talking-head=术语 term、其余 highlight 已含)
170+
if request.profile == "hands-on" {
171+
blocks.push(AiRefineBlock {
172+
block_type: AiRefineBlockType::List,
173+
content: "第一步\n第二步\n第三步".to_string(),
174+
anchor_ref: None,
175+
});
176+
} else if request.profile == "talking-head" && !request.glossary.is_empty() {
177+
blocks.push(AiRefineBlock {
178+
block_type: AiRefineBlockType::Term,
179+
content: request.glossary.join(";"),
180+
anchor_ref: None,
181+
});
182+
}
168183
// F3 v2:配图块追加(验证 image 块校验 + to_markdown 渲染)
169184
blocks.extend(images.clone());
170185
AiRefineSection {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,8 @@ impl AiRefineResponse {
221221
#[cfg(test)]
222222
#[path = "ai_refine_protocol_tests.rs"]
223223
mod tests;
224+
225+
/// F3-E golden 结构回归(2026-08-21;REQ-147 扩展)——内置样本集 mock 全链路。
226+
#[cfg(test)]
227+
#[path = "refine_golden_tests.rs"]
228+
mod golden_tests;
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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

Comments
 (0)