@zhouchangui/math-ati 0.1.2 → 0.1.4

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.
Files changed (35) hide show
  1. package/AGENTS.md +4 -1
  2. package/README.md +11 -0
  3. package/bin/math-ati.js +136 -5
  4. package/dist/assets/{index-CGZslJ0a.css → index-DOg8CQsE.css} +1 -1
  5. package/dist/assets/index-DyfeTKmg.js +22 -0
  6. package/dist/index.html +3 -3
  7. package/package.json +9 -5
  8. package/prompts/geometry-practice-experience.md +44 -0
  9. package/prompts/grading.system.md +3 -1
  10. package/prompts/knowledge-extract.system.md +35 -54
  11. package/prompts/knowledge-structure.system.md +75 -0
  12. package/prompts/knowledge-summarize.system.md +21 -7
  13. package/prompts/pdf-grading.system.md +4 -1
  14. package/prompts/pdf-recheck.system.md +2 -0
  15. package/prompts/practice-answers.system.md +154 -0
  16. package/prompts/practice-coverage-repair.system.md +112 -0
  17. package/prompts/practice-generate.system.md +51 -9
  18. package/prompts/practice-review.system.md +4 -2
  19. package/prompts/practice-revise.system.md +5 -4
  20. package/prompts/practice-rules.md +61 -0
  21. package/prompts/svg-figure-review.system.md +13 -0
  22. package/prompts/svg-figure-revise.system.md +21 -0
  23. package/server/agentClient.js +179 -10
  24. package/server/coveragePlanner.js +174 -0
  25. package/server/fileStore.js +49 -9
  26. package/server/index.js +78 -1
  27. package/server/knowledgeExtractor.js +717 -120
  28. package/server/knowledgeFeedback.js +69 -0
  29. package/server/practiceGenerator.js +637 -116
  30. package/server/practicePaperHtml.js +105 -35
  31. package/server/practiceService.js +27 -2
  32. package/server/promptStore.js +14 -0
  33. package/server/submissionService.js +1 -1
  34. package/server/svgFigureVerifier.js +315 -0
  35. package/dist/assets/index-CGfjl7nO.js +0 -22
@@ -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
- import { promptPayload, readPrompt } from './promptStore.js';
6
+ import { promptPayload, readPrompt, readRules } 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 || []) {
@@ -159,23 +241,8 @@ function compactPreviousStem(stem) {
159
241
  }
160
242
 
161
243
  function contextForAnswerMetadata(baseContext, questions) {
162
- const usedIds = new Set();
163
- for (const question of questions || []) {
164
- for (const pointId of question.knowledgePointIds || []) usedIds.add(pointId);
165
- }
166
244
  return {
167
245
  chapter: baseContext.chapter,
168
- options: {
169
- type: baseContext.options.type,
170
- questionCount: baseContext.options.questionCount,
171
- difficulty: baseContext.options.difficulty,
172
- questionKind: baseContext.options.questionKind,
173
- knowledgePointIds: [...usedIds],
174
- abilityIds: baseContext.options.abilityIds || []
175
- },
176
- abilitySpecs: baseContext.abilitySpecs || [],
177
- targetKnowledgePoints: baseContext.targetKnowledgePoints
178
- .filter((point) => usedIds.size === 0 || usedIds.has(point.id)),
179
246
  questions
180
247
  };
181
248
  }
@@ -288,6 +355,204 @@ function coverageSummaryForQuestions(targetKnowledgePoints, questions) {
288
355
  };
289
356
  }
290
357
 
