@zhouchangui/math-ati 0.1.3 → 0.1.5

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.
@@ -3,7 +3,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { chapterDataPaths, isActionableMistake, readChapterMistakes, readJson } from './fileStore.js';
5
5
  import { flattenKnowledgePoints } from './knowledgeBase.js';
6
- import { promptPayload, readPrompt } from './promptStore.js';
6
+ import { promptPayload, readPrompt, readRules } from './promptStore.js';
7
7
  import { ABILITY_CATALOG } from './abilityService.js';
8
8
  import { verifyAndRepairSvgFigures } from './svgFigureVerifier.js';
9
9
 
@@ -241,23 +241,8 @@ function compactPreviousStem(stem) {
241
241
  }
242
242
 
243
243
  function contextForAnswerMetadata(baseContext, questions) {
244
- const usedIds = new Set();
245
- for (const question of questions || []) {
246
- for (const pointId of question.knowledgePointIds || []) usedIds.add(pointId);
247
- }
248
244
  return {
249
245
  chapter: baseContext.chapter,
250
- options: {
251
- type: baseContext.options.type,
252
- questionCount: baseContext.options.questionCount,
253
- difficulty: baseContext.options.difficulty,
254
- questionKind: baseContext.options.questionKind,
255
- knowledgePointIds: [...usedIds],
256
- abilityIds: baseContext.options.abilityIds || []
257
- },
258
- abilitySpecs: baseContext.abilitySpecs || [],
259
- targetKnowledgePoints: baseContext.targetKnowledgePoints
260
- .filter((point) => usedIds.size === 0 || usedIds.has(point.id)),
261
246
  questions
262
247
  };
263
248
  }
