@zhouchangui/math-ati 0.1.1 → 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.
@@ -1,9 +1,11 @@
1
1
  import { callChatAgent } from './agentClient.js';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { chapterDataPaths, isActionableMistake, readChapterMistakes, readJson } from './fileStore.js';
4
5
  import { flattenKnowledgePoints } from './knowledgeBase.js';
5
6
  import { promptPayload, readPrompt } from './promptStore.js';
6
7
  import { ABILITY_CATALOG } from './abilityService.js';
8
+ import { verifyAndRepairSvgFigures } from './svgFigureVerifier.js';
7
9
 
8
10
  function generationError(reason, detail = '') {
9
11
  const error = new Error(`practice_generation_failed:${reason}`);
@@ -123,6 +125,86 @@ function chunkArray(items, size) {
123
125
  return chunks;
124
126
  }
125
127
 
128
+ async function readStageCache(cacheDir, fileName) {
129
+ if (!cacheDir) return null;
130
+ try {
131
+ return JSON.parse(await readFile(path.join(cacheDir, fileName), 'utf8'));
132
+ } catch (error) {
133
+ if (error?.code === 'ENOENT') return null;
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ async function writeStageCache(cacheDir, fileName, data) {
139
+ if (!cacheDir) return;
140
+ await mkdir(cacheDir, { recursive: true });
141
+ await writeFile(path.join(cacheDir, fileName), `${JSON.stringify(data, null, 2)}\n`, 'utf8');
142
+ }
143
+
144
+ async function callPracticeAgentCached({ cacheDir, cacheFile, onProgress, ...input }) {
145
+ const cached = await readStageCache(cacheDir, cacheFile);
146
+ if (cached) {
147
+ onProgress?.({
148
+ step: 'practice_generate.cache_hit',
149
+ message: `复用阶段缓存:${cacheFile}。`
150
+ });
151
+ return cached;
152
+ }
153
+ const result = await callPracticeAgent(input);
154
+ if (result.ok) await writeStageCache(cacheDir, cacheFile, result);
155
+ return result;
156
+ }
157
+
158
+ function mergeRevisionQuestions(originalQuestions, revisedQuestions) {
159
+ const revisedById = new Map((revisedQuestions || []).map((question) => [question.id, question]));
160
+ return (originalQuestions || []).map((question) => revisedById.get(question.id) || question);
161
+ }
162
+
163
+ function omitSvgSourceForTextReview(question) {
164
+ if (!question || !question.svg) return question;
165
+ return {
166
+ ...question,
167
+ svg: '[inline SVG omitted from text review; rendered figure is reviewed separately]',
168
+ svgReview: question.svgReview
169
+ ? {
170
+ passed: question.svgReview.passed,
171
+ summary: question.svgReview.summary,
172
+ findings: question.svgReview.findings
173
+ }
174
+ : undefined
175
+ };
176
+ }
177
+
178
+ function omitSvgSourcesForTextReview(practiceDraft) {
179
+ return {
180
+ ...practiceDraft,
181
+ questions: (practiceDraft.questions || []).map(omitSvgSourceForTextReview)
182
+ };
183
+ }
184
+
185
+ function normalizeSvgSelectionItems(data) {
186
+ if (Array.isArray(data?.items)) return data.items;
187
+ if (!Array.isArray(data?.findings)) return [];
188
+ return data.findings
189
+ .map((finding) => {
190
+ const instruction = String(finding.fixInstruction || finding.visualPriority || '').toLowerCase();
191
+ const message = String(finding.message || '');
192
+ const priority = instruction.includes('required')
193
+ ? 'required'
194
+ : instruction.includes('useful')
195
+ ? 'useful'
196
+ : instruction.includes('not_needed') || instruction.includes('not needed')
197
+ ? 'not_needed'
198
+ : '';
199
+ return {
200
+ questionId: finding.questionId,
201
+ visualPriority: priority,
202
+ reason: message || finding.fixInstruction || ''
203
+ };
204
+ })
205
+ .filter((item) => item.questionId && item.visualPriority);
206
+ }
207
+
126
208
  function contextForQuestions(baseContext, questions) {
127
209
  const usedIds = new Set();
128
210
  for (const question of questions || []) {
@@ -288,6 +370,204 @@ function coverageSummaryForQuestions(targetKnowledgePoints, questions) {
288
370
  };
289
371
  }
290
372
 
373
+ async function selectQuestionsForSvgReview({
374
+ stageCacheDir,
375
+ onProgress,
376
+ baseContext,
377
+ revision,
378
+ minSvgQuestions
379
+ }) {
380
+ const existingSvgIds = revision.questions
381
+ .filter((question) => String(question.svg || '').includes('<svg'))
382
+ .map((question) => question.id);
383
+ const targetCount = minSvgQuestions > 0
384
+ ? geometryVisualSampleCount(revision.questions.length, minSvgQuestions)
385
+ : existingSvgIds.length;
386
+ if (!targetCount) return existingSvgIds;
387
+ if (existingSvgIds.length >= targetCount && !minSvgQuestions) return existingSvgIds;
388
+ onProgress({
389
+ step: 'practice_generate.svg_select',
390
+ message: `正在判断哪些题需要配图或适合作为几何视觉校验样本(目标至少 ${targetCount} 题)。`
391
+ });
392
+ const result = await callPracticeAgentCached({
393
+ cacheDir: stageCacheDir,
394
+ cacheFile: 'svg-selection.json',
395
+ onProgress,
396
+ promptName: 'practice-review.system.md',
397
+ task: '判断定稿题目中哪些题需要或适合补充 SVG 配图,以便进入渲染后视觉校验。',
398
+ context: {
399
+ ...baseContext,
400
+ practiceDraft: omitSvgSourcesForTextReview(revision)
401
+ },
402
+ requirements: [
403
+ '逐题判断题目是否需要视觉信息才能唯一作答。',
404
+ 'required 表示没有图会导致位置关系、展开图、角、线段、射线方向、交点数量等条件不完整或不唯一。',
405
+ 'useful 表示题目文字可答,但配图能显著降低几何表述歧义,适合作为几何 SVG 回归样本。',
406
+ 'not_needed 表示纯概念、纯换算或纯文字辨析,配图不会增加有效信息。',
407
+ `如果 required 题不足 ${targetCount} 题,请从 useful 中按最能验证几何画图机制的题补足。`,
408
+ '不要因为题目属于几何章节就机械判 required;必须说明理由。'
409
+ ],
410
+ schema: {
411
+ items: [{
412
+ questionId: 'q1',
413
+ visualPriority: 'required|useful|not_needed',
414
+ reason: 'string'
415
+ }]
416
+ }
417
+ });
418
+ const selectionItems = result.ok ? normalizeSvgSelectionItems(result.data) : [];
419
+ if (!result.ok || !selectionItems.length) {
420
+ const attempts = result.attempts ? ` attempts=${result.attempts}` : '';
421
+ throw generationError(`svg_selection_${result.reason || 'invalid_agent_response'}`, `${result.detail || ''}${attempts}`);
422
+ }
423
+ const validQuestionIds = new Set(revision.questions.map((question) => question.id));
424
+ const required = selectionItems
425
+ .filter((item) => item.visualPriority === 'required' && validQuestionIds.has(item.questionId))
426
+ .map((item) => item.questionId);
427
+ const useful = selectionItems
428
+ .filter((item) => item.visualPriority === 'useful' && validQuestionIds.has(item.questionId))
429
+ .map((item) => item.questionId);
430
+ const selected = [...new Set([...existingSvgIds, ...required])];
431
+ for (const questionId of useful) {
432
+ if (selected.length >= targetCount) break;
433
+ selected.push(questionId);
434
+ }
435
+ if (selected.length < targetCount) {
436
+ for (const question of revision.questions) {
437
+ if (selected.length >= targetCount) break;
438
+ if (!selected.includes(question.id)) selected.push(question.id);
439
+ }
440
+ }
441
+ onProgress({
442
+ step: 'practice_generate.svg_select_done',
443
+ message: `已选择 ${selected.length} 道题进入 SVG 补图/渲染审查:${selected.join('、')}。`
444
+ });
445
+ return {
446
+ questionIds: selected,
447
+ strictQuestionIds: [...new Set([...existingSvgIds, ...required])]
448
+ };
449
+ }
450
+
451
+ async function runFinalReview({
452
+ stageCacheDir,
453
+ cacheFile,
454
+ onProgress,
455
+ baseContext,
456
+ revision,
457
+ revisionCoverage,
458
+ initialReview,
459
+ isAbilityAssessment
460
+ }) {
461
+ return callPracticeAgentCached({
462
+ cacheDir: stageCacheDir,
463
+ cacheFile,
464
+ onProgress,
465
+ promptName: 'practice-review.system.md',
466
+ task: '复审修订后的最终练习题目和知识点绑定;本阶段不评审答案元数据',
467
+ context: {
468
+ ...baseContext,
469
+ practiceDraft: omitSvgSourcesForTextReview(revision),
470
+ localCoverageCheck: revisionCoverage,
471
+ initialReview
472
+ },
473
+ requirements: [
474
+ '这是定稿复审;评审题目质量、可作答性、重复风险、题量规划和 knowledgePointIds。',
475
+ '如果仍有 blocker,必须 passed=false,并指出具体题号和修正原因。',
476
+ '必须拦截题目条件缺失、答案不唯一、选项不完整、知识点绑定不在 targetKnowledgePoints 中的问题。',
477
+ '覆盖判断必须以 context.localCoverageCheck 为准;只有 missingPointIds 或 outsidePointIds 非空时,才能给 bad_coverage blocker。',
478
+ '必须检查 knowledgePoints、expectedErrorTypes、skillAtoms、expectedAbilityErrors 是否为纯文本标签;出现 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装时要求修正。',
479
+ isAbilityAssessment ? '能力评估题必须检查 abilityIds 是否完整且只来自 context.options.abilityIds。' : '',
480
+ '定稿复审必须拦截 LaTeX 反斜杠损坏、制表符残留、几何/SVG 标注与题干不一致、标签遮挡关键条件。'
481
+ ].filter(Boolean),
482
+ schema: {
483
+ passed: false,
484
+ summary: 'string',
485
+ findings: [{
486
+ level: 'blocker|warning',
487
+ type: 'string',
488
+ questionId: 'q1',
489
+ message: 'string',
490
+ fixInstruction: 'string'
491
+ }],
492
+ coverage: {
493
+ coveredPointIds: ['chapter-01-kp-01'],
494
+ missingPointIds: ['chapter-01-kp-02']
495
+ }
496
+ }
497
+ });
498
+ }
499
+
500
+ async function repairFinalBlockers({
501
+ stageCacheDir,
502
+ onProgress,
503
+ baseContext,
504
+ revision,
505
+ finalBlockers,
506
+ isAbilityAssessment,
507
+ repairRound = 1
508
+ }) {
509
+ const globalFindings = finalBlockers.filter((finding) => !finding.questionId);
510
+ const questionIds = new Set(finalBlockers.map((finding) => finding.questionId).filter(Boolean));
511
+ const questionsToRevise = globalFindings.length
512
+ ? revision.questions
513
+ : revision.questions.filter((question) => questionIds.has(question.id));
514
+ if (!questionsToRevise.length) return revision;
515
+ const chunks = chunkArray(questionsToRevise, 4);
516
+ const revisionChunks = [];
517
+ for (let index = 0; index < chunks.length; index += 1) {
518
+ const questions = chunks[index];
519
+ const chunkQuestionIds = questions.map((question) => question.id);
520
+ const findings = finalBlockers
521
+ .filter((finding) => !finding.questionId || chunkQuestionIds.includes(finding.questionId));
522
+ onProgress({
523
+ step: 'practice_generate.final_blocker_repair',
524
+ message: `正在按终审 blocker 修订题目:第 ${index + 1}/${chunks.length} 批(${chunkQuestionIds.join('、')})。`
525
+ });
526
+ const chunk = assertAgentQuestions(await callPracticeAgentCached({
527
+ cacheDir: stageCacheDir,
528
+ cacheFile: `final-blocker-revision-r${String(repairRound).padStart(2, '0')}-chunk-${String(index + 1).padStart(2, '0')}.json`,
529
+ onProgress,
530
+ promptName: 'practice-revise.system.md',
531
+ task: `根据最终复审 blocker 修正题目(第 ${repairRound} 轮,第 ${index + 1}/${chunks.length} 批)`,
532
+ context: {
533
+ ...contextForQuestions(baseContext, questions),
534
+ practiceDraft: {
535
+ ...revision,
536
+ questions: questions.map(omitSvgSourceForTextReview)
537
+ },
538
+ review: {
539
+ passed: false,
540
+ summary: '最终复审发现 blocker,必须逐项修复后才能生成试卷。',
541
+ findings
542
+ }
543
+ },
544
+ requirements: [
545
+ '必须逐条修复终审 blocker,不能只复述问题。',
546
+ '如果 blocker 类型是 bad_options、unanswerable 或指出答案不唯一,必须先保证题目存在唯一、明确、可判分的答案;选择题必须确保四个选项中恰好一个正确。',
547
+ '遇到选项唯一性问题时,不要只微调一个模糊选项;如果不能确信唯一正确,必须重写整道题的题干和全部选项,必要时改成填空题或简答题。',
548
+ '正方体展开图等视觉题不要使用“可能、并非明显、也许可以”这类不确定条件作为错误选项;错误选项必须是概念上明确错误或与给定图形明确矛盾。',
549
+ '只输出 practiceDraft.questions 中的题目,不新增题目,不删除题目,不改变题目 ID。',
550
+ '保持 knowledgePointIds 来自 targetKnowledgePoints;如果修复导致知识点绑定变化,必须同步改题干使题目仍可作答。',
551
+ '修订版必须包含题目、题型、难度、knowledgePointIds、knowledgePoints、expectedErrorTypes、配图字段和答题空间行数。',
552
+ isAbilityAssessment ? '能力评估修订版还必须保留 abilityIds、skillAtoms、expectedAbilityErrors。' : '',
553
+ '如果 blocker 指出题目缺图或视觉信息不清,必须补充或修复内联 SVG,并确保题干与图中标签完全一致。',
554
+ '不要输出 answer、solutionSteps 或 rubric;这些字段会在题目定稿后分批补全。'
555
+ ].filter(Boolean),
556
+ schema: questionRevisionSchema()
557
+ }), 'final_blocker_revision');
558
+ revisionChunks.push(chunk);
559
+ }
560
+ const firstRevision = revisionChunks[0] || {};
561
+ return {
562
+ title: normalizePracticeTitle(firstRevision.title || revision.title),
563
+ personalizationBasis: firstRevision.personalizationBasis || revision.personalizationBasis || [],
564
+ revisionSummary: [revision.revisionSummary, ...revisionChunks.map((chunk) => chunk.revisionSummary)]
565
+ .filter(Boolean)
566
+ .join('\n'),
567
+ questions: mergeRevisionQuestions(revision.questions, revisionChunks.flatMap((chunk) => chunk.questions))
568
+ };
569
+ }
570
+
291
571
  function answerMetadataSchema() {
292
572
  return {
293
573
  questions: [{
@@ -302,6 +582,56 @@ function answerMetadataSchema() {
302
582
  };
303
583
  }
304
584
 
585
+ function questionRevisionSchema() {
586
+ return {
587
+ title: 'string',
588
+ personalizationBasis: ['string'],
589
+ revisionSummary: 'string',
590
+ questions: [{
591
+ id: 'q1',
592
+ stem: 'plain text with LaTeX delimiters; only question',
593
+ questionKind: 'choice|blank|short_answer',
594
+ difficulty: 'basic|medium|challenge',
595
+ knowledgePointIds: ['chapter-01-kp-01'],
596
+ knowledgePoints: ['plain Chinese tag label, no LaTeX delimiters or HTML'],
597
+ expectedErrorTypes: ['plain Chinese tag label, no LaTeX delimiters or HTML'],
598
+ abilityIds: ['calculation_accuracy'],
599
+ skillAtoms: ['plain Chinese calculation skill atom'],
600
+ expectedAbilityErrors: ['plain Chinese ability error label, no LaTeX delimiters or HTML'],
601
+ svg: 'optional inline SVG string',
602
+ imagePrompt: 'optional gpt-image-2 prompt',
603
+ score: 6,
604
+ answerSpaceLines: 2
605
+ }]
606
+ };
607
+ }
608
+
609
+ function compactFinding(finding) {
610
+ return [
611
+ finding.questionId || 'global',
612
+ finding.type || 'unknown',
613
+ finding.message || '',
614
+ finding.fixInstruction ? `修复:${finding.fixInstruction}` : ''
615
+ ].filter(Boolean).join(' - ');
616
+ }
617
+
618
+ function blockerDetail(findings) {
619
+ return (findings || []).map(compactFinding).join(' | ');
620
+ }
621
+
622
+ function blockerMessage(findings, prefix = '最终复审仍有 blocker') {
623
+ const list = (findings || []).map(compactFinding);
624
+ if (!list.length) return prefix;
625
+ return `${prefix}:${list.slice(0, 4).join(';')}${list.length > 4 ? `;另 ${list.length - 4} 条` : ''}`;
626
+ }
627
+
628
+ function geometryVisualSampleCount(totalQuestionCount, configuredCount) {
629
+ const requested = Number(configuredCount);
630
+ if (Number.isFinite(requested) && requested > 0) return Math.floor(requested);
631
+ const total = Math.max(1, Number(totalQuestionCount || 0));
632
+ return Math.max(1, Math.min(4, Math.ceil(total / 4)));
633
+ }
634
+
305
635
  function mergeAnswerMetadata(questions, answerChunks) {
306
636
  const answersById = new Map();
307
637
  for (const chunk of answerChunks) {
@@ -396,8 +726,27 @@ function recommendedQuestionCount(targetCount, maxQuestionCount) {
396
726
  return Math.max(3, Math.min(maxCount, Math.ceil(targetCount / 2)));
397
727
  }
398
728
 
399
- async function callPracticeAgent({ promptName, task, context, requirements, schema, timeoutMs = 120000, temperature = 0.2, retries = 2 }) {
729
+ function isGeometryLikeChapter(chapter) {
730
+ const text = `${chapter?.title || ''} ${chapter?.fullTitle || ''} ${chapter?.track || ''}`;
731
+ return /几何|图形|三角形|平行|垂直|圆|角|线段|直线|射线|展开图|立体/.test(text);
732
+ }
733
+
734
+ async function readPracticeSystemPrompt(promptName, context) {
400
735
  const systemPrompt = await readPrompt(promptName);
736
+ const geometryPromptNames = new Set([
737
+ 'practice-generate.system.md',
738
+ 'practice-review.system.md',
739
+ 'practice-revise.system.md'
740
+ ]);
741
+ if (!geometryPromptNames.has(promptName) || !isGeometryLikeChapter(context?.chapter)) {
742
+ return systemPrompt;
743
+ }
744
+ const geometryExperience = await readPrompt('geometry-practice-experience.md');
745
+ return `${systemPrompt}\n\n${geometryExperience}`;
746
+ }
747
+
748
+ async function callPracticeAgent({ promptName, task, context, requirements, schema, timeoutMs = 120000, temperature = 0.2, retries = 1 }) {
749
+ const systemPrompt = await readPracticeSystemPrompt(promptName, context);
401
750
  return callChatAgent({
402
751
  timeoutMs,
403
752
  retries,
@@ -474,6 +823,11 @@ async function generationHistory(chapterId) {
474
823
  export async function generatePracticeContent(input) {
475
824
  const { profile, chapter, options } = input;
476
825
  const onProgress = progressReporter(input.onProgress);
826
+ const stageCacheDir = options.generationCacheDir || '';
827
+ const fastReview = Boolean(options.fastReview);
828
+ const verifySvgFigures = Boolean(options.verifySvgFigures);
829
+ const requireSvg = Boolean(options.requireSvg);
830
+ const minSvgQuestions = Number(options.minSvgQuestions || 0);
477
831
  onProgress({ step: 'practice_generate.history', message: '正在整理历史题目和近期错因。' });
478
832
  const history = await generationHistory(chapter.id);
479
833
  const knowledgeDoc = options.knowledgeDoc;
@@ -559,6 +913,7 @@ export async function generatePracticeContent(input) {
559
913
  history
560
914
  };
561
915
  const isAbilityAssessment = options.type === 'ability_assessment';
916
+ const isMistakeRepair = options.type === 'mistake_repair';
562
917
  const baseRequirements = [
563
918
  '全部内容只能由 LLM 生成,不能使用本地模板或规则兜底。',
564
919
  '题目之间不能重复,也不要和 previousStems 高度相似。',
@@ -572,7 +927,9 @@ export async function generatePracticeContent(input) {
572
927
  ? '本次是指定知识点范围覆盖,只能围绕 targetKnowledgePoints 出题,不要覆盖范围外知识点;必须用少题高覆盖策略,一道题可以覆盖多个紧密相关知识点。'
573
928
  : isAbilityAssessment
574
929
  ? '本次是能力评估卷:目标来自 abilitySpecs 与本章知识范围,题目应围绕能力表现取样,不要机械追求知识点全覆盖。'
575
- : '本次是章节知识点覆盖卷,不要要求用户提供知识点编号;首轮覆盖尚未完成时,knowledge_coverage 必须优先从 previousKnowledgePointIds 没有覆盖过的 targetKnowledgePoints 出题;已覆盖点只做必要合并抽样。首轮完成后,再按 needs_review、wrongCount 高和 recentMistakes 命中的点优先。',
930
+ : isMistakeRepair
931
+ ? '本次是易错点覆盖/巩固卷:优先围绕 recentMistakes、needs_review、wrongCount 高的知识点和易错类型设计题目;如果当前没有可用易错点,则生成基础巩固卷,但题目仍要体现常见易错辨析,不要伪装成首轮知识点覆盖。'
932
+ : '本次是章节知识点覆盖卷,不要要求用户提供知识点编号;首轮覆盖尚未完成时,knowledge_coverage 必须优先从 previousKnowledgePointIds 没有覆盖过的 targetKnowledgePoints 出题;已覆盖点只做必要合并抽样。首轮完成后,再按 needs_review、wrongCount 高和 recentMistakes 命中的点优先。',
576
933
  isAbilityAssessment
577
934
  ? '能力评估第一版只评估计算准确性,不纳入用时、速度、限时达标;题目以短题、多题、低语境为主,尽量暴露计算准确性和常见计算错误。'
578
935
  : '',
@@ -588,16 +945,20 @@ export async function generatePracticeContent(input) {
588
945
  'knowledgePoints 和 expectedErrorTypes 是讲解版胶囊标签,只能写纯中文短标签;禁止使用 $...$、\\(...\\)、\\[...\\]、HTML 或复杂公式包装。例如写“特殊值 0 误判”,不要写“特殊值 $0$ 误判”。',
589
946
  '题干只放题目,不放解题过程。',
590
947
  '可以在题干中使用 Markdown 表格表达分类、对照或数据,但不要使用 HTML table 标签。',
591
- '题目使用纯文本加 LaTeX 分隔符;只有几何、数轴或图形题必要时才使用内联 SVG 或 imagePrompt。',
948
+ '题目使用纯文本加 LaTeX 分隔符;出题时必须先判断题目是否需要视觉信息才能作答。',
592
949
  'LaTeX 命令必须保留完整反斜杠,例如 \\triangle、\\angle、\\perp、\\parallel、\\text{};禁止输出 triangle/riangle/angle/perp/parallel/ext{} 或把 \\t 变成制表符。',
593
- '几何、数轴或 SVG 图形题必须保证题干、图中标签、角弧、垂直/平行/长度标注完全一致;如果题干问 angle 2、切点、垂足或某条边,图中对应位置必须准确。',
950
+ '如果没有图,题干必须仍然信息完整、唯一可答、无需学生凭想象补出位置关系或图形结构;否则必须提供内联 SVG,或把题目改写成纯文字可答题。',
951
+ '展开图、折叠、图形甲/乙、点线面位置、直线/射线/线段位置、角、垂直、平行、中点、延长线、交点数量、方格拼图等通常需要视觉判断,但这不是固定清单;最终以本题是否依赖视觉信息为准。',
952
+ '几何、数轴或 SVG 图形题必须保证题干、图中标签、角弧、垂直/平行/长度标注完全一致;如果题干问 angle 2、切点、垂足、某条边或某个展开图,SVG 中对应位置必须准确。',
594
953
  'SVG 标签必须可读且不遮挡线段、角弧、表格或答题区;优先使用简单清晰的线图。',
595
954
  '题面不给学生显示分值,不要在题干里写“本题多少分”。',
596
955
  '第一阶段只生成题面、题型、知识点绑定、预期错因、分值和留白行数;answer、solutionSteps、rubric 会在题目定稿后由第二阶段分批补全。'
597
956
  ].filter(Boolean);
598
957
  onProgress({
599
958
  step: 'practice_generate.plan',
600
- message: `正在分析首轮覆盖策略:已覆盖 ${firstRoundCoveredIds.length}/${compactTargetKnowledgePoints.length} 个知识点,本次候选 ${targetKnowledgePoints.length} 个。`
959
+ message: isMistakeRepair
960
+ ? `正在分析易错巩固策略:当前可用易错点 ${history.recentMistakes.length} 个,本次候选知识点 ${targetKnowledgePoints.length} 个。`
961
+ : `正在分析首轮覆盖策略:已覆盖 ${firstRoundCoveredIds.length}/${compactTargetKnowledgePoints.length} 个知识点,本次候选 ${targetKnowledgePoints.length} 个。`
601
962
  });
602
963
  const questionGenerationRequirements = [
603
964
  ...baseRequirements,
@@ -613,7 +974,8 @@ export async function generatePracticeContent(input) {
613
974
  '不要输出 answer、solutionSteps 或 rubric;本阶段只定稿题目本身和知识点绑定。'
614
975
  ];
615
976
  const totalQuestionCount = options.questionCount || localRecommendedQuestionCount;
616
- const questionTargetChunks = chunkArray(targetKnowledgePoints, 3);
977
+ const questionTargetChunkSize = totalQuestionCount <= 10 ? 4 : 5;
978
+ const questionTargetChunks = chunkArray(targetKnowledgePoints, questionTargetChunkSize);
617
979
  const questionBatchCounts = distributeQuestionCounts(questionTargetChunks, totalQuestionCount);
618
980
  const questionChunks = [];
619
981
  const promptName = 'practice-generate.system.md';
@@ -646,7 +1008,10 @@ export async function generatePracticeContent(input) {
646
1008
  step: 'practice_generate.questions',
647
1009
  message: `正在生成题目草稿:第 ${index + 1}/${questionTargetChunks.length} 批(${batchQuestionCount} 题,约 ${Math.round(requestChars / 1000)}KB 请求)。`
648
1010
  });
649
- const chunk = assertAgentQuestions(await callPracticeAgent({
1011
+ const chunk = assertAgentQuestions(await callPracticeAgentCached({
1012
+ cacheDir: stageCacheDir,
1013
+ cacheFile: `question-chunk-${String(index + 1).padStart(2, '0')}.json`,
1014
+ onProgress,
650
1015
  promptName,
651
1016
  task: batchTask,
652
1017
  context: batchContext,
@@ -699,12 +1064,15 @@ export async function generatePracticeContent(input) {
699
1064
  source: 'agent'
700
1065
  });
701
1066
  onProgress({ step: 'practice_generate.review', message: '正在评审题目可作答性、覆盖规划和知识点绑定。' });
702
- const review = await callPracticeAgent({
1067
+ const review = await callPracticeAgentCached({
1068
+ cacheDir: stageCacheDir,
1069
+ cacheFile: 'initial-review.json',
1070
+ onProgress,
703
1071
  promptName: 'practice-review.system.md',
704
1072
  task: '评审练习题目草稿和知识点绑定;本阶段不评审答案元数据',
705
1073
  context: {
706
1074
  ...contextForQuestions(baseContext, practiceDraft.questions),
707
- practiceDraft
1075
+ practiceDraft: omitSvgSourcesForTextReview(practiceDraft)
708
1076
  },
709
1077
  requirements: [
710
1078
  '至少完成一次 LLM review;发现问题必须给出具体修正指令。',
@@ -713,7 +1081,8 @@ export async function generatePracticeContent(input) {
713
1081
  isAbilityAssessment ? '能力评估题必须检查 abilityIds 是否完整且只来自 context.options.abilityIds。' : '',
714
1082
  '如果题目缺少必要条件、答案无法唯一推出、选项不完整或题型不合适,必须判 blocker。',
715
1083
  '必须检查 LaTeX 反斜杠是否损坏,尤其是 \\triangle、\\angle、\\perp、\\parallel、\\text{};发现丢反斜杠或制表符残留必须判 blocker。',
716
- '必须检查几何/SVG 题的题干与图中角标、垂足、切点、边名、长度和平行/垂直标注是否一致;不一致必须判 blocker。'
1084
+ '必须检查几何/SVG 题的题干与图中角标、垂足、切点、边名、长度和平行/垂直标注是否一致;不一致必须判 blocker。',
1085
+ '必须由评审判断每道题是否需要视觉信息才能作答。若没有 svg 但题干仍信息完整、唯一可答,则不要因为题目属于几何题而判错;若缺少图会导致学生凭想象补条件、空间位置不明确或答案不唯一,必须判 blocker 并要求补 SVG 或改成纯文字题。'
717
1086
  ].filter(Boolean),
718
1087
  schema: {
719
1088
  passed: false,
@@ -740,7 +1109,20 @@ export async function generatePracticeContent(input) {
740
1109
  message: `质量评审完成:${review.data.findings.length} 条意见,开始修订题目。`
741
1110
  });
742
1111
  const revisionChunks = [];
743
- const questionRevisionChunks = chunkArray(practiceDraft.questions, 3);
1112
+ const globalReviewFindings = (review.data.findings || []).filter((finding) => !finding.questionId);
1113
+ const questionIdsNeedingRevision = new Set((review.data.findings || [])
1114
+ .map((finding) => finding.questionId)
1115
+ .filter(Boolean));
1116
+ const questionsToRevise = globalReviewFindings.length
1117
+ ? practiceDraft.questions
1118
+ : practiceDraft.questions.filter((question) => questionIdsNeedingRevision.has(question.id));
1119
+ const questionRevisionChunks = chunkArray(questionsToRevise, 4);
1120
+ if (!questionRevisionChunks.length) {
1121
+ onProgress({
1122
+ step: 'practice_generate.revision_skip',
1123
+ message: '评审未提出逐题修改意见,跳过 LLM 修订阶段。'
1124
+ });
1125
+ }
744
1126
  for (let index = 0; index < questionRevisionChunks.length; index += 1) {
745
1127
  const questions = questionRevisionChunks[index];
746
1128
  const questionIds = questions.map((question) => question.id);
@@ -750,14 +1132,17 @@ export async function generatePracticeContent(input) {
750
1132
  step: 'practice_generate.revision',
751
1133
  message: `正在修订题目:第 ${index + 1}/${questionRevisionChunks.length} 批(${questionIds.join('、')})。`
752
1134
  });
753
- const revisionChunk = assertAgentQuestions(await callPracticeAgent({
1135
+ const revisionChunk = assertAgentQuestions(await callPracticeAgentCached({
1136
+ cacheDir: stageCacheDir,
1137
+ cacheFile: `revision-chunk-${String(index + 1).padStart(2, '0')}.json`,
1138
+ onProgress,
754
1139
  promptName: 'practice-revise.system.md',
755
1140
  task: `根据评审意见修正练习卷并输出修订版(第 ${index + 1}/${questionRevisionChunks.length} 批)`,
756
1141
  context: {
757
1142
  ...contextForQuestions(baseContext, questions),
758
1143
  practiceDraft: {
759
1144
  ...practiceDraft,
760
- questions
1145
+ questions: questions.map(omitSvgSourceForTextReview)
761
1146
  },
762
1147
  review: {
763
1148
  ...review.data,
@@ -775,33 +1160,15 @@ export async function generatePracticeContent(input) {
775
1160
  '必须修复 LaTeX 反斜杠损坏,并同步校准几何/SVG 题的题干与图中角标、垂足、切点、边名、长度和平行/垂直标注。',
776
1161
  '不要输出未修正草稿。'
777
1162
  ].filter(Boolean),
778
- schema: {
779
- title: 'string',
780
- personalizationBasis: ['string'],
781
- revisionSummary: 'string',
782
- questions: [{
783
- id: 'q1',
784
- stem: 'plain text with LaTeX delimiters; only question',
785
- questionKind: 'choice|blank|short_answer',
786
- difficulty: 'basic|medium|challenge',
787
- knowledgePointIds: ['chapter-01-kp-01'],
788
- knowledgePoints: ['plain Chinese tag label, no LaTeX delimiters or HTML'],
789
- expectedErrorTypes: ['plain Chinese tag label, no LaTeX delimiters or HTML'],
790
- abilityIds: ['calculation_accuracy'],
791
- skillAtoms: ['plain Chinese calculation skill atom'],
792
- expectedAbilityErrors: ['plain Chinese ability error label, no LaTeX delimiters or HTML'],
793
- svg: 'optional inline SVG string',
794
- imagePrompt: 'optional gpt-image-2 prompt',
795
- score: 6,
796
- answerSpaceLines: 2
797
- }]
798
- }
1163
+ schema: questionRevisionSchema()
799
1164
  }), 'revision');
800
1165
  revisionChunks.push(revisionChunk);
801
1166
  }
802
- const revisionQuestions = revisionChunks.flatMap((chunk) => chunk.questions);
1167
+ const revisionQuestions = questionRevisionChunks.length
1168
+ ? mergeRevisionQuestions(practiceDraft.questions, revisionChunks.flatMap((chunk) => chunk.questions))
1169
+ : practiceDraft.questions;
803
1170
  const firstRevision = revisionChunks[0] || {};
804
- const revision = {
1171
+ let revision = {
805
1172
  title: normalizePracticeTitle(firstRevision.title || practiceDraft.title),
806
1173
  personalizationBasis: firstRevision.personalizationBasis || practiceDraft.personalizationBasis || [],
807
1174
  revisionSummary: revisionChunks
@@ -811,42 +1178,124 @@ export async function generatePracticeContent(input) {
811
1178
  questions: revisionQuestions
812
1179
  };
813
1180
  onProgress({ step: 'practice_generate.revision_done', message: `题目修订完成:${revision.questions.length} 题。` });
814
- const revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
815
- onProgress({ step: 'practice_generate.final_review', message: '正在复审题目质量和重复风险。' });
816
- const finalReview = await callPracticeAgent({
817
- promptName: 'practice-review.system.md',
818
- task: '复审修订后的最终练习题目和知识点绑定;本阶段不评审答案元数据',
819
- context: {
820
- ...baseContext,
821
- practiceDraft: revision,
822
- localCoverageCheck: revisionCoverage,
823
- initialReview: review.data
824
- },
825
- requirements: [
826
- '这是定稿复审;评审题目质量、可作答性、重复风险、题量规划和 knowledgePointIds。',
827
- '如果仍有 blocker,必须 passed=false,并指出具体题号和修正原因。',
828
- '必须拦截题目条件缺失、答案不唯一、选项不完整、知识点绑定不在 targetKnowledgePoints 中的问题。',
829
- '覆盖判断必须以 context.localCoverageCheck 为准;只有 missingPointIds 或 outsidePointIds 非空时,才能给 bad_coverage blocker。',
830
- '必须检查 knowledgePoints、expectedErrorTypes、skillAtoms、expectedAbilityErrors 是否为纯文本标签;出现 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装时要求修正。',
831
- isAbilityAssessment ? '能力评估题必须检查 abilityIds 是否完整且只来自 context.options.abilityIds。' : '',
832
- '定稿复审必须拦截 LaTeX 反斜杠损坏、制表符残留、几何/SVG 标注与题干不一致、标签遮挡关键条件。'
833
- ].filter(Boolean),
834
- schema: {
835
- passed: false,
836
- summary: 'string',
837
- findings: [{
838
- level: 'blocker|warning',
839
- type: 'string',
840
- questionId: 'q1',
841
- message: 'string',
842
- fixInstruction: 'string'
843
- }],
844
- coverage: {
845
- coveredPointIds: ['chapter-01-kp-01'],
846
- missingPointIds: ['chapter-01-kp-02']
847
- }
848
- }
849
- });
1181
+ let revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1182
+ if (!isAbilityAssessment && revisionCoverage.missingPointIds.length) {
1183
+ const missingPoints = targetKnowledgePoints.filter((point) => revisionCoverage.missingPointIds.includes(point.id));
1184
+ onProgress({
1185
+ step: 'practice_generate.coverage_repair',
1186
+ message: `正在修复知识点覆盖缺口:${revisionCoverage.missingPointIds.join('、')}。`
1187
+ });
1188
+ const repair = assertAgentQuestions(await callPracticeAgentCached({
1189
+ cacheDir: stageCacheDir,
1190
+ cacheFile: 'coverage-repair.json',
1191
+ onProgress,
1192
+ promptName: 'practice-revise.system.md',
1193
+ task: '在不新增题目、不删除题目的前提下修复定稿题目的知识点覆盖缺口',
1194
+ context: {
1195
+ ...baseContext,
1196
+ practiceDraft: omitSvgSourcesForTextReview(revision),
1197
+ localCoverageCheck: revisionCoverage,
1198
+ missingTargetKnowledgePoints: missingPoints
1199
+ },
1200
+ requirements: [
1201
+ '只修复覆盖缺口,不新增题目,不删除题目,不改变题目 ID。',
1202
+ `必须让修订后的 questions 覆盖这些 missingPointIds:${revisionCoverage.missingPointIds.join('、')}。`,
1203
+ '优先选择最相关的已有题目进行小幅改写或补充 knowledgePointIds;不能硬塞不相关知识点。',
1204
+ '如果题面需要随知识点绑定变化而调整,必须同步改题干,保证学生可作答且答案唯一。',
1205
+ '继续遵守视觉信息判断:题目需要图才能唯一作答时必须提供 SVG,否则应改成纯文字可答题。',
1206
+ '不要输出 answer、solutionSteps 或 rubric;这些字段会在题目定稿后补全。'
1207
+ ],
1208
+ schema: questionRevisionSchema()
1209
+ }), 'coverage_repair');
1210
+ revision = {
1211
+ ...revision,
1212
+ title: normalizePracticeTitle(repair.title || revision.title),
1213
+ personalizationBasis: repair.personalizationBasis || revision.personalizationBasis || [],
1214
+ revisionSummary: [revision.revisionSummary, repair.revisionSummary]
1215
+ .filter(Boolean)
1216
+ .join('\n'),
1217
+ questions: mergeRevisionQuestions(revision.questions, repair.questions)
1218
+ };
1219
+ revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1220
+ onProgress({
1221
+ step: 'practice_generate.coverage_repair_done',
1222
+ message: `覆盖修复完成:已覆盖 ${revisionCoverage.coveredCount}/${revisionCoverage.intendedCount} 个目标点。`
1223
+ });
1224
+ }
1225
+ const shouldRunSvgVerification = verifySvgFigures || requireSvg || minSvgQuestions > 0;
1226
+ if (shouldRunSvgVerification) {
1227
+ const svgSelection = requireSvg
1228
+ ? {
1229
+ questionIds: revision.questions.map((question) => question.id),
1230
+ strictQuestionIds: revision.questions.map((question) => question.id)
1231
+ }
1232
+ : await selectQuestionsForSvgReview({
1233
+ stageCacheDir,
1234
+ onProgress,
1235
+ baseContext,
1236
+ revision,
1237
+ minSvgQuestions
1238
+ });
1239
+ onProgress({
1240
+ step: 'practice_generate.svg_verify',
1241
+ message: requireSvg
1242
+ ? '正在逐题渲染并审查 SVG 配图;本次要求每题都必须有图。'
1243
+ : `正在渲染并审查 SVG 配图:${svgSelection.questionIds.join('、') || '已有配图'}。`
1244
+ });
1245
+ revision = {
1246
+ ...revision,
1247
+ questions: await verifyAndRepairSvgFigures({
1248
+ questions: revision.questions,
1249
+ context: {
1250
+ chapter,
1251
+ targetKnowledgePoints
1252
+ },
1253
+ workDir: path.join(stageCacheDir || chapterDataPaths(chapter.id).context, 'svg-renders'),
1254
+ requireSvg,
1255
+ requiredQuestionIds: svgSelection.questionIds,
1256
+ strictQuestionIds: svgSelection.strictQuestionIds,
1257
+ maxRounds: 3,
1258
+ onProgress
1259
+ })
1260
+ };
1261
+ revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1262
+ onProgress({
1263
+ step: 'practice_generate.svg_verify_done',
1264
+ message: 'SVG 渲染审查完成,继续复审题目质量。'
1265
+ });
1266
+ }
1267
+ let finalReview = null;
1268
+ const reviewBlockers = (review.data.findings || []).filter((finding) => finding.level === 'blocker');
1269
+ const canSkipFinalReview = fastReview
1270
+ && reviewBlockers.length === 0
1271
+ && revisionCoverage.outsidePointIds.length === 0
1272
+ && (isAbilityAssessment || revisionCoverage.missingPointIds.length === 0);
1273
+ if (canSkipFinalReview) {
1274
+ finalReview = {
1275
+ ok: true,
1276
+ data: {
1277
+ passed: true,
1278
+ summary: 'fast_review 模式:初评无 blocker,且本地覆盖检查通过,跳过 LLM 定稿复审。',
1279
+ findings: [],
1280
+ coverage: revisionCoverage,
1281
+ source: 'fast_review_skip'
1282
+ },
1283
+ attempts: 0
1284
+ };
1285
+ onProgress({ step: 'practice_generate.final_review_skip', message: '快速回归模式下跳过定稿 LLM 复审。' });
1286
+ } else {
1287
+ onProgress({ step: 'practice_generate.final_review', message: '正在复审题目质量和重复风险。' });
1288
+ finalReview = await runFinalReview({
1289
+ stageCacheDir,
1290
+ cacheFile: 'final-review.json',
1291
+ onProgress,
1292
+ baseContext,
1293
+ revision,
1294
+ revisionCoverage,
1295
+ initialReview: review.data,
1296
+ isAbilityAssessment
1297
+ });
1298
+ }
850
1299
  if (!finalReview.ok || !Array.isArray(finalReview.data?.findings)) {
851
1300
  const attempts = finalReview.attempts ? ` attempts=${finalReview.attempts}` : '';
852
1301
  throw generationError(`final_review_${finalReview.reason || 'invalid_agent_response'}`, `${finalReview.detail || ''}${attempts}`);
@@ -857,18 +1306,92 @@ export async function generatePracticeContent(input) {
857
1306
  if (!isAbilityAssessment && revisionCoverage.missingPointIds.length) {
858
1307
  throw generationError('coverage_missing_targets', revisionCoverage.missingPointIds.join(','));
859
1308
  }
860
- const finalBlockers = (finalReview.data.findings || [])
861
- .filter((finding) => finding.level === 'blocker')
862
- .filter((finding) => finding.type !== 'bad_coverage');
863
- if (finalBlockers.length) {
864
- throw generationError(
865
- 'final_review_blockers',
866
- finalBlockers.map((finding) => `${finding.questionId || 'global'}:${finding.type}`).join('|')
867
- );
1309
+ const maxFinalBlockerRepairRounds = 3;
1310
+ for (let repairRound = 1; ; repairRound += 1) {
1311
+ const finalBlockers = (finalReview.data.findings || [])
1312
+ .filter((finding) => finding.level === 'blocker')
1313
+ .filter((finding) => finding.type !== 'bad_coverage');
1314
+ if (!finalBlockers.length) break;
1315
+ if (repairRound > maxFinalBlockerRepairRounds) {
1316
+ onProgress({
1317
+ step: 'practice_generate.final_review_failed',
1318
+ message: blockerMessage(finalBlockers, '自动修订后仍有终审 blocker')
1319
+ });
1320
+ throw generationError('final_review_blockers', blockerDetail(finalBlockers));
1321
+ }
1322
+ onProgress({
1323
+ step: 'practice_generate.final_review_blockers',
1324
+ message: blockerMessage(finalBlockers, `最终复审发现 blocker,正在自动修订(第 ${repairRound}/${maxFinalBlockerRepairRounds} 轮)`)
1325
+ });
1326
+ revision = await repairFinalBlockers({
1327
+ stageCacheDir,
1328
+ onProgress,
1329
+ baseContext,
1330
+ revision,
1331
+ finalBlockers,
1332
+ isAbilityAssessment,
1333
+ repairRound
1334
+ });
1335
+ revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1336
+ if (revisionCoverage.outsidePointIds.length) {
1337
+ throw generationError('coverage_outside_target_after_final_repair', revisionCoverage.outsidePointIds.join(','));
1338
+ }
1339
+ if (!isAbilityAssessment && revisionCoverage.missingPointIds.length) {
1340
+ throw generationError('coverage_missing_targets_after_final_repair', revisionCoverage.missingPointIds.join(','));
1341
+ }
1342
+ if (shouldRunSvgVerification) {
1343
+ const svgSelection = requireSvg
1344
+ ? {
1345
+ questionIds: revision.questions.map((question) => question.id),
1346
+ strictQuestionIds: revision.questions.map((question) => question.id)
1347
+ }
1348
+ : await selectQuestionsForSvgReview({
1349
+ stageCacheDir,
1350
+ onProgress,
1351
+ baseContext,
1352
+ revision,
1353
+ minSvgQuestions
1354
+ });
1355
+ revision = {
1356
+ ...revision,
1357
+ questions: await verifyAndRepairSvgFigures({
1358
+ questions: revision.questions,
1359
+ context: {
1360
+ chapter,
1361
+ targetKnowledgePoints
1362
+ },
1363
+ workDir: path.join(stageCacheDir || chapterDataPaths(chapter.id).context, 'svg-renders'),
1364
+ requireSvg,
1365
+ requiredQuestionIds: svgSelection.questionIds,
1366
+ strictQuestionIds: svgSelection.strictQuestionIds,
1367
+ maxRounds: 3,
1368
+ onProgress
1369
+ })
1370
+ };
1371
+ revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1372
+ }
1373
+ onProgress({
1374
+ step: 'practice_generate.final_review_retry',
1375
+ message: `终审 blocker 第 ${repairRound} 轮修订完成,正在重新复审。`
1376
+ });
1377
+ finalReview = await runFinalReview({
1378
+ stageCacheDir,
1379
+ cacheFile: `final-review-after-blocker-repair-${String(repairRound).padStart(2, '0')}.json`,
1380
+ onProgress,
1381
+ baseContext,
1382
+ revision,
1383
+ revisionCoverage,
1384
+ initialReview: review.data,
1385
+ isAbilityAssessment
1386
+ });
1387
+ if (!finalReview.ok || !Array.isArray(finalReview.data?.findings)) {
1388
+ const attempts = finalReview.attempts ? ` attempts=${finalReview.attempts}` : '';
1389
+ throw generationError(`final_review_retry_${finalReview.reason || 'invalid_agent_response'}`, `${finalReview.detail || ''}${attempts}`);
1390
+ }
868
1391
  }
869
1392
  onProgress({ step: 'practice_generate.final_review_done', message: `最终复审通过:${finalReview.data.findings.length} 条提示。` });
870
1393
  const answerChunks = [];
871
- const answerQuestionChunks = chunkArray(revision.questions, 3);
1394
+ const answerQuestionChunks = chunkArray(revision.questions, 4);
872
1395
  for (let index = 0; index < answerQuestionChunks.length; index += 1) {
873
1396
  const questions = answerQuestionChunks[index];
874
1397
  const questionIds = questions.map((question) => question.id);
@@ -876,7 +1399,10 @@ export async function generatePracticeContent(input) {
876
1399
  step: 'practice_generate.answers',
877
1400
  message: `正在生成标准答案和讲解:第 ${index + 1}/${answerQuestionChunks.length} 批(${questionIds.join('、')})。`
878
1401
  });
879
- const answerChunk = assertAgentQuestions(await callPracticeAgent({
1402
+ const answerChunk = assertAgentQuestions(await callPracticeAgentCached({
1403
+ cacheDir: stageCacheDir,
1404
+ cacheFile: `answer-chunk-${String(index + 1).padStart(2, '0')}.json`,
1405
+ onProgress,
880
1406
  promptName: 'practice-generate.system.md',
881
1407
  task: `为定稿题目补全答案元数据(第 ${index + 1}/${answerQuestionChunks.length} 批)`,
882
1408
  context: contextForAnswerMetadata(baseContext, questions),
@@ -884,7 +1410,8 @@ export async function generatePracticeContent(input) {
884
1410
  '只输出输入 questions 中已有题目的 id、answer、solutionSteps、rubric。',
885
1411
  '不得修改题目 ID、题干、题型、难度、knowledgePointIds、knowledgePoints、expectedErrorTypes、abilityIds、skillAtoms、expectedAbilityErrors、score、answerSpaceLines、svg 或 imagePrompt。',
886
1412
  'answer 必须是可核对的标准答案;选择题写正确选项和结论,填空/问答题写完整答案。',
887
- 'solutionSteps 必须是 2-5 步中文解题过程,能够解释孩子错在哪里。',
1413
+ 'solutionSteps 面向讲解卷阅读,不要机械写 2-5 步。简单选择题/填空题只写 1 条简短说明,说明本题考察什么和关键辨析点;需要推导、几何观察或多步判断的题,再写 2-4 条解题要点。',
1414
+ 'solutionSteps 不要重复标准答案,不要写成批改标准;语言要像给孩子看的讲解。',
888
1415
  'rubric 必须可用于批改,至少包含关键结论和关键过程;各项 score 之和应接近题目 score。',
889
1416
  '公式继续使用 LaTeX 分隔符,并保留完整反斜杠。'
890
1417
  ],