358
+ async function selectQuestionsForSvgReview({
359
+ stageCacheDir,
360
+ onProgress,
361
+ baseContext,
362
+ revision,
363
+ minSvgQuestions
364
+ }) {
365
+ const existingSvgIds = revision.questions
366
+ .filter((question) => String(question.svg || '').includes('<svg'))
367
+ .map((question) => question.id);
368
+ const targetCount = minSvgQuestions > 0
369
+ ? geometryVisualSampleCount(revision.questions.length, minSvgQuestions)
370
+ : existingSvgIds.length;
371
+ if (!targetCount) return existingSvgIds;
372
+ if (existingSvgIds.length >= targetCount && !minSvgQuestions) return existingSvgIds;
373
+ onProgress({
374
+ step: 'practice_generate.svg_select',
375
+ message: `正在判断哪些题需要配图或适合作为几何视觉校验样本(目标至少 ${targetCount} 题)。`
376
+ });
377
+ const result = await callPracticeAgentCached({
378
+ cacheDir: stageCacheDir,
379
+ cacheFile: 'svg-selection.json',
380
+ onProgress,
381
+ promptName: 'practice-review.system.md',
382
+ task: '判断定稿题目中哪些题需要或适合补充 SVG 配图,以便进入渲染后视觉校验。',
383
+ context: {
384
+ ...baseContext,
385
+ practiceDraft: omitSvgSourcesForTextReview(revision)
386
+ },
387
+ requirements: [
388
+ '逐题判断题目是否需要视觉信息才能唯一作答。',
389
+ 'required 表示没有图会导致位置关系、展开图、角、线段、射线方向、交点数量等条件不完整或不唯一。',
390
+ 'useful 表示题目文字可答,但配图能显著降低几何表述歧义,适合作为几何 SVG 回归样本。',
391
+ 'not_needed 表示纯概念、纯换算或纯文字辨析,配图不会增加有效信息。',
392
+ `如果 required 题不足 ${targetCount} 题,请从 useful 中按最能验证几何画图机制的题补足。`,
393
+ '不要因为题目属于几何章节就机械判 required;必须说明理由。'
394
+ ],
395
+ schema: {
396
+ items: [{
397
+ questionId: 'q1',
398
+ visualPriority: 'required|useful|not_needed',
399
+ reason: 'string'
400
+ }]
401
+ }
402
+ });
403
+ const selectionItems = result.ok ? normalizeSvgSelectionItems(result.data) : [];
404
+ if (!result.ok || !selectionItems.length) {
405
+ const attempts = result.attempts ? ` attempts=${result.attempts}` : '';
406
+ throw generationError(`svg_selection_${result.reason || 'invalid_agent_response'}`, `${result.detail || ''}${attempts}`);
407
+ }
408
+ const validQuestionIds = new Set(revision.questions.map((question) => question.id));
409
+ const required = selectionItems
410
+ .filter((item) => item.visualPriority === 'required' && validQuestionIds.has(item.questionId))
411
+ .map((item) => item.questionId);
412
+ const useful = selectionItems
413
+ .filter((item) => item.visualPriority === 'useful' && validQuestionIds.has(item.questionId))
414
+ .map((item) => item.questionId);
415
+ const selected = [...new Set([...existingSvgIds, ...required])];
416
+ for (const questionId of useful) {
417
+ if (selected.length >= targetCount) break;
418
+ selected.push(questionId);
419
+ }
420
+ if (selected.length < targetCount) {
421
+ for (const question of revision.questions) {
422
+ if (selected.length >= targetCount) break;
423
+ if (!selected.includes(question.id)) selected.push(question.id);
424
+ }
425
+ }
426
+ onProgress({
427
+ step: 'practice_generate.svg_select_done',
428
+ message: `已选择 ${selected.length} 道题进入 SVG 补图/渲染审查:${selected.join('、')}。`
429
+ });
430
+ return {
431
+ questionIds: selected,
432
+ strictQuestionIds: [...new Set([...existingSvgIds, ...required])]
433
+ };
434
+ }
435
+
436
+ async function runFinalReview({
437
+ stageCacheDir,
438
+ cacheFile,
439
+ onProgress,
440
+ baseContext,
441
+ revision,
442
+ revisionCoverage,
443
+ initialReview,
444
+ isAbilityAssessment
445
+ }) {
446
+ return callPracticeAgentCached({
447
+ cacheDir: stageCacheDir,
448
+ cacheFile,
449
+ onProgress,
450
+ promptName: 'practice-review.system.md',
451
+ task: '复审修订后的最终练习题目和知识点绑定;本阶段不评审答案元数据',
452
+ context: {
453
+ ...baseContext,
454
+ practiceDraft: omitSvgSourcesForTextReview(revision),
455
+ localCoverageCheck: revisionCoverage,
456
+ initialReview
457
+ },
458
+ requirements: [
459
+ '这是定稿复审;遵守通用规则 R01-R20(已自动加载到 system prompt)。',
460
+ '评审题目质量、可作答性、重复风险、题量规划和 knowledgePointIds。',
461
+ '如果仍有 blocker,必须 passed=false,并指出具体题号和修正原因。',
462
+ '必须拦截题目条件缺失、答案不唯一、选项不完整、知识点绑定不在 targetKnowledgePoints 中的问题。',
463
+ '覆盖判断必须以 context.localCoverageCheck 为准;只有 missingPointIds 或 outsidePointIds 非空时,才能给 bad_coverage blocker。',
464
+ isAbilityAssessment ? '能力评估题必须检查 abilityIds 是否完整且只来自 context.options.abilityIds。' : ''
465
+ ].filter(Boolean),
466
+ schema: {
467
+ passed: false,
468
+ summary: 'string',
469
+ findings: [{
470
+ level: 'blocker|warning',
471
+ type: 'string',
472
+ questionId: 'q1',
473
+ message: 'string',
474
+ fixInstruction: 'string'
475
+ }],
476
+ coverage: {
477
+ coveredPointIds: ['chapter-01-kp-01'],
478
+ missingPointIds: ['chapter-01-kp-02']
479
+ }
480
+ }
481
+ });
482
+ }
483
+
484
+ async function repairFinalBlockers({
485
+ stageCacheDir,
486
+ onProgress,
487
+ baseContext,
488
+ revision,
489
+ finalBlockers,
490
+ isAbilityAssessment,
491
+ repairRound = 1
492
+ }) {
493
+ const globalFindings = finalBlockers.filter((finding) => !finding.questionId);
494
+ const questionIds = new Set(finalBlockers.map((finding) => finding.questionId).filter(Boolean));
495
+ const questionsToRevise = globalFindings.length
496
+ ? revision.questions
497
+ : revision.questions.filter((question) => questionIds.has(question.id));
498
+ if (!questionsToRevise.length) return revision;
499
+ const chunks = chunkArray(questionsToRevise, 4);
500
+ const revisionChunks = [];
501
+ for (let index = 0; index < chunks.length; index += 1) {
502
+ const questions = chunks[index];
503
+ const chunkQuestionIds = questions.map((question) => question.id);
504
+ const findings = finalBlockers
505
+ .filter((finding) => !finding.questionId || chunkQuestionIds.includes(finding.questionId));
506
+ onProgress({
507
+ step: 'practice_generate.final_blocker_repair',
508
+ message: `正在按终审 blocker 修订题目:第 ${index + 1}/${chunks.length} 批(${chunkQuestionIds.join('、')})。`
509
+ });
510
+ const chunk = assertAgentQuestions(await callPracticeAgentCached({
511
+ cacheDir: stageCacheDir,
512
+ cacheFile: `final-blocker-revision-r${String(repairRound).padStart(2, '0')}-chunk-${String(index + 1).padStart(2, '0')}.json`,
513
+ onProgress,
514
+ promptName: 'practice-revise.system.md',
515
+ task: `根据最终复审 blocker 修正题目(第 ${repairRound} 轮,第 ${index + 1}/${chunks.length} 批)`,
516
+ context: {
517
+ ...contextForQuestions(baseContext, questions),
518
+ practiceDraft: {
519
+ ...revision,
520
+ questions: questions.map(omitSvgSourceForTextReview)
521
+ },
522
+ review: {
523
+ passed: false,
524
+ summary: '最终复审发现 blocker,必须逐项修复后才能生成试卷。',
525
+ findings
526
+ }
527
+ },
528
+ requirements: [
529
+ '必须逐条修复终审 blocker,不能只复述问题。',
530
+ '遵守通用规则 R01-R20(已自动加载到 system prompt)。',
531
+ '如果 blocker 类型是 bad_options、unanswerable 或指出答案不唯一,必须先保证题目存在唯一、明确、可判分的答案;选择题必须确保四个选项中恰好一个正确。',
532
+ '遇到选项唯一性问题时,不要只微调一个模糊选项;如果不能确信唯一正确,必须重写整道题的题干和全部选项,必要时改成填空题或简答题。',
533
+ '正方体展开图等视觉题不要使用“可能、并非明显、也许可以”这类不确定条件作为错误选项;错误选项必须是概念上明确错误或与给定图形明确矛盾。',
534
+ '只输出 practiceDraft.questions 中的题目,不新增题目,不删除题目,不改变题目 ID。',
535
+ '保持 knowledgePointIds 来自 targetKnowledgePoints;如果修复导致知识点绑定变化,必须同步改题干使题目仍可作答。',
536
+ '修订版必须包含题目、题型、难度、knowledgePointIds、knowledgePoints、expectedErrorTypes、配图字段和答题空间行数。',
537
+ isAbilityAssessment ? '能力评估修订版还必须保留 abilityIds、skillAtoms、expectedAbilityErrors。' : '',
538
+ '如果 blocker 指出题目缺图或视觉信息不清,必须补充或修复内联 SVG,并确保题干与图中标签完全一致。',
539
+ '不要输出 answer、solutionSteps 或 rubric;这些字段会在题目定稿后分批补全。'
540
+ ].filter(Boolean),
541
+ schema: questionRevisionSchema()
542
+ }), 'final_blocker_revision');
543
+ revisionChunks.push(chunk);
544
+ }
545
+ const firstRevision = revisionChunks[0] || {};
546
+ return {
547
+ title: normalizePracticeTitle(firstRevision.title || revision.title),
548
+ personalizationBasis: firstRevision.personalizationBasis || revision.personalizationBasis || [],
549
+ revisionSummary: [revision.revisionSummary, ...revisionChunks.map((chunk) => chunk.revisionSummary)]
550
+ .filter(Boolean)
551
+ .join('\n'),
552
+ questions: mergeRevisionQuestions(revision.questions, revisionChunks.flatMap((chunk) => chunk.questions))
553
+ };
554
+ }
555
+
291
556
  function answerMetadataSchema() {
292
557
  return {
293
558
  questions: [{
@@ -302,6 +567,56 @@ function answerMetadataSchema() {
302
567
  };
303
568
  }
304
569
 
570
+ function questionRevisionSchema() {
571
+ return {
572
+ title: 'string',
573
+ personalizationBasis: ['string'],
574
+ revisionSummary: 'string',
575
+ questions: [{
576
+ id: 'q1',
577
+ stem: 'plain text with LaTeX delimiters; only question',
578
+ questionKind: 'choice|blank|short_answer',
579
+ difficulty: 'basic|medium|challenge',
580
+ knowledgePointIds: ['chapter-01-kp-01'],
581
+ knowledgePoints: ['plain Chinese tag label, no LaTeX delimiters or HTML'],
582
+ expectedErrorTypes: ['plain Chinese tag label, no LaTeX delimiters or HTML'],
583
+ abilityIds: ['calculation_accuracy'],
584
+ skillAtoms: ['plain Chinese calculation skill atom'],
585
+ expectedAbilityErrors: ['plain Chinese ability error label, no LaTeX delimiters or HTML'],
586
+ svg: 'optional inline SVG string',
587
+ imagePrompt: 'optional gpt-image-2 prompt',
588
+ score: 6,
589
+ answerSpaceLines: 2
590
+ }]
591
+ };
592
+ }
593
+
594
+ function compactFinding(finding) {
595
+ return [
596
+ finding.questionId || 'global',
597
+ finding.type || 'unknown',
598
+ finding.message || '',
599
+ finding.fixInstruction ? `修复:${finding.fixInstruction}` : ''
600
+ ].filter(Boolean).join(' - ');
601
+ }
602
+
603
+ function blockerDetail(findings) {
604
+ return (findings || []).map(compactFinding).join(' | ');
605
+ }
606
+
607
+ function blockerMessage(findings, prefix = '最终复审仍有 blocker') {
608
+ const list = (findings || []).map(compactFinding);
609
+ if (!list.length) return prefix;
610
+ return `${prefix}:${list.slice(0, 4).join(';')}${list.length > 4 ? `;另 ${list.length - 4} 条` : ''}`;
611
+ }
612
+
613
+ function geometryVisualSampleCount(totalQuestionCount, configuredCount) {
614
+ const requested = Number(configuredCount);
615
+ if (Number.isFinite(requested) && requested > 0) return Math.floor(requested);
616
+ const total = Math.max(1, Number(totalQuestionCount || 0));
617
+ return Math.max(1, Math.min(4, Math.ceil(total / 4)));
618
+ }
619
+
305
620
  function mergeAnswerMetadata(questions, answerChunks) {
306
621
  const answersById = new Map();
307
622
  for (const chunk of answerChunks) {
@@ -342,6 +657,7 @@ function compactKnowledgePoint(point, mastery = null) {
342
657
  sectionTitle: point.sectionTitle,
343
658
  summary: point.summary,
344
659
  pitfalls: Array.isArray(point.pitfalls) ? point.pitfalls.slice(0, 3) : [],
660
+ teachingTips: point.teachingTips || null,
345
661
  masteryStatus: mastery?.status || 'not_started',
346
662
  masteryStats: mastery
347
663
  ? {
@@ -396,13 +712,33 @@ function recommendedQuestionCount(targetCount, maxQuestionCount) {
396
712
  return Math.max(3, Math.min(maxCount, Math.ceil(targetCount / 2)));
397
713
  }
398
714
 
399
- async function callPracticeAgent({ promptName, task, context, requirements, schema, timeoutMs = 120000, temperature = 0.2, retries = 2 }) {
715
+ function isGeometryLikeChapter(chapter) {
716
+ const text = `${chapter?.title || ''} ${chapter?.fullTitle || ''} ${chapter?.track || ''}`;
717
+ return /几何|图形|三角形|平行|垂直|圆|角|线段|直线|射线|展开图|立体/.test(text);
718
+ }
719
+
720
+ async function readPracticeSystemPrompt(promptName, context) {
400
721
  const systemPrompt = await readPrompt(promptName);
722
+ const geometryPromptNames = new Set([
723
+ 'practice-generate.system.md',
724
+ 'practice-review.system.md',
725
+ 'practice-revise.system.md'
726
+ ]);
727
+ if (!geometryPromptNames.has(promptName) || !isGeometryLikeChapter(context?.chapter)) {
728
+ return systemPrompt;
729
+ }
730
+ const geometryExperience = await readPrompt('geometry-practice-experience.md');
731
+ return `${systemPrompt}\n\n${geometryExperience}`;
732
+ }
733
+
734
+ async function callPracticeAgent({ promptName, task, context, requirements, schema, timeoutMs = 120000, temperature = 0.2, retries = 1 }) {
735
+ const systemPrompt = await readPracticeSystemPrompt(promptName, context);
736
+ const practiceRules = await readRules();
401
737
  return callChatAgent({
402
738
  timeoutMs,
403
739
  retries,
404
740
  temperature,
405
- system: systemPrompt,
741
+ system: `${systemPrompt}\n\n${practiceRules}`,
406
742
  user: promptPayload({
407
743
  task,
408
744
  context,
@@ -474,6 +810,11 @@ async function generationHistory(chapterId) {
474
810
  export async function generatePracticeContent(input) {
475
811
  const { profile, chapter, options } = input;
476
812
  const onProgress = progressReporter(input.onProgress);
813
+ const stageCacheDir = options.generationCacheDir || '';
814
+ const fastReview = Boolean(options.fastReview);
815
+ const verifySvgFigures = Boolean(options.verifySvgFigures);
816
+ const requireSvg = Boolean(options.requireSvg);
817
+ const minSvgQuestions = Number(options.minSvgQuestions || 0);
477
818
  onProgress({ step: 'practice_generate.history', message: '正在整理历史题目和近期错因。' });
478
819
  const history = await generationHistory(chapter.id);
479
820
  const knowledgeDoc = options.knowledgeDoc;
@@ -559,6 +900,7 @@ export async function generatePracticeContent(input) {
559
900
  history
560
901
  };
561
902
  const isAbilityAssessment = options.type === 'ability_assessment';
903
+ const isMistakeRepair = options.type === 'mistake_repair';
562
904
  const baseRequirements = [
563
905
  '全部内容只能由 LLM 生成,不能使用本地模板或规则兜底。',
564
906
  '题目之间不能重复,也不要和 previousStems 高度相似。',
@@ -572,7 +914,10 @@ export async function generatePracticeContent(input) {
572
914
  ? '本次是指定知识点范围覆盖,只能围绕 targetKnowledgePoints 出题,不要覆盖范围外知识点;必须用少题高覆盖策略,一道题可以覆盖多个紧密相关知识点。'
573
915
  : isAbilityAssessment
574
916
  ? '本次是能力评估卷:目标来自 abilitySpecs 与本章知识范围,题目应围绕能力表现取样,不要机械追求知识点全覆盖。'
575
- : '本次是章节知识点覆盖卷,不要要求用户提供知识点编号;首轮覆盖尚未完成时,knowledge_coverage 必须优先从 previousKnowledgePointIds 没有覆盖过的 targetKnowledgePoints 出题;已覆盖点只做必要合并抽样。首轮完成后,再按 needs_review、wrongCount 高和 recentMistakes 命中的点优先。',
917
+ : isMistakeRepair
918
+ ? '本次是易错点覆盖/巩固卷:优先围绕 recentMistakes、needs_review、wrongCount 高的知识点和易错类型设计题目;如果当前没有可用易错点,则生成基础巩固卷,但题目仍要体现常见易错辨析,不要伪装成首轮知识点覆盖。'
919
+ : '本次是章节知识点覆盖卷,不要要求用户提供知识点编号;首轮覆盖尚未完成时,knowledge_coverage 必须优先从 previousKnowledgePointIds 没有覆盖过的 targetKnowledgePoints 出题;已覆盖点只做必要合并抽样。首轮完成后,再按 needs_review、wrongCount 高和 recentMistakes 命中的点优先。',
920
+ '每个 targetKnowledgePoint 可能包含 teachingTips,其中 commonMisconceptions 是孩子常见的具体误解,checkUnderstandingQuestions 是检查是否真正理解的问法。设计题目时,利用 commonMisconceptions 来设计选择题的干扰项或陷阱条件,让题目能有效暴露这些误解。',
576
921
  isAbilityAssessment
577
922
  ? '能力评估第一版只评估计算准确性,不纳入用时、速度、限时达标;题目以短题、多题、低语境为主,尽量暴露计算准确性和常见计算错误。'
578
923
  : '',
@@ -582,22 +927,25 @@ export async function generatePracticeContent(input) {
582
927
  isAbilityAssessment
583
928
  ? 'skillAtoms 和 expectedAbilityErrors 是讲解版胶囊标签,只能写纯中文短标签;禁止使用 $...$、\\(...\\)、\\[...\\]、HTML 或复杂公式包装。'
584
929
  : '',
585
- 'questionKind auto 时,由你根据知识点覆盖需要自主组合 choice、blank、short_answer;choice 必须给出 4 个选项;blank 适合概念/公式/结果检测;short_answer 适合方法、辨析和计算步骤检测。',
586
- '每题填写 questionKind 字段,值只能是 choice、blank 或 short_answer。',
587
- '每题必须填写 knowledgePointIds,且 id 必须来自 targetKnowledgePoints;一题可填写多个知识点 ID。',
588
- 'knowledgePoints expectedErrorTypes 是讲解版胶囊标签,只能写纯中文短标签;禁止使用 $...$、\\(...\\)、\\[...\\]、HTML 或复杂公式包装。例如写“特殊值 0 误判”,不要写“特殊值 $0$ 误判”。',
589
- '题干只放题目,不放解题过程。',
590
- '可以在题干中使用 Markdown 表格表达分类、对照或数据,但不要使用 HTML table 标签。',
591
- '题目使用纯文本加 LaTeX 分隔符;只有几何、数轴或图形题必要时才使用内联 SVG 或 imagePrompt。',
592
- 'LaTeX 命令必须保留完整反斜杠,例如 \\triangle、\\angle、\\perp、\\parallel、\\text{};禁止输出 triangle/riangle/angle/perp/parallel/ext{} 或把 \\t 变成制表符。',
593
- '几何、数轴或 SVG 图形题必须保证题干、图中标签、角弧、垂直/平行/长度标注完全一致;如果题干问 angle 2、切点、垂足或某条边,图中对应位置必须准确。',
594
- 'SVG 标签必须可读且不遮挡线段、角弧、表格或答题区;优先使用简单清晰的线图。',
595
- '题面不给学生显示分值,不要在题干里写“本题多少分”。',
930
+ '遵守通用出题规则 R01-R20(已自动加载到 system prompt)。',
931
+ isAbilityAssessment || isMistakeRepair ? '' : (() => {
932
+ const trend = profile.learningState?.recentAccuracyTrend || 'stable';
933
+ const fatigue = profile.learningState?.lastSessionFatigue || 'medium';
934
+ const frustrationTopics = profile.learningState?.frustrationTopics || [];
935
+ const parts = [];
936
+ if (trend === 'declining') parts.push('最近正确率呈下降趋势,建议增加基础题比例(≥70%),降低题目难度,优先让孩子重建信心。');
937
+ else if (trend === 'improving') parts.push('最近正确率呈上升趋势,保持当前节奏,可以在最后 1-2 题适当增加挑战。');
938
+ if (fatigue === 'high') parts.push('孩子可能处于疲劳状态,建议本次练习题量偏少、题目偏简单,多给正向反馈型题目。');
939
+ if (frustrationTopics.length) parts.push(`以下知识点孩子已连续多次出错,设计相关题目时应降低难度、拆成更小步骤,或先换其他知识点避免连续打击:${frustrationTopics.slice(0, 3).join('、')}。`);
940
+ return parts.join(' ');
941
+ })(),
596
942
  '第一阶段只生成题面、题型、知识点绑定、预期错因、分值和留白行数;answer、solutionSteps、rubric 会在题目定稿后由第二阶段分批补全。'
597
943
  ].filter(Boolean);
598
944
  onProgress({
599
945
  step: 'practice_generate.plan',
600
- message: `正在分析首轮覆盖策略:已覆盖 ${firstRoundCoveredIds.length}/${compactTargetKnowledgePoints.length} 个知识点,本次候选 ${targetKnowledgePoints.length} 个。`
946
+ message: isMistakeRepair
947
+ ? `正在分析易错巩固策略:当前可用易错点 ${history.recentMistakes.length} 个,本次候选知识点 ${targetKnowledgePoints.length} 个。`
948
+ : `正在分析首轮覆盖策略:已覆盖 ${firstRoundCoveredIds.length}/${compactTargetKnowledgePoints.length} 个知识点,本次候选 ${targetKnowledgePoints.length} 个。`
601
949
  });
602
950
  const questionGenerationRequirements = [
603
951
  ...baseRequirements,
@@ -613,7 +961,8 @@ export async function generatePracticeContent(input) {
613
961
  '不要输出 answer、solutionSteps 或 rubric;本阶段只定稿题目本身和知识点绑定。'
614
962
  ];
615
963
  const totalQuestionCount = options.questionCount || localRecommendedQuestionCount;
616
- const questionTargetChunks = chunkArray(targetKnowledgePoints, 3);
964
+ const questionTargetChunkSize = totalQuestionCount <= 10 ? 4 : 5;
965
+ const questionTargetChunks = chunkArray(targetKnowledgePoints, questionTargetChunkSize);
617
966
  const questionBatchCounts = distributeQuestionCounts(questionTargetChunks, totalQuestionCount);
618
967
  const questionChunks = [];
619
968
  const promptName = 'practice-generate.system.md';
@@ -646,7 +995,10 @@ export async function generatePracticeContent(input) {
646
995
  step: 'practice_generate.questions',
647
996
  message: `正在生成题目草稿:第 ${index + 1}/${questionTargetChunks.length} 批(${batchQuestionCount} 题,约 ${Math.round(requestChars / 1000)}KB 请求)。`
648
997
  });
649
- const chunk = assertAgentQuestions(await callPracticeAgent({
998
+ const chunk = assertAgentQuestions(await callPracticeAgentCached({
999
+ cacheDir: stageCacheDir,
1000
+ cacheFile: `question-chunk-${String(index + 1).padStart(2, '0')}.json`,
1001
+ onProgress,
650
1002
  promptName,
651
1003
  task: batchTask,
652
1004
  context: batchContext,
@@ -699,21 +1051,22 @@ export async function generatePracticeContent(input) {
699
1051
  source: 'agent'
700
1052
  });
701
1053
  onProgress({ step: 'practice_generate.review', message: '正在评审题目可作答性、覆盖规划和知识点绑定。' });
702
- const review = await callPracticeAgent({
1054
+ const review = await callPracticeAgentCached({
1055
+ cacheDir: stageCacheDir,
1056
+ cacheFile: 'initial-review.json',
1057
+ onProgress,
703
1058
  promptName: 'practice-review.system.md',
704
1059
  task: '评审练习题目草稿和知识点绑定;本阶段不评审答案元数据',
705
1060
  context: {
706
1061
  ...contextForQuestions(baseContext, practiceDraft.questions),
707
- practiceDraft
1062
+ practiceDraft: omitSvgSourcesForTextReview(practiceDraft)
708
1063
  },
709
1064
  requirements: [
710
1065
  '至少完成一次 LLM review;发现问题必须给出具体修正指令。',
1066
+ '遵守通用规则 R01-R20(已自动加载到 system prompt),逐条检查题目是否符合规范。',
711
1067
  '评审题目质量、可作答性、题量规划和 knowledgePointIds。',
712
- '必须检查 knowledgePoints、expectedErrorTypes、skillAtoms、expectedAbilityErrors 是否为纯文本标签;出现 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装时必须要求修正。',
713
1068
  isAbilityAssessment ? '能力评估题必须检查 abilityIds 是否完整且只来自 context.options.abilityIds。' : '',
714
- '如果题目缺少必要条件、答案无法唯一推出、选项不完整或题型不合适,必须判 blocker。',
715
- '必须检查 LaTeX 反斜杠是否损坏,尤其是 \\triangle、\\angle、\\perp、\\parallel、\\text{};发现丢反斜杠或制表符残留必须判 blocker。',
716
- '必须检查几何/SVG 题的题干与图中角标、垂足、切点、边名、长度和平行/垂直标注是否一致;不一致必须判 blocker。'
1069
+ '如果题目缺少必要条件、答案无法唯一推出、选项不完整或题型不合适,必须判 blocker。'
717
1070
  ].filter(Boolean),
718
1071
  schema: {
719
1072
  passed: false,
@@ -740,7 +1093,20 @@ export async function generatePracticeContent(input) {
740
1093
  message: `质量评审完成:${review.data.findings.length} 条意见,开始修订题目。`
741
1094
  });
742
1095
  const revisionChunks = [];
743
- const questionRevisionChunks = chunkArray(practiceDraft.questions, 3);
1096
+ const globalReviewFindings = (review.data.findings || []).filter((finding) => !finding.questionId);
1097
+ const questionIdsNeedingRevision = new Set((review.data.findings || [])
1098
+ .map((finding) => finding.questionId)
1099
+ .filter(Boolean));
1100
+ const questionsToRevise = globalReviewFindings.length
1101
+ ? practiceDraft.questions
1102
+ : practiceDraft.questions.filter((question) => questionIdsNeedingRevision.has(question.id));
1103
+ const questionRevisionChunks = chunkArray(questionsToRevise, 4);
1104
+ if (!questionRevisionChunks.length) {
1105
+ onProgress({
1106
+ step: 'practice_generate.revision_skip',
1107
+ message: '评审未提出逐题修改意见,跳过 LLM 修订阶段。'
1108
+ });
1109
+ }
744
1110
  for (let index = 0; index < questionRevisionChunks.length; index += 1) {
745
1111
  const questions = questionRevisionChunks[index];
746
1112
  const questionIds = questions.map((question) => question.id);
@@ -750,14 +1116,17 @@ export async function generatePracticeContent(input) {
750
1116
  step: 'practice_generate.revision',
751
1117
  message: `正在修订题目:第 ${index + 1}/${questionRevisionChunks.length} 批(${questionIds.join('、')})。`
752
1118
  });
753
- const revisionChunk = assertAgentQuestions(await callPracticeAgent({
1119
+ const revisionChunk = assertAgentQuestions(await callPracticeAgentCached({
1120
+ cacheDir: stageCacheDir,
1121
+ cacheFile: `revision-chunk-${String(index + 1).padStart(2, '0')}.json`,
1122
+ onProgress,
754
1123
  promptName: 'practice-revise.system.md',
755
1124
  task: `根据评审意见修正练习卷并输出修订版(第 ${index + 1}/${questionRevisionChunks.length} 批)`,
756
1125
  context: {
757
1126
  ...contextForQuestions(baseContext, questions),
758
1127
  practiceDraft: {
759
1128
  ...practiceDraft,
760
- questions
1129
+ questions: questions.map(omitSvgSourceForTextReview)
761
1130
  },
762
1131
  review: {
763
1132
  ...review.data,
@@ -766,42 +1135,22 @@ export async function generatePracticeContent(input) {
766
1135
  },
767
1136
  requirements: [
768
1137
  '必须根据 review 修正后输出最终题目。',
1138
+ '遵守通用规则 R01-R20(已自动加载到 system prompt)。',
769
1139
  '只输出 practiceDraft.questions 中的题目,不新增题目,不删除题目,不改变题目 ID。',
770
1140
  '修订版必须包含题目、题型、难度、knowledgePointIds、knowledgePoints、expectedErrorTypes、配图字段和答题空间行数。',
771
1141
  isAbilityAssessment ? '能力评估修订版还必须保留 abilityIds、skillAtoms、expectedAbilityErrors。' : '',
772
- 'knowledgePoints 和 expectedErrorTypes 必须是纯文本短标签,不能包含 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装。',
773
- isAbilityAssessment ? 'skillAtoms 和 expectedAbilityErrors 必须是纯文本短标签,不能包含 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装。' : '',
774
1142
  '不要输出 answer、solutionSteps 或 rubric;这些字段会在题目定稿后分批补全。',
775
- '必须修复 LaTeX 反斜杠损坏,并同步校准几何/SVG 题的题干与图中角标、垂足、切点、边名、长度和平行/垂直标注。',
776
1143
  '不要输出未修正草稿。'
777
1144
  ].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
- }
1145
+ schema: questionRevisionSchema()
799
1146
  }), 'revision');
800
1147
  revisionChunks.push(revisionChunk);
801
1148
  }
802
- const revisionQuestions = revisionChunks.flatMap((chunk) => chunk.questions);
1149
+ const revisionQuestions = questionRevisionChunks.length
1150
+ ? mergeRevisionQuestions(practiceDraft.questions, revisionChunks.flatMap((chunk) => chunk.questions))
1151
+ : practiceDraft.questions;
803
1152
  const firstRevision = revisionChunks[0] || {};
804
- const revision = {
1153
+ let revision = {
805
1154
  title: normalizePracticeTitle(firstRevision.title || practiceDraft.title),
806
1155
  personalizationBasis: firstRevision.personalizationBasis || practiceDraft.personalizationBasis || [],
807
1156
  revisionSummary: revisionChunks
@@ -811,42 +1160,136 @@ export async function generatePracticeContent(input) {
811
1160
  questions: revisionQuestions
812
1161
  };
813
1162
  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
- }
1163
+ let revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1164
+ if (!isAbilityAssessment && revisionCoverage.missingPointIds.length) {
1165
+ const missingPointLabels = revisionCoverage.missingPointIds.join('、');
1166
+ const missingPoints = targetKnowledgePoints.filter((point) => revisionCoverage.missingPointIds.includes(point.id));
1167
+ onProgress({
1168
+ step: 'practice_generate.coverage_repair',
1169
+ message: `正在修复知识点覆盖缺口:${missingPointLabels}。`
1170
+ });
1171
+ try {
1172
+ const repairResult = await callPracticeAgentCached({
1173
+ cacheDir: stageCacheDir,
1174
+ cacheFile: 'coverage-repair.json',
1175
+ onProgress,
1176
+ promptName: 'practice-coverage-repair.system.md',
1177
+ task: '在不新增题目、不删除题目的前提下修复定稿题目的知识点覆盖缺口',
1178
+ context: {
1179
+ ...baseContext,
1180
+ practiceDraft: omitSvgSourcesForTextReview(revision),
1181
+ localCoverageCheck: revisionCoverage,
1182
+ missingTargetKnowledgePoints: missingPoints
1183
+ },
1184
+ requirements: [
1185
+ '遵守通用规则 R01-R20(已自动加载到 system prompt)。',
1186
+ '只修复覆盖缺口,不新增题目,不删除题目,不改变题目 ID。',
1187
+ `必须让修订后的 questions 覆盖这些 missingPointIds:${missingPointLabels}。`,
1188
+ '优先选择最相关的已有题目进行小幅改写或补充 knowledgePointIds;不能硬塞不相关知识点。',
1189
+ '如果题面需要随知识点绑定变化而调整,必须同步改题干,保证学生可作答且答案唯一。',
1190
+ '不要输出 answer、solutionSteps 或 rubric;这些字段会在题目定稿后补全。'
1191
+ ],
1192
+ schema: questionRevisionSchema(),
1193
+ retries: 2
1194
+ });
1195
+ const repair = assertAgentQuestions(repairResult, 'coverage_repair');
1196
+ revision = {
1197
+ ...revision,
1198
+ title: normalizePracticeTitle(repair.title || revision.title),
1199
+ personalizationBasis: repair.personalizationBasis || revision.personalizationBasis || [],
1200
+ revisionSummary: [revision.revisionSummary, repair.revisionSummary]
1201
+ .filter(Boolean)
1202
+ .join('\n'),
1203
+ questions: mergeRevisionQuestions(revision.questions, repair.questions)
1204
+ };
1205
+ revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1206
+ onProgress({
1207
+ step: 'practice_generate.coverage_repair_done',
1208
+ message: `覆盖修复完成:已覆盖 ${revisionCoverage.coveredCount}/${revisionCoverage.intendedCount} 个目标点。`
1209
+ });
1210
+ } catch (error) {
1211
+ repairSkipped = true;
1212
+ onProgress({
1213
+ step: 'practice_generate.coverage_repair_skipped',
1214
+ message: `覆盖缺口修复未成功,跳过:${missingPointLabels}。当前卷已覆盖 ${revisionCoverage.coveredCount}/${revisionCoverage.intendedCount} 个点。建议改用显式 knowledgePointIds 指定目标点重新生成以覆盖剩余缺口。`,
1215
+ detail: error.message
1216
+ });
848
1217
  }
849
- });
1218
+ }
1219
+ const shouldRunSvgVerification = verifySvgFigures || requireSvg || minSvgQuestions > 0;
1220
+ if (shouldRunSvgVerification) {
1221
+ const svgSelection = requireSvg
1222
+ ? {
1223
+ questionIds: revision.questions.map((question) => question.id),
1224
+ strictQuestionIds: revision.questions.map((question) => question.id)
1225
+ }
1226
+ : await selectQuestionsForSvgReview({
1227
+ stageCacheDir,
1228
+ onProgress,
1229
+ baseContext,
1230
+ revision,
1231
+ minSvgQuestions
1232
+ });
1233
+ onProgress({
1234
+ step: 'practice_generate.svg_verify',
1235
+ message: requireSvg
1236
+ ? '正在逐题渲染并审查 SVG 配图;本次要求每题都必须有图。'
1237
+ : `正在渲染并审查 SVG 配图:${svgSelection.questionIds.join('、') || '已有配图'}。`
1238
+ });
1239
+ revision = {
1240
+ ...revision,
1241
+ questions: await verifyAndRepairSvgFigures({
1242
+ questions: revision.questions,
1243
+ context: {
1244
+ chapter,
1245
+ targetKnowledgePoints
1246
+ },
1247
+ workDir: path.join(stageCacheDir || chapterDataPaths(chapter.id).context, 'svg-renders'),
1248
+ requireSvg,
1249
+ requiredQuestionIds: svgSelection.questionIds,
1250
+ strictQuestionIds: svgSelection.strictQuestionIds,
1251
+ maxRounds: 3,
1252
+ onProgress
1253
+ })
1254
+ };
1255
+ revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1256
+ onProgress({
1257
+ step: 'practice_generate.svg_verify_done',
1258
+ message: 'SVG 渲染审查完成,继续复审题目质量。'
1259
+ });
1260
+ }
1261
+ let finalReview = null;
1262
+ const reviewBlockers = (review.data.findings || []).filter((finding) => finding.level === 'blocker');
1263
+ const canSkipFinalReview = fastReview
1264
+ && reviewBlockers.length === 0
1265
+ && revisionCoverage.outsidePointIds.length === 0
1266
+ && (isAbilityAssessment || revisionCoverage.missingPointIds.length === 0);
1267
+ if (canSkipFinalReview) {
1268
+ finalReview = {
1269
+ ok: true,
1270
+ data: {
1271
+ passed: true,
1272
+ summary: 'fast_review 模式:初评无 blocker,且本地覆盖检查通过,跳过 LLM 定稿复审。',
1273
+ findings: [],
1274
+ coverage: revisionCoverage,
1275
+ source: 'fast_review_skip'
1276
+ },
1277
+ attempts: 0
1278
+ };
1279
+ onProgress({ step: 'practice_generate.final_review_skip', message: '快速回归模式下跳过定稿 LLM 复审。' });
1280
+ } else {
1281
+ onProgress({ step: 'practice_generate.final_review', message: '正在复审题目质量和重复风险。' });
1282
+ finalReview = await runFinalReview({
1283
+ stageCacheDir,
1284
+ cacheFile: 'final-review.json',
1285
+ onProgress,
1286
+ baseContext,
1287
+ revision,
1288
+ revisionCoverage,
1289
+ initialReview: review.data,
1290
+ isAbilityAssessment
1291
+ });
1292
+ }
850
1293
  if (!finalReview.ok || !Array.isArray(finalReview.data?.findings)) {
851
1294
  const attempts = finalReview.attempts ? ` attempts=${finalReview.attempts}` : '';
852
1295
  throw generationError(`final_review_${finalReview.reason || 'invalid_agent_response'}`, `${finalReview.detail || ''}${attempts}`);
@@ -857,18 +1300,92 @@ export async function generatePracticeContent(input) {
857
1300
  if (!isAbilityAssessment && revisionCoverage.missingPointIds.length) {
858
1301
  throw generationError('coverage_missing_targets', revisionCoverage.missingPointIds.join(','));
859
1302
  }
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
- );
1303
+ const maxFinalBlockerRepairRounds = 3;
1304
+ for (let repairRound = 1; ; repairRound += 1) {
1305
+ const finalBlockers = (finalReview.data.findings || [])
1306
+ .filter((finding) => finding.level === 'blocker')
1307
+ .filter((finding) => finding.type !== 'bad_coverage');
1308
+ if (!finalBlockers.length) break;
1309
+ if (repairRound > maxFinalBlockerRepairRounds) {
1310
+ onProgress({
1311
+ step: 'practice_generate.final_review_failed',
1312
+ message: blockerMessage(finalBlockers, '自动修订后仍有终审 blocker')
1313
+ });
1314
+ throw generationError('final_review_blockers', blockerDetail(finalBlockers));
1315
+ }
1316
+ onProgress({
1317
+ step: 'practice_generate.final_review_blockers',
1318
+ message: blockerMessage(finalBlockers, `最终复审发现 blocker,正在自动修订(第 ${repairRound}/${maxFinalBlockerRepairRounds} 轮)`)
1319
+ });
1320
+ revision = await repairFinalBlockers({
1321
+ stageCacheDir,
1322
+ onProgress,
1323
+ baseContext,
1324
+ revision,
1325
+ finalBlockers,
1326
+ isAbilityAssessment,
1327
+ repairRound
1328
+ });
1329
+ revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1330
+ if (revisionCoverage.outsidePointIds.length) {
1331
+ throw generationError('coverage_outside_target_after_final_repair', revisionCoverage.outsidePointIds.join(','));
1332
+ }
1333
+ if (!isAbilityAssessment && revisionCoverage.missingPointIds.length) {
1334
+ throw generationError('coverage_missing_targets_after_final_repair', revisionCoverage.missingPointIds.join(','));
1335
+ }
1336
+ if (shouldRunSvgVerification) {
1337
+ const svgSelection = requireSvg
1338
+ ? {
1339
+ questionIds: revision.questions.map((question) => question.id),
1340
+ strictQuestionIds: revision.questions.map((question) => question.id)
1341
+ }
1342
+ : await selectQuestionsForSvgReview({
1343
+ stageCacheDir,
1344
+ onProgress,
1345
+ baseContext,
1346
+ revision,
1347
+ minSvgQuestions
1348
+ });
1349
+ revision = {
1350
+ ...revision,
1351
+ questions: await verifyAndRepairSvgFigures({
1352
+ questions: revision.questions,
1353
+ context: {
1354
+ chapter,
1355
+ targetKnowledgePoints
1356
+ },
1357
+ workDir: path.join(stageCacheDir || chapterDataPaths(chapter.id).context, 'svg-renders'),
1358
+ requireSvg,
1359
+ requiredQuestionIds: svgSelection.questionIds,
1360
+ strictQuestionIds: svgSelection.strictQuestionIds,
1361
+ maxRounds: 3,
1362
+ onProgress
1363
+ })
1364
+ };
1365
+ revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1366
+ }
1367
+ onProgress({
1368
+ step: 'practice_generate.final_review_retry',
1369
+ message: `终审 blocker 第 ${repairRound} 轮修订完成,正在重新复审。`
1370
+ });
1371
+ finalReview = await runFinalReview({
1372
+ stageCacheDir,
1373
+ cacheFile: `final-review-after-blocker-repair-${String(repairRound).padStart(2, '0')}.json`,
1374
+ onProgress,
1375
+ baseContext,
1376
+ revision,
1377
+ revisionCoverage,
1378
+ initialReview: review.data,
1379
+ isAbilityAssessment
1380
+ });
1381
+ if (!finalReview.ok || !Array.isArray(finalReview.data?.findings)) {
1382
+ const attempts = finalReview.attempts ? ` attempts=${finalReview.attempts}` : '';
1383
+ throw generationError(`final_review_retry_${finalReview.reason || 'invalid_agent_response'}`, `${finalReview.detail || ''}${attempts}`);
1384
+ }
868
1385
  }
869
1386
  onProgress({ step: 'practice_generate.final_review_done', message: `最终复审通过:${finalReview.data.findings.length} 条提示。` });
870
1387
  const answerChunks = [];
871
- const answerQuestionChunks = chunkArray(revision.questions, 3);
1388
+ const answerQuestionChunks = chunkArray(revision.questions, 4);
872
1389
  for (let index = 0; index < answerQuestionChunks.length; index += 1) {
873
1390
  const questions = answerQuestionChunks[index];
874
1391
  const questionIds = questions.map((question) => question.id);
@@ -876,15 +1393,19 @@ export async function generatePracticeContent(input) {
876
1393
  step: 'practice_generate.answers',
877
1394
  message: `正在生成标准答案和讲解:第 ${index + 1}/${answerQuestionChunks.length} 批(${questionIds.join('、')})。`
878
1395
  });
879
- const answerChunk = assertAgentQuestions(await callPracticeAgent({
880
- promptName: 'practice-generate.system.md',
1396
+ const answerChunk = assertAgentQuestions(await callPracticeAgentCached({
1397
+ cacheDir: stageCacheDir,
1398
+ cacheFile: `answer-chunk-${String(index + 1).padStart(2, '0')}.json`,
1399
+ onProgress,
1400
+ promptName: 'practice-answers.system.md',
881
1401
  task: `为定稿题目补全答案元数据(第 ${index + 1}/${answerQuestionChunks.length} 批)`,
882
1402
  context: contextForAnswerMetadata(baseContext, questions),
883
1403
  requirements: [
884
1404
  '只输出输入 questions 中已有题目的 id、answer、solutionSteps、rubric。',
885
1405
  '不得修改题目 ID、题干、题型、难度、knowledgePointIds、knowledgePoints、expectedErrorTypes、abilityIds、skillAtoms、expectedAbilityErrors、score、answerSpaceLines、svg 或 imagePrompt。',
886
- 'answer 必须是可核对的标准答案;选择题写正确选项和结论,填空/问答题写完整答案。',
887
- 'solutionSteps 必须是 2-5 步中文解题过程,能够解释孩子错在哪里。',
1406
+ 'answer 必须是可核对的标准答案;选择题写正确选项和简短结论,填空/问答题写完整答案。',
1407
+ 'solutionSteps 分级输出:简单选择题/填空题只写 1 条简短说明(考察什么、关键辨析点);需要推导、几何观察或多步判断的题写 2-4 条解题要点。',
1408
+ 'solutionSteps 不要重复标准答案,不要写成批改标准;语言要像给孩子看的讲解。',
888
1409
  'rubric 必须可用于批改,至少包含关键结论和关键过程;各项 score 之和应接近题目 score。',
889
1410
  '公式继续使用 LaTeX 分隔符,并保留完整反斜杠。'
890
1411
  ],