@@ -471,13 +456,12 @@ async function runFinalReview({
471
456
  initialReview
472
457
  },
473
458
  requirements: [
474
- '这是定稿复审;评审题目质量、可作答性、重复风险、题量规划和 knowledgePointIds。',
459
+ '这是定稿复审;遵守通用规则 R01-R20(已自动加载到 system prompt)。',
460
+ '评审题目质量、可作答性、重复风险、题量规划和 knowledgePointIds。',
475
461
  '如果仍有 blocker,必须 passed=false,并指出具体题号和修正原因。',
476
462
  '必须拦截题目条件缺失、答案不唯一、选项不完整、知识点绑定不在 targetKnowledgePoints 中的问题。',
477
463
  '覆盖判断必须以 context.localCoverageCheck 为准;只有 missingPointIds 或 outsidePointIds 非空时,才能给 bad_coverage blocker。',
478
- '必须检查 knowledgePoints、expectedErrorTypes、skillAtoms、expectedAbilityErrors 是否为纯文本标签;出现 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装时要求修正。',
479
- isAbilityAssessment ? '能力评估题必须检查 abilityIds 是否完整且只来自 context.options.abilityIds。' : '',
480
- '定稿复审必须拦截 LaTeX 反斜杠损坏、制表符残留、几何/SVG 标注与题干不一致、标签遮挡关键条件。'
464
+ isAbilityAssessment ? '能力评估题必须检查 abilityIds 是否完整且只来自 context.options.abilityIds。' : ''
481
465
  ].filter(Boolean),
482
466
  schema: {
483
467
  passed: false,
@@ -543,6 +527,7 @@ async function repairFinalBlockers({
543
527
  },
544
528
  requirements: [
545
529
  '必须逐条修复终审 blocker,不能只复述问题。',
530
+ '遵守通用规则 R01-R20(已自动加载到 system prompt)。',
546
531
  '如果 blocker 类型是 bad_options、unanswerable 或指出答案不唯一,必须先保证题目存在唯一、明确、可判分的答案;选择题必须确保四个选项中恰好一个正确。',
547
532
  '遇到选项唯一性问题时,不要只微调一个模糊选项;如果不能确信唯一正确,必须重写整道题的题干和全部选项,必要时改成填空题或简答题。',
548
533
  '正方体展开图等视觉题不要使用“可能、并非明显、也许可以”这类不确定条件作为错误选项;错误选项必须是概念上明确错误或与给定图形明确矛盾。',
@@ -672,6 +657,7 @@ function compactKnowledgePoint(point, mastery = null) {
672
657
  sectionTitle: point.sectionTitle,
673
658
  summary: point.summary,
674
659
  pitfalls: Array.isArray(point.pitfalls) ? point.pitfalls.slice(0, 3) : [],
660
+ teachingTips: point.teachingTips || null,
675
661
  masteryStatus: mastery?.status || 'not_started',
676
662
  masteryStats: mastery
677
663
  ? {
@@ -728,7 +714,7 @@ function recommendedQuestionCount(targetCount, maxQuestionCount) {
728
714
 
729
715
  function isGeometryLikeChapter(chapter) {
730
716
  const text = `${chapter?.title || ''} ${chapter?.fullTitle || ''} ${chapter?.track || ''}`;
731
- return /几何|图形|三角形|平行|垂直|圆|角|线段|直线|射线|展开图|立体/.test(text);
717
+ return /几何|图形|三角形|平行|垂直|圆|线段|直线|射线|展开图|立体/.test(text);
732
718
  }
733
719
 
734
720
  async function readPracticeSystemPrompt(promptName, context) {
@@ -747,11 +733,12 @@ async function readPracticeSystemPrompt(promptName, context) {
747
733
 
748
734
  async function callPracticeAgent({ promptName, task, context, requirements, schema, timeoutMs = 120000, temperature = 0.2, retries = 1 }) {
749
735
  const systemPrompt = await readPracticeSystemPrompt(promptName, context);
736
+ const practiceRules = await readRules();
750
737
  return callChatAgent({
751
738
  timeoutMs,
752
739
  retries,
753
740
  temperature,
754
- system: systemPrompt,
741
+ system: `${systemPrompt}\n\n${practiceRules}`,
755
742
  user: promptPayload({
756
743
  task,
757
744
  context,
@@ -930,6 +917,7 @@ export async function generatePracticeContent(input) {
930
917
  : isMistakeRepair
931
918
  ? '本次是易错点覆盖/巩固卷:优先围绕 recentMistakes、needs_review、wrongCount 高的知识点和易错类型设计题目;如果当前没有可用易错点,则生成基础巩固卷,但题目仍要体现常见易错辨析,不要伪装成首轮知识点覆盖。'
932
919
  : '本次是章节知识点覆盖卷,不要要求用户提供知识点编号;首轮覆盖尚未完成时,knowledge_coverage 必须优先从 previousKnowledgePointIds 没有覆盖过的 targetKnowledgePoints 出题;已覆盖点只做必要合并抽样。首轮完成后,再按 needs_review、wrongCount 高和 recentMistakes 命中的点优先。',
920
+ '每个 targetKnowledgePoint 可能包含 teachingTips,其中 commonMisconceptions 是孩子常见的具体误解,checkUnderstandingQuestions 是检查是否真正理解的问法。设计题目时,利用 commonMisconceptions 来设计选择题的干扰项或陷阱条件,让题目能有效暴露这些误解。',
933
921
  isAbilityAssessment
934
922
  ? '能力评估第一版只评估计算准确性,不纳入用时、速度、限时达标;题目以短题、多题、低语境为主,尽量暴露计算准确性和常见计算错误。'
935
923
  : '',
@@ -939,19 +927,18 @@ export async function generatePracticeContent(input) {
939
927
  isAbilityAssessment
940
928
  ? 'skillAtoms 和 expectedAbilityErrors 是讲解版胶囊标签,只能写纯中文短标签;禁止使用 $...$、\\(...\\)、\\[...\\]、HTML 或复杂公式包装。'
941
929
  : '',
942
- 'questionKind auto 时,由你根据知识点覆盖需要自主组合 choice、blank、short_answer;choice 必须给出 4 个选项;blank 适合概念/公式/结果检测;short_answer 适合方法、辨析和计算步骤检测。',
943
- '每题填写 questionKind 字段,值只能是 choice、blank 或 short_answer。',
944
- '每题必须填写 knowledgePointIds,且 id 必须来自 targetKnowledgePoints;一题可填写多个知识点 ID。',
945
- 'knowledgePoints expectedErrorTypes 是讲解版胶囊标签,只能写纯中文短标签;禁止使用 $...$、\\(...\\)、\\[...\\]、HTML 或复杂公式包装。例如写“特殊值 0 误判”,不要写“特殊值 $0$ 误判”。',
946
- '题干只放题目,不放解题过程。',
947
- '可以在题干中使用 Markdown 表格表达分类、对照或数据,但不要使用 HTML table 标签。',
948
- '题目使用纯文本加 LaTeX 分隔符;出题时必须先判断题目是否需要视觉信息才能作答。',
949
- 'LaTeX 命令必须保留完整反斜杠,例如 \\triangle、\\angle、\\perp、\\parallel、\\text{};禁止输出 triangle/riangle/angle/perp/parallel/ext{} 或把 \\t 变成制表符。',
950
- '如果没有图,题干必须仍然信息完整、唯一可答、无需学生凭想象补出位置关系或图形结构;否则必须提供内联 SVG,或把题目改写成纯文字可答题。',
951
- '展开图、折叠、图形甲/乙、点线面位置、直线/射线/线段位置、角、垂直、平行、中点、延长线、交点数量、方格拼图等通常需要视觉判断,但这不是固定清单;最终以本题是否依赖视觉信息为准。',
952
- '几何、数轴或 SVG 图形题必须保证题干、图中标签、角弧、垂直/平行/长度标注完全一致;如果题干问 angle 2、切点、垂足、某条边或某个展开图,SVG 中对应位置必须准确。',
953
- 'SVG 标签必须可读且不遮挡线段、角弧、表格或答题区;优先使用简单清晰的线图。',
954
- '题面不给学生显示分值,不要在题干里写“本题多少分”。',
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
+ })(),
955
942
  '第一阶段只生成题面、题型、知识点绑定、预期错因、分值和留白行数;answer、solutionSteps、rubric 会在题目定稿后由第二阶段分批补全。'
956
943
  ].filter(Boolean);
957
944
  onProgress({
@@ -1076,13 +1063,10 @@ export async function generatePracticeContent(input) {
1076
1063
  },
1077
1064
  requirements: [
1078
1065
  '至少完成一次 LLM review;发现问题必须给出具体修正指令。',
1066
+ '遵守通用规则 R01-R20(已自动加载到 system prompt),逐条检查题目是否符合规范。',
1079
1067
  '评审题目质量、可作答性、题量规划和 knowledgePointIds。',
1080
- '必须检查 knowledgePoints、expectedErrorTypes、skillAtoms、expectedAbilityErrors 是否为纯文本标签;出现 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装时必须要求修正。',
1081
1068
  isAbilityAssessment ? '能力评估题必须检查 abilityIds 是否完整且只来自 context.options.abilityIds。' : '',
1082
- '如果题目缺少必要条件、答案无法唯一推出、选项不完整或题型不合适,必须判 blocker。',
1083
- '必须检查 LaTeX 反斜杠是否损坏,尤其是 \\triangle、\\angle、\\perp、\\parallel、\\text{};发现丢反斜杠或制表符残留必须判 blocker。',
1084
- '必须检查几何/SVG 题的题干与图中角标、垂足、切点、边名、长度和平行/垂直标注是否一致;不一致必须判 blocker。',
1085
- '必须由评审判断每道题是否需要视觉信息才能作答。若没有 svg 但题干仍信息完整、唯一可答,则不要因为题目属于几何题而判错;若缺少图会导致学生凭想象补条件、空间位置不明确或答案不唯一,必须判 blocker 并要求补 SVG 或改成纯文字题。'
1069
+ '如果题目缺少必要条件、答案无法唯一推出、选项不完整或题型不合适,必须判 blocker。'
1086
1070
  ].filter(Boolean),
1087
1071
  schema: {
1088
1072
  passed: false,
@@ -1151,13 +1135,11 @@ export async function generatePracticeContent(input) {
1151
1135
  },
1152
1136
  requirements: [
1153
1137
  '必须根据 review 修正后输出最终题目。',
1138
+ '遵守通用规则 R01-R20(已自动加载到 system prompt)。',
1154
1139
  '只输出 practiceDraft.questions 中的题目,不新增题目,不删除题目,不改变题目 ID。',
1155
1140
  '修订版必须包含题目、题型、难度、knowledgePointIds、knowledgePoints、expectedErrorTypes、配图字段和答题空间行数。',
1156
1141
  isAbilityAssessment ? '能力评估修订版还必须保留 abilityIds、skillAtoms、expectedAbilityErrors。' : '',
1157
- 'knowledgePoints 和 expectedErrorTypes 必须是纯文本短标签,不能包含 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装。',
1158
- isAbilityAssessment ? 'skillAtoms 和 expectedAbilityErrors 必须是纯文本短标签,不能包含 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装。' : '',
1159
1142
  '不要输出 answer、solutionSteps 或 rubric;这些字段会在题目定稿后分批补全。',
1160
- '必须修复 LaTeX 反斜杠损坏,并同步校准几何/SVG 题的题干与图中角标、垂足、切点、边名、长度和平行/垂直标注。',
1161
1143
  '不要输出未修正草稿。'
1162
1144
  ].filter(Boolean),
1163
1145
  schema: questionRevisionSchema()
@@ -1180,47 +1162,60 @@ export async function generatePracticeContent(input) {
1180
1162
  onProgress({ step: 'practice_generate.revision_done', message: `题目修订完成:${revision.questions.length} 题。` });
1181
1163
  let revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1182
1164
  if (!isAbilityAssessment && revisionCoverage.missingPointIds.length) {
1165
+ const missingPointLabels = revisionCoverage.missingPointIds.join('、');
1183
1166
  const missingPoints = targetKnowledgePoints.filter((point) => revisionCoverage.missingPointIds.includes(point.id));
1184
1167
  onProgress({
1185
1168
  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} 个目标点。`
1169
+ message: `正在修复知识点覆盖缺口:${missingPointLabels}。`
1223
1170
  });
1171
+ let repairSkipped = false;
1172
+ try {
1173
+ const repairResult = await callPracticeAgentCached({
1174
+ cacheDir: stageCacheDir,
1175
+ cacheFile: 'coverage-repair.json',
1176
+ onProgress,
1177
+ promptName: 'practice-coverage-repair.system.md',
1178
+ task: '在不新增题目、不删除题目的前提下修复定稿题目的知识点覆盖缺口',
1179
+ context: {
1180
+ ...baseContext,
1181
+ practiceDraft: omitSvgSourcesForTextReview(revision),
1182
+ localCoverageCheck: revisionCoverage,
1183
+ missingTargetKnowledgePoints: missingPoints
1184
+ },
1185
+ requirements: [
1186
+ '遵守通用规则 R01-R20(已自动加载到 system prompt)。',
1187
+ '只修复覆盖缺口,不新增题目,不删除题目,不改变题目 ID。',
1188
+ `必须让修订后的 questions 覆盖这些 missingPointIds:${missingPointLabels}。`,
1189
+ '优先选择最相关的已有题目进行小幅改写或补充 knowledgePointIds;不能硬塞不相关知识点。',
1190
+ '如果题面需要随知识点绑定变化而调整,必须同步改题干,保证学生可作答且答案唯一。',
1191
+ '不要输出 answer、solutionSteps 或 rubric;这些字段会在题目定稿后补全。'
1192
+ ],
1193
+ schema: questionRevisionSchema(),
1194
+ retries: 2
1195
+ });
1196
+ const repair = assertAgentQuestions(repairResult, 'coverage_repair');
1197
+ revision = {
1198
+ ...revision,
1199
+ title: normalizePracticeTitle(repair.title || revision.title),
1200
+ personalizationBasis: repair.personalizationBasis || revision.personalizationBasis || [],
1201
+ revisionSummary: [revision.revisionSummary, repair.revisionSummary]
1202
+ .filter(Boolean)
1203
+ .join('\n'),
1204
+ questions: mergeRevisionQuestions(revision.questions, repair.questions)
1205
+ };
1206
+ revisionCoverage = coverageSummaryForQuestions(targetKnowledgePoints, revision.questions);
1207
+ onProgress({
1208
+ step: 'practice_generate.coverage_repair_done',
1209
+ message: `覆盖修复完成:已覆盖 ${revisionCoverage.coveredCount}/${revisionCoverage.intendedCount} 个目标点。`
1210
+ });
1211
+ } catch (error) {
1212
+ repairSkipped = true;
1213
+ onProgress({
1214
+ step: 'practice_generate.coverage_repair_skipped',
1215
+ message: `覆盖缺口修复未成功,跳过:${missingPointLabels}。当前卷已覆盖 ${revisionCoverage.coveredCount}/${revisionCoverage.intendedCount} 个点。建议改用显式 knowledgePointIds 指定目标点重新生成以覆盖剩余缺口。`,
1216
+ detail: error.message
1217
+ });
1218
+ }
1224
1219
  }
1225
1220
  const shouldRunSvgVerification = verifySvgFigures || requireSvg || minSvgQuestions > 0;
1226
1221
  if (shouldRunSvgVerification) {
@@ -1403,14 +1398,14 @@ export async function generatePracticeContent(input) {
1403
1398
  cacheDir: stageCacheDir,
1404
1399
  cacheFile: `answer-chunk-${String(index + 1).padStart(2, '0')}.json`,
1405
1400
  onProgress,
1406
- promptName: 'practice-generate.system.md',
1401
+ promptName: 'practice-answers.system.md',
1407
1402
  task: `为定稿题目补全答案元数据(第 ${index + 1}/${answerQuestionChunks.length} 批)`,
1408
1403
  context: contextForAnswerMetadata(baseContext, questions),
1409
1404
  requirements: [
1410
1405
  '只输出输入 questions 中已有题目的 id、answer、solutionSteps、rubric。',
1411
1406
  '不得修改题目 ID、题干、题型、难度、knowledgePointIds、knowledgePoints、expectedErrorTypes、abilityIds、skillAtoms、expectedAbilityErrors、score、answerSpaceLines、svg 或 imagePrompt。',
1412
- 'answer 必须是可核对的标准答案;选择题写正确选项和结论,填空/问答题写完整答案。',
1413
- 'solutionSteps 面向讲解卷阅读,不要机械写 2-5 步。简单选择题/填空题只写 1 条简短说明,说明本题考察什么和关键辨析点;需要推导、几何观察或多步判断的题,再写 2-4 条解题要点。',
1407
+ 'answer 必须是可核对的标准答案;选择题写正确选项和简短结论,填空/问答题写完整答案。',
1408
+ 'solutionSteps 分级输出:简单选择题/填空题只写 1 条简短说明(考察什么、关键辨析点);需要推导、几何观察或多步判断的题写 2-4 条解题要点。',
1414
1409
  'solutionSteps 不要重复标准答案,不要写成批改标准;语言要像给孩子看的讲解。',
1415
1410
  'rubric 必须可用于批改,至少包含关键结论和关键过程;各项 score 之和应接近题目 score。',
1416
1411
  '公式继续使用 LaTeX 分隔符,并保留完整反斜杠。'
@@ -120,6 +120,28 @@ function duplicateFindings(questions, historicalQuestions) {
120
120
  return findings;
121
121
  }
122
122
 
123
+ function svgMarkupFinding(question) {
124
+ const svg = String(question.svg || '').trim();
125
+ if (!svg) return null;
126
+ if (!/^<svg[\s>]/i.test(svg) || !/<\/svg>\s*$/i.test(svg)) {
127
+ return {
128
+ level: 'blocker',
129
+ type: 'invalid_svg_markup',
130
+ questionId: question.id,
131
+ message: '题目 SVG 必须是可直接内联渲染的 <svg>...</svg> 标记,不能是转义文本、Markdown 代码块或说明文字。'
132
+ };
133
+ }
134
+ if (/<script\b|<foreignObject\b|on[a-z]+\s*=|javascript:/i.test(svg)) {
135
+ return {
136
+ level: 'blocker',
137
+ type: 'unsafe_svg_markup',
138
+ questionId: question.id,
139
+ message: '题目 SVG 包含脚本、foreignObject、事件属性或 javascript 链接,不能写入试卷预览。'
140
+ };
141
+ }
142
+ return null;
143
+ }
144
+
123
145
  function personalizationFindings(practice, options) {
124
146
  const profile = typeProfiles[options.type] ?? typeProfiles.foundation;
125
147
  const findings = [];
@@ -149,6 +171,8 @@ function personalizationFindings(practice, options) {
149
171
  });
150
172
  }
151
173
  for (const question of practice.questions) {
174
+ const svgFinding = svgMarkupFinding(question);
175
+ if (svgFinding) findings.push(svgFinding);
152
176
  if (!profile.allowedDifficulties.includes(question.difficulty)) {
153
177
  findings.push({
154
178
  level: 'warning',
@@ -285,7 +285,7 @@ export async function createPractice({
285
285
  const requestedMinSvgQuestions = Number(minSvgQuestions);
286
286
  const effectiveMinSvgQuestions = Number.isFinite(requestedMinSvgQuestions) && requestedMinSvgQuestions > 0
287
287
  ? Math.floor(requestedMinSvgQuestions)
288
- : autoGeometryVisualCheck
288
+ : (autoGeometryVisualCheck && verifySvgFigures === undefined)
289
289
  ? Math.max(1, Math.min(4, Math.ceil(maxQuestionCount / 4)))
290
290
  : 0;
291
291
  const options = {
@@ -301,7 +301,7 @@ export async function createPractice({
301
301
  questionKind,
302
302
  generationCacheDir,
303
303
  fastReview: Boolean(fastReview),
304
- verifySvgFigures: Boolean(verifySvgFigures) || autoGeometryVisualCheck,
304
+ verifySvgFigures: verifySvgFigures !== undefined ? Boolean(verifySvgFigures) : autoGeometryVisualCheck,
305
305
  requireSvg: Boolean(requireSvg),
306
306
  minSvgQuestions: effectiveMinSvgQuestions,
307
307
  knowledgeDoc: knowledgeBundle.knowledge,
@@ -6,6 +6,15 @@ export async function readPrompt(name) {
6
6
  return readFile(path.join(paths.rootDir, 'prompts', name), 'utf8');
7
7
  }
8
8
 
9
+ let rulesCache = null;
10
+
11
+ export async function readRules() {
12
+ if (rulesCache) return rulesCache;
13
+ const raw = await readFile(path.join(paths.rootDir, 'prompts', 'practice-rules.md'), 'utf8');
14
+ rulesCache = raw;
15
+ return raw;
16
+ }
17
+
9
18
  export function promptPayload({ task, context, schema, requirements = [] }) {
10
19
  return JSON.stringify({
11
20
  task,
@@ -14,3 +23,8 @@ export function promptPayload({ task, context, schema, requirements = [] }) {
14
23
  schema
15
24
  });
16
25
  }
26
+
27
+ export function expandRuleRefs(requirements, ruleIds = []) {
28
+ if (!ruleIds.length) return requirements;
29
+ return [...ruleIds.map((id) => `R${String(id).padStart(2, '0')}`), ...requirements];
30
+ }