@zhouchangui/math-ati 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -138,6 +138,29 @@ function tagsHtml(items) {
138
138
  .join('');
139
139
  }
140
140
 
141
+ function uniqueTags(...groups) {
142
+ const seen = new Set();
143
+ const output = [];
144
+ for (const group of groups) {
145
+ for (const item of Array.isArray(group) ? group : []) {
146
+ const text = tagText(item);
147
+ if (!text || seen.has(text)) continue;
148
+ seen.add(text);
149
+ output.push(text);
150
+ }
151
+ }
152
+ return output;
153
+ }
154
+
155
+ function shouldShowSolutionProcess(question) {
156
+ const steps = Array.isArray(question.solutionSteps) ? question.solutionSteps.filter(Boolean) : [];
157
+ if (!steps.length) return false;
158
+ if (question.svg || question.questionKind === 'short_answer') return true;
159
+ if (question.difficulty && question.difficulty !== 'basic') return true;
160
+ const stemLength = String(question.stem || '').length;
161
+ return stemLength > 260;
162
+ }
163
+
141
164
  function practiceHtmlShell({ title, body, print = true, sheetClass = '' }) {
142
165
  return `<!doctype html>
143
166
  <html lang="zh-CN">
@@ -147,30 +170,81 @@ function practiceHtmlShell({ title, body, print = true, sheetClass = '' }) {
147
170
  <title>${escapeHtml(title)}</title>
148
171
  <script>
149
172
  (function () {
150
- var katexBase = window.location.protocol === 'file:'
151
- ? '../../../../node_modules/katex/dist'
152
- : '/vendor/katex';
153
- document.write('<link rel="stylesheet" href="' + katexBase + '/katex.min.css" />');
154
- document.write('<script defer src="' + katexBase + '/katex.min.js"><\\/script>');
155
- document.write('<script defer src="' + katexBase + '/contrib/auto-render.min.js"><\\/script>');
173
+ function loadScript(src) {
174
+ return new Promise(function (resolve, reject) {
175
+ var script = document.createElement('script');
176
+ script.src = src;
177
+ script.onload = resolve;
178
+ script.onerror = reject;
179
+ document.head.appendChild(script);
180
+ });
181
+ }
182
+ function loadStyle(href) {
183
+ var link = document.createElement('link');
184
+ link.rel = 'stylesheet';
185
+ link.href = href;
186
+ document.head.appendChild(link);
187
+ }
188
+ function candidateBases() {
189
+ if (window.location.protocol !== 'file:') return ['/vendor/katex'];
190
+ return [
191
+ '../../../../node_modules/katex/dist',
192
+ '../../../node_modules/katex/dist',
193
+ '../../node_modules/katex/dist',
194
+ '../node_modules/katex/dist',
195
+ './node_modules/katex/dist'
196
+ ];
197
+ }
198
+ window.__mathReady = candidateBases().reduce(function (chain, base) {
199
+ return chain.catch(function () {
200
+ loadStyle(base + '/katex.min.css');
201
+ return loadScript(base + '/katex.min.js')
202
+ .then(function () { return loadScript(base + '/contrib/auto-render.min.js'); });
203
+ });
204
+ }, Promise.reject());
156
205
  })();
157
206
  </script>
158
207
  <script>
159
208
  document.addEventListener('DOMContentLoaded', function () {
160
- if (!window.renderMathInElement) return;
161
- window.renderMathInElement(document.body, {
162
- ignoredClasses: ['no-math-render'],
163
- delimiters: [
164
- { left: '$$', right: '$$', display: true },
165
- { left: '$', right: '$', display: false },
166
- { left: '\\\\(', right: '\\\\)', display: false },
167
- { left: '\\\\[', right: '\\\\]', display: true },
168
- { left: '\\\\begin{equation}', right: '\\\\end{equation}', display: true },
169
- { left: '\\\\begin{align}', right: '\\\\end{align}', display: true },
170
- { left: '\\\\begin{gather}', right: '\\\\end{gather}', display: true }
171
- ],
172
- throwOnError: false
173
- });
209
+ function fallbackPlainMath() {
210
+ if (window.__mathRendered || document.querySelector('.katex')) return;
211
+ var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
212
+ var nodes = [];
213
+ while (walker.nextNode()) nodes.push(walker.currentNode);
214
+ nodes.forEach(function (node) {
215
+ var parent = node.parentElement;
216
+ if (!parent || parent.closest('script, style, svg, .katex, .no-math-render')) return;
217
+ node.nodeValue = node.nodeValue
218
+ .replace(/\\$\\$([^$]+)\\$\\$/g, '$1')
219
+ .replace(/\\$([^$\\n]+)\\$/g, '$1')
220
+ .replace(/\\\\\\((.*?)\\\\\\)/g, '$1')
221
+ .replace(/\\\\\\[(.*?)\\\\\\]/g, '$1');
222
+ });
223
+ }
224
+ function renderMath() {
225
+ if (!window.renderMathInElement) return;
226
+ try {
227
+ window.renderMathInElement(document.body, {
228
+ ignoredClasses: ['no-math-render'],
229
+ delimiters: [
230
+ { left: '$$', right: '$$', display: true },
231
+ { left: '$', right: '$', display: false },
232
+ { left: '\\\\(', right: '\\\\)', display: false },
233
+ { left: '\\\\[', right: '\\\\]', display: true },
234
+ { left: '\\\\begin{equation}', right: '\\\\end{equation}', display: true },
235
+ { left: '\\\\begin{align}', right: '\\\\end{align}', display: true },
236
+ { left: '\\\\begin{gather}', right: '\\\\end{gather}', display: true }
237
+ ],
238
+ throwOnError: false
239
+ });
240
+ window.__mathRendered = true;
241
+ } catch (error) {
242
+ fallbackPlainMath();
243
+ }
244
+ }
245
+ if (window.__mathReady) window.__mathReady.then(renderMath).catch(renderMath);
246
+ else renderMath();
247
+ window.setTimeout(fallbackPlainMath, 1800);
174
248
  });
175
249
  </script>
176
250
  <style>
@@ -263,17 +337,19 @@ export function practiceAnswersToHtml(practice) {
263
337
  ' <header class="sheet-header">',
264
338
  ' <div>',
265
339
  ` <h1>${escapeHtml(title)} 讲解版</h1>`,
266
- ` <p class="meta">${(practice.questions || []).length} 题 · 含标准答案、解题步骤和知识点</p>`,
340
+ ` <p class="meta">${(practice.questions || []).length} 题 · 含标准答案和考察内容</p>`,
267
341
  ' </div>',
268
342
  ' </header>'
269
343
  ];
270
344
  body.push(' <div class="answer-questions">');
271
345
  for (const [index, question] of (practice.questions || []).entries()) {
272
- const knowledgeTags = tagsHtml(question.knowledgePoints?.length ? question.knowledgePoints : question.knowledgePointIds);
273
- const errorTags = tagsHtml(question.expectedErrorTypes);
274
- const steps = listItemsHtml(question.solutionSteps);
275
- const rubric = rubricHtml(question.rubric);
276
- const hasExplanation = question.answer || steps || knowledgeTags || rubric;
346
+ const focusTags = tagsHtml(uniqueTags(
347
+ question.knowledgePoints?.length ? question.knowledgePoints : question.knowledgePointIds,
348
+ question.expectedErrorTypes
349
+ ));
350
+ const showProcess = shouldShowSolutionProcess(question);
351
+ const steps = showProcess ? listItemsHtml(question.solutionSteps) : '';
352
+ const hasExplanation = question.answer || steps || focusTags;
277
353
  body.push(
278
354
  ` <article class="question" data-question-id="${escapeHtml(question.id)}">`,
279
355
  ' <div class="question-row">',
@@ -287,16 +363,10 @@ export function practiceAnswersToHtml(practice) {
287
363
  ? ` <div class="answer-value">${contentHtml(question.answer)}</div>`
288
364
  : ' <p class="explain-missing">这份旧试卷没有生成阶段答案;新生成试卷会包含标准答案和元数据。</p>',
289
365
  steps
290
- ? ` <h3>解题过程</h3><ol class="steps">${steps}</ol>`
291
- : '',
292
- knowledgeTags
293
- ? ` <h3>覆盖知识点</h3><div class="tag-list no-math-render">${knowledgeTags}</div>`
294
- : '',
295
- errorTags
296
- ? ` <h3>易错点</h3><div class="tag-list no-math-render">${errorTags}</div>`
366
+ ? ` <h3>${question.questionKind === 'short_answer' || question.svg ? '解题要点' : '说明'}</h3><ol class="steps">${steps}</ol>`
297
367
  : '',
298
- rubric
299
- ? ` <h3>批改要点</h3><ul class="rubric-list">${rubric}</ul>`
368
+ focusTags
369
+ ? ` <h3>考察内容</h3><div class="tag-list no-math-render">${focusTags}</div>`
300
370
  : '',
301
371
  ' </section>',
302
372
  ' </div>',
@@ -226,9 +226,15 @@ export async function ensurePracticeHtml(practice) {
226
226
  export function cleanPracticeOptions(options) {
227
227
  const persisted = { ...options };
228
228
  delete persisted.knowledgeDoc;
229
+ delete persisted.generationCacheDir;
229
230
  return persisted;
230
231
  }
231
232
 
233
+ function isGeometryLikeChapter(chapter) {
234
+ const text = `${chapter?.title || ''} ${chapter?.fullTitle || ''} ${chapter?.track || ''}`;
235
+ return /几何|图形|线|角|三角形|四边形|圆|相似|投影|视图/.test(text);
236
+ }
237
+
232
238
  export async function createPractice({
233
239
  chapterId,
234
240
  type = 'knowledge_coverage',
@@ -239,6 +245,11 @@ export async function createPractice({
239
245
  knowledgePointIds = [],
240
246
  abilityIds = [],
241
247
  questionKind = 'auto',
248
+ generationCacheDir = '',
249
+ fastReview = false,
250
+ verifySvgFigures = false,
251
+ requireSvg = false,
252
+ minSvgQuestions = null,
242
253
  onProgress = () => {}
243
254
  }) {
244
255
  await ensureDataDirs();
@@ -264,10 +275,19 @@ export async function createPractice({
264
275
  const hasRequestedQuestionCount = Number.isFinite(requestedQuestionCount) && requestedQuestionCount > 0;
265
276
  const maxQuestionCount = scopedKnowledgePointIds.length === 1
266
277
  ? Math.max(1, Math.min(3, hasRequestedQuestionCount ? requestedQuestionCount : 1))
267
- : type === 'mistake_repair'
278
+ : hasRequestedQuestionCount
279
+ ? Math.max(1, Math.min(30, requestedQuestionCount))
280
+ : type === 'mistake_repair'
268
281
  ? Math.max(3, Math.min(30, hasRequestedQuestionCount ? requestedQuestionCount : 8))
269
- : Math.max(6, Math.min(24, hasRequestedQuestionCount ? requestedQuestionCount : 18));
282
+ : Math.max(6, Math.min(24, 18));
270
283
  const adaptiveQuestionCount = !hasRequestedQuestionCount && (type === 'knowledge_coverage' || type === 'mastery_check');
284
+ const autoGeometryVisualCheck = ['knowledge_coverage', 'mistake_repair'].includes(type) && isGeometryLikeChapter(chapter);
285
+ const requestedMinSvgQuestions = Number(minSvgQuestions);
286
+ const effectiveMinSvgQuestions = Number.isFinite(requestedMinSvgQuestions) && requestedMinSvgQuestions > 0
287
+ ? Math.floor(requestedMinSvgQuestions)
288
+ : autoGeometryVisualCheck
289
+ ? Math.max(1, Math.min(4, Math.ceil(maxQuestionCount / 4)))
290
+ : 0;
271
291
  const options = {
272
292
  type,
273
293
  questionCount: adaptiveQuestionCount ? null : maxQuestionCount,
@@ -279,6 +299,11 @@ export async function createPractice({
279
299
  knowledgePointIds: scopedKnowledgePointIds,
280
300
  abilityIds: scopedAbilityIds,
281
301
  questionKind,
302
+ generationCacheDir,
303
+ fastReview: Boolean(fastReview),
304
+ verifySvgFigures: Boolean(verifySvgFigures) || autoGeometryVisualCheck,
305
+ requireSvg: Boolean(requireSvg),
306
+ minSvgQuestions: effectiveMinSvgQuestions,
282
307
  knowledgeDoc: knowledgeBundle.knowledge,
283
308
  mastery: knowledgeBundle.mastery
284
309
  };
@@ -166,7 +166,7 @@ export async function confirmSubmission({ submissionId, grading = null, parentSu
166
166
  const report = [
167
167
  `# ${practice.chapterTitle} 学习报告`,
168
168
  '',
169
- `- 学生:周子烊`,
169
+ `- 学生:学生`,
170
170
  `- 练习:${practice.title}`,
171
171
  `- 正确率:${accuracy}%`,
172
172
  `- 错因:${errorTypes.join('、') || '暂无'}`,
@@ -0,0 +1,315 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { callChatAgent, callVisionAgent } from './agentClient.js';
7
+ import { promptPayload, readPrompt } from './promptStore.js';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ function figureError(reason, detail = '') {
12
+ const error = new Error(`svg_figure_verification_failed:${reason}`);
13
+ error.status = 502;
14
+ error.reason = reason;
15
+ error.detail = detail;
16
+ return error;
17
+ }
18
+
19
+ function hasSvg(question) {
20
+ return String(question?.svg || '').trim().includes('<svg');
21
+ }
22
+
23
+ function questionFigureFingerprint(question) {
24
+ return createHash('sha256')
25
+ .update(JSON.stringify({
26
+ id: question?.id || '',
27
+ stem: question?.stem || '',
28
+ questionKind: question?.questionKind || '',
29
+ knowledgePointIds: question?.knowledgePointIds || [],
30
+ knowledgePoints: question?.knowledgePoints || [],
31
+ expectedErrorTypes: question?.expectedErrorTypes || []
32
+ }))
33
+ .digest('hex');
34
+ }
35
+
36
+ function safeCachePart(value) {
37
+ return String(value || 'question').replace(/[^a-zA-Z0-9_-]+/g, '-').slice(0, 80) || 'question';
38
+ }
39
+
40
+ async function readQuestionCache(workDir, questionId, kind) {
41
+ if (!workDir || !questionId) return null;
42
+ try {
43
+ return JSON.parse(await readFile(path.join(workDir, `${safeCachePart(questionId)}-${kind}.json`), 'utf8'));
44
+ } catch (error) {
45
+ if (error?.code === 'ENOENT') return null;
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ async function writeQuestionCache(workDir, questionId, kind, data) {
51
+ if (!workDir || !questionId) return;
52
+ await mkdir(workDir, { recursive: true });
53
+ await writeFile(path.join(workDir, `${safeCachePart(questionId)}-${kind}.json`), `${JSON.stringify(data, null, 2)}\n`, 'utf8');
54
+ }
55
+
56
+ function assertSafeSvg(question) {
57
+ const svg = String(question.svg || '').trim();
58
+ if (!svg) throw figureError('missing_svg', question.id);
59
+ if (!svg.includes('<svg')) throw figureError('invalid_svg_markup', question.id);
60
+ if (/<script\b|<foreignObject\b|on[a-z]+\s*=|javascript:/i.test(svg)) {
61
+ throw figureError('unsafe_svg_markup', question.id);
62
+ }
63
+ return svg;
64
+ }
65
+
66
+ async function renderSvgToPng({ question, workDir }) {
67
+ const svg = assertSafeSvg(question);
68
+ await mkdir(workDir, { recursive: true });
69
+ const baseName = `${question.id || 'question'}-${randomUUID().slice(0, 8)}`;
70
+ const svgPath = path.join(workDir, `${baseName}.svg`);
71
+ const pngPath = path.join(workDir, `${baseName}.png`);
72
+ await writeFile(svgPath, svg, 'utf8');
73
+ try {
74
+ await execFileAsync('rsvg-convert', ['-f', 'png', '-w', '900', '-o', pngPath, svgPath], {
75
+ timeout: 15000
76
+ });
77
+ } catch (error) {
78
+ throw figureError('render_failed', `${question.id}:${error.message}`);
79
+ }
80
+ return { svgPath, pngPath };
81
+ }
82
+
83
+ async function reviewRenderedFigure({ question, imagePath, context }) {
84
+ const system = `${await readPrompt('svg-figure-review.system.md')}\n\n${await readPrompt('geometry-practice-experience.md')}`;
85
+ const result = await callVisionAgent({
86
+ system,
87
+ text: promptPayload({
88
+ task: '审查题目配图是否准确表达题干条件,是否可打印、无歧义、无明显破损。',
89
+ context: {
90
+ chapter: context.chapter,
91
+ targetKnowledgePoints: context.targetKnowledgePoints,
92
+ question: {
93
+ id: question.id,
94
+ stem: question.stem,
95
+ questionKind: question.questionKind,
96
+ knowledgePointIds: question.knowledgePointIds,
97
+ knowledgePoints: question.knowledgePoints,
98
+ expectedErrorTypes: question.expectedErrorTypes
99
+ }
100
+ },
101
+ requirements: [
102
+ '必须以渲染后的图片为准,不只看 SVG 源码。',
103
+ '检查图中点名、线段名、射线方向、角标、垂足、中点、平行/垂直/长度标记、展开图格子连接关系是否与题干一致。',
104
+ '检查是否存在标签遮挡、线条断裂、关键对象缺失、图形比例导致歧义、题干引用了图中不存在的对象。',
105
+ '如果题目没有图就无法唯一作答,而图又没有给足条件,必须 passed=false。',
106
+ '只有渲染图片能让学生明确读懂题意,且不会依赖猜测,才能 passed=true。'
107
+ ],
108
+ schema: {
109
+ passed: false,
110
+ summary: 'string',
111
+ confidence: 'high|medium|low',
112
+ findings: [{
113
+ level: 'blocker|warning',
114
+ type: 'string',
115
+ message: 'string',
116
+ fixInstruction: 'string'
117
+ }]
118
+ }
119
+ }),
120
+ imagePaths: [imagePath],
121
+ temperature: 0.1,
122
+ timeoutMs: 60000,
123
+ retries: 1
124
+ });
125
+ if (!result.ok || !Array.isArray(result.data?.findings)) {
126
+ const attempts = result.attempts ? ` attempts=${result.attempts}` : '';
127
+ throw figureError(`review_${result.reason || 'invalid_agent_response'}`, `${result.detail || ''}${attempts}`);
128
+ }
129
+ return result.data;
130
+ }
131
+
132
+ async function repairQuestionFigure({ question, review, context, missingSvg = false }) {
133
+ const system = `${await readPrompt('svg-figure-revise.system.md')}\n\n${await readPrompt('geometry-practice-experience.md')}`;
134
+ const result = await callChatAgent({
135
+ system,
136
+ user: promptPayload({
137
+ task: missingSvg
138
+ ? '为视觉依赖几何题补充准确 SVG 配图。'
139
+ : '根据渲染图片审查意见修复题目 SVG 配图,必要时微调题干使图文一致。',
140
+ context: {
141
+ chapter: context.chapter,
142
+ targetKnowledgePoints: context.targetKnowledgePoints,
143
+ question,
144
+ renderedReview: review
145
+ },
146
+ requirements: [
147
+ '输出必须是一个严格 JSON 对象;第一个非空字符必须是 {,最后一个非空字符必须是 }。',
148
+ '禁止输出“我先”“下面是”“已修复”等任何 JSON 外文字;禁止 Markdown 代码块。',
149
+ '保持题目 id 不变,不新增题目,不删除题目。',
150
+ '优先修 SVG,让图中对象、标签、方向、角标、长度/相等标记、展开图连接关系与题干完全一致。',
151
+ '只有在题干确实与正确图形表达冲突时,才允许微调 stem;不要改变题目考查目标。',
152
+ 'SVG 必须是完整内联 <svg> 字符串,包含 viewBox,适合黑白打印。',
153
+ '不要使用 script、foreignObject、外链图片、事件属性或 CSS 动画。',
154
+ '标签必须清晰,不遮挡关键线段、角弧、格子或交点。',
155
+ '不要输出 answer、solutionSteps 或 rubric。'
156
+ ],
157
+ schema: {
158
+ id: question.id,
159
+ stem: 'plain text with LaTeX delimiters; only question',
160
+ questionKind: question.questionKind || 'short_answer',
161
+ difficulty: question.difficulty || 'medium',
162
+ knowledgePointIds: question.knowledgePointIds || [],
163
+ knowledgePoints: question.knowledgePoints || [],
164
+ expectedErrorTypes: question.expectedErrorTypes || [],
165
+ svg: 'complete inline SVG string',
166
+ imagePrompt: '',
167
+ score: question.score || 6,
168
+ answerSpaceLines: question.answerSpaceLines || 4,
169
+ revisionSummary: 'string'
170
+ }
171
+ }),
172
+ temperature: 0,
173
+ timeoutMs: 90000,
174
+ retries: 1
175
+ });
176
+ if (!result.ok || !result.data?.svg) {
177
+ const attempts = result.attempts ? ` attempts=${result.attempts}` : '';
178
+ throw figureError(`repair_${result.reason || 'invalid_agent_response'}`, `${result.detail || ''}${attempts}`);
179
+ }
180
+ return {
181
+ ...question,
182
+ ...result.data,
183
+ id: question.id,
184
+ svgFigureRevisionSummary: result.data.revisionSummary || ''
185
+ };
186
+ }
187
+
188
+ export async function verifyAndRepairSvgFigures({
189
+ questions,
190
+ context,
191
+ workDir,
192
+ requireSvg = false,
193
+ requiredQuestionIds = [],
194
+ strictQuestionIds = requiredQuestionIds,
195
+ maxRounds = 2,
196
+ onProgress = () => {}
197
+ }) {
198
+ const output = [];
199
+ const requiredQuestionIdSet = new Set(requiredQuestionIds || []);
200
+ const strictQuestionIdSet = new Set(strictQuestionIds || []);
201
+ for (const originalQuestion of questions || []) {
202
+ let question = originalQuestion;
203
+ const sourceFingerprint = questionFigureFingerprint(question);
204
+ const mustHaveSvg = requireSvg || requiredQuestionIdSet.has(question.id);
205
+ const strictSvg = requireSvg || strictQuestionIdSet.has(question.id);
206
+ const verifiedQuestion = await readQuestionCache(workDir, question.id, 'verified');
207
+ if (verifiedQuestion?.svgReview?.passed && verifiedQuestion.svgReview.sourceFingerprint === sourceFingerprint) {
208
+ onProgress({
209
+ step: 'practice_generate.svg_cached',
210
+ message: `复用 ${question.id} 已通过的 SVG 渲染审查缓存。`
211
+ });
212
+ output.push(verifiedQuestion);
213
+ continue;
214
+ }
215
+ const latestQuestion = await readQuestionCache(workDir, question.id, 'latest');
216
+ if (latestQuestion?.svg && latestQuestion.svgFigureSourceFingerprint === sourceFingerprint) {
217
+ onProgress({
218
+ step: 'practice_generate.svg_cached',
219
+ message: `复用 ${question.id} 已补图的 SVG 缓存。`
220
+ });
221
+ question = { ...question, ...latestQuestion, id: question.id };
222
+ }
223
+ if (!hasSvg(question) && !mustHaveSvg) {
224
+ output.push(question);
225
+ continue;
226
+ }
227
+ for (let round = 1; round <= maxRounds; round += 1) {
228
+ if (!hasSvg(question)) {
229
+ onProgress({
230
+ step: 'practice_generate.svg_repair',
231
+ message: `${question.id} 缺少 SVG,正在补图(第 ${round}/${maxRounds} 轮)。`
232
+ });
233
+ question = await repairQuestionFigure({
234
+ question,
235
+ review: {
236
+ passed: false,
237
+ findings: [{
238
+ level: 'blocker',
239
+ type: 'missing_svg',
240
+ message: mustHaveSvg
241
+ ? '本题被要求补充图形表达,但没有 SVG。'
242
+ : '本题被要求必须使用图形作答,但没有 SVG。',
243
+ fixInstruction: '补充与题干完全一致的内联 SVG。'
244
+ }]
245
+ },
246
+ context,
247
+ missingSvg: true
248
+ });
249
+ question = {
250
+ ...question,
251
+ svgFigureSourceFingerprint: questionFigureFingerprint(question)
252
+ };
253
+ await writeQuestionCache(workDir, question.id, 'latest', question);
254
+ }
255
+ const render = await renderSvgToPng({ question, workDir });
256
+ onProgress({
257
+ step: 'practice_generate.svg_review',
258
+ message: `${question.id} 已渲染为图片,正在视觉审查(第 ${round}/${maxRounds} 轮)。`
259
+ });
260
+ const review = await reviewRenderedFigure({
261
+ question,
262
+ imagePath: render.pngPath,
263
+ context
264
+ });
265
+ const blockers = (review.findings || []).filter((finding) => finding.level === 'blocker');
266
+ if (review.passed && blockers.length === 0) {
267
+ output.push({
268
+ ...question,
269
+ svgFigureSourceFingerprint: questionFigureFingerprint(question),
270
+ svgReview: {
271
+ ...review,
272
+ sourceFingerprint: questionFigureFingerprint(question),
273
+ renderPngPath: render.pngPath
274
+ }
275
+ });
276
+ await writeQuestionCache(workDir, question.id, 'verified', output.at(-1));
277
+ break;
278
+ }
279
+ if (round === maxRounds) {
280
+ if (!strictSvg) {
281
+ onProgress({
282
+ step: 'practice_generate.svg_optional_skip',
283
+ message: `${question.id} 是可选 SVG 回归样本,渲染审查未通过,已回退为无图文字题:${blockers.map((finding) => finding.type).join('、') || review.summary || 'not_passed'}`
284
+ });
285
+ output.push({
286
+ ...originalQuestion,
287
+ svg: '',
288
+ svgReview: {
289
+ passed: false,
290
+ skipped: true,
291
+ summary: review.summary || '可选 SVG 样本未通过,已回退为文字题。',
292
+ findings: review.findings || []
293
+ }
294
+ });
295
+ break;
296
+ }
297
+ throw figureError(
298
+ 'review_blockers',
299
+ `${question.id}:${blockers.map((finding) => finding.type).join('|') || review.summary || 'not_passed'}`
300
+ );
301
+ }
302
+ onProgress({
303
+ step: 'practice_generate.svg_repair',
304
+ message: `${question.id} 视觉审查未通过,正在修图:${blockers.map((finding) => finding.type).join('、') || review.summary}`
305
+ });
306
+ question = await repairQuestionFigure({ question, review, context });
307
+ question = {
308
+ ...question,
309
+ svgFigureSourceFingerprint: questionFigureFingerprint(question)
310
+ };
311
+ await writeQuestionCache(workDir, question.id, 'latest', question);
312
+ }
313
+ }
314
+ return output;
315
+ }