@zhouchangui/math-ati 0.1.0

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 (46) hide show
  1. package/.env.local.example +6 -0
  2. package/AGENTS.md +273 -0
  3. package/README.md +34 -0
  4. package/bin/math-ati.js +194 -0
  5. package/dist/assets/index-BYFoutza.js +22 -0
  6. package/dist/assets/index-Bk2WFPoL.css +1 -0
  7. package/dist/index.html +13 -0
  8. package/package.json +72 -0
  9. package/prompts/grading.system.md +129 -0
  10. package/prompts/knowledge-extract.system.md +123 -0
  11. package/prompts/knowledge-summarize.system.md +127 -0
  12. package/prompts/learning-summary.system.md +123 -0
  13. package/prompts/pdf-grading.system.md +80 -0
  14. package/prompts/pdf-page-extract.system.md +52 -0
  15. package/prompts/pdf-recheck.system.md +43 -0
  16. package/prompts/practice-generate.system.md +161 -0
  17. package/prompts/practice-review.system.md +65 -0
  18. package/prompts/practice-revise.system.md +56 -0
  19. package/server/abilityService.js +259 -0
  20. package/server/agentClient.js +202 -0
  21. package/server/env.js +4 -0
  22. package/server/fileStore.js +726 -0
  23. package/server/grading.js +116 -0
  24. package/server/index.js +655 -0
  25. package/server/jobStore.js +169 -0
  26. package/server/knowledgeBase.js +30 -0
  27. package/server/knowledgeExtractor.js +360 -0
  28. package/server/knowledgeFeedback.js +299 -0
  29. package/server/llmConfig.js +96 -0
  30. package/server/mistakeLifecycle.js +251 -0
  31. package/server/pdfSubmissionGrader.js +846 -0
  32. package/server/practiceGenerator.js +908 -0
  33. package/server/practicePaperHtml.js +313 -0
  34. package/server/practiceReviewer.js +307 -0
  35. package/server/practiceService.js +331 -0
  36. package/server/promptStore.js +16 -0
  37. package/server/submissionService.js +184 -0
  38. package/templates/workspace/.env.local.example +6 -0
  39. package/templates/workspace/data/global/ability_index.json +5 -0
  40. package/templates/workspace/data/global/chapters.json +621 -0
  41. package/templates/workspace/data/global/mastery_index.json +6 -0
  42. package/templates/workspace/data/global/mistakes_index.json +7 -0
  43. package/templates/workspace/data/global/student_profile.json +11 -0
  44. package/templates/workspace/data/knowledge_points.json +1264 -0
  45. package/templates/workspace/data/mistakes.json +1 -0
  46. package/vite.config.js +21 -0
@@ -0,0 +1,313 @@
1
+ function escapeHtml(value) {
2
+ return String(value ?? '')
3
+ .replaceAll('&', '&')
4
+ .replaceAll('<', '&lt;')
5
+ .replaceAll('>', '&gt;')
6
+ .replaceAll('"', '&quot;')
7
+ .replaceAll("'", '&#39;');
8
+ }
9
+
10
+ function textHtml(text) {
11
+ return escapeHtml(text)
12
+ .replace(/\n{2,}/g, '</p><p>')
13
+ .replace(/\n/g, '<br />');
14
+ }
15
+
16
+ function splitMarkdownRow(line) {
17
+ return line
18
+ .trim()
19
+ .replace(/^\|/, '')
20
+ .replace(/\|$/, '')
21
+ .split('|')
22
+ .map((cell) => cell.trim());
23
+ }
24
+
25
+ function isMarkdownTableSeparator(line) {
26
+ return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line || '');
27
+ }
28
+
29
+ function markdownTableHtml(lines, startIndex) {
30
+ if (!lines[startIndex]?.includes('|') || !isMarkdownTableSeparator(lines[startIndex + 1])) {
31
+ return null;
32
+ }
33
+ const header = splitMarkdownRow(lines[startIndex]);
34
+ const rows = [];
35
+ let index = startIndex + 2;
36
+ while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
37
+ rows.push(splitMarkdownRow(lines[index]));
38
+ index += 1;
39
+ }
40
+ const headHtml = header.map((cell) => `<th>${escapeHtml(cell)}</th>`).join('');
41
+ const bodyHtml = rows
42
+ .map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`)
43
+ .join('');
44
+ return {
45
+ html: `<table class="question-table"><thead><tr>${headHtml}</tr></thead><tbody>${bodyHtml}</tbody></table>`,
46
+ nextIndex: index
47
+ };
48
+ }
49
+
50
+ function contentHtml(text) {
51
+ const lines = String(text || '').split(/\r?\n/);
52
+ const blocks = [];
53
+ let paragraph = [];
54
+ const flushParagraph = () => {
55
+ if (!paragraph.length) return;
56
+ blocks.push(`<p>${textHtml(paragraph.join('\n'))}</p>`);
57
+ paragraph = [];
58
+ };
59
+
60
+ for (let index = 0; index < lines.length;) {
61
+ const table = markdownTableHtml(lines, index);
62
+ if (table) {
63
+ flushParagraph();
64
+ blocks.push(table.html);
65
+ index = table.nextIndex;
66
+ continue;
67
+ }
68
+ if (!lines[index].trim()) {
69
+ flushParagraph();
70
+ index += 1;
71
+ continue;
72
+ }
73
+ paragraph.push(lines[index]);
74
+ index += 1;
75
+ }
76
+ flushParagraph();
77
+ return blocks.join('\n');
78
+ }
79
+
80
+ function figureHtml(question) {
81
+ const parts = [];
82
+ if (question.svg) parts.push(`<div class="question-figure">${question.svg}</div>`);
83
+ if (question.svgPath) parts.push(`<img class="question-image" src="${escapeHtml(question.svgPath)}" alt="题目配图" />`);
84
+ if (question.imagePath) parts.push(`<img class="question-image" src="${escapeHtml(question.imagePath)}" alt="题目配图" />`);
85
+ return parts.join('\n');
86
+ }
87
+
88
+ function answerSpaceLines(question) {
89
+ const byKind = {
90
+ choice: 2,
91
+ blank: 3,
92
+ short_answer: 6
93
+ };
94
+ return Math.max(byKind[question.questionKind] || 5, Number(question.answerSpaceLines || 0));
95
+ }
96
+
97
+ function paperDisplayTitle(title) {
98
+ return String(title || '')
99
+ .replace(/最终版/g, '')
100
+ .replace(/\s*(第\s*\d+\s*\/\s*\d+\s*批)/g, '')
101
+ .replace(/\s{2,}/g, ' ')
102
+ .trim();
103
+ }
104
+
105
+ function listItemsHtml(items) {
106
+ return (Array.isArray(items) ? items : [])
107
+ .map((item) => `<li>${contentHtml(item)}</li>`)
108
+ .join('');
109
+ }
110
+
111
+ function rubricHtml(rubric) {
112
+ if (!Array.isArray(rubric) || !rubric.length) return '';
113
+ return rubric
114
+ .map((item) => {
115
+ if (typeof item === 'string') return `<li>${contentHtml(item)}</li>`;
116
+ const point = item?.point || item?.criterion || item?.description || '';
117
+ const score = item?.score || item?.points || '';
118
+ return `<li>${contentHtml(`${point}${score ? `(${score} 分)` : ''}`)}</li>`;
119
+ })
120
+ .join('');
121
+ }
122
+
123
+ function tagText(value) {
124
+ return String(value ?? '')
125
+ .replace(/\$\$([^$]+)\$\$/g, '$1')
126
+ .replace(/\$([^$]+)\$/g, '$1')
127
+ .replace(/\\\((.*?)\\\)/g, '$1')
128
+ .replace(/\\\[(.*?)\\\]/g, '$1')
129
+ .replace(/\\left|\\right/g, '')
130
+ .replace(/\\frac\{([^{}]+)\}\{([^{}]+)\}/g, '$1/$2')
131
+ .trim();
132
+ }
133
+
134
+ function tagsHtml(items) {
135
+ return (Array.isArray(items) ? items : [])
136
+ .filter(Boolean)
137
+ .map((item) => `<span>${escapeHtml(tagText(item))}</span>`)
138
+ .join('');
139
+ }
140
+
141
+ function practiceHtmlShell({ title, body, print = true, sheetClass = '' }) {
142
+ return `<!doctype html>
143
+ <html lang="zh-CN">
144
+ <head>
145
+ <meta charset="utf-8" />
146
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
147
+ <title>${escapeHtml(title)}</title>
148
+ <script>
149
+ (function () {
150
+ var katexBase = window.location.protocol === 'file:'
151
+ ? '../../../../node_modules/katex/dist'
152
+ : '/vendor/katex';
153
+ document.write('<link rel="stylesheet" href="' + katexBase + '/katex.min.css" />');
154
+ document.write('<script defer src="' + katexBase + '/katex.min.js"><\\/script>');
155
+ document.write('<script defer src="' + katexBase + '/contrib/auto-render.min.js"><\\/script>');
156
+ })();
157
+ </script>
158
+ <script>
159
+ document.addEventListener('DOMContentLoaded', function () {
160
+ if (!window.renderMathInElement) return;
161
+ window.renderMathInElement(document.body, {
162
+ ignoredClasses: ['no-math-render'],
163
+ delimiters: [
164
+ { left: '$$', right: '$$', display: true },
165
+ { left: '$', right: '$', display: false },
166
+ { left: '\\\\(', right: '\\\\)', display: false },
167
+ { left: '\\\\[', right: '\\\\]', display: true },
168
+ { left: '\\\\begin{equation}', right: '\\\\end{equation}', display: true },
169
+ { left: '\\\\begin{align}', right: '\\\\end{align}', display: true },
170
+ { left: '\\\\begin{gather}', right: '\\\\end{gather}', display: true }
171
+ ],
172
+ throwOnError: false
173
+ });
174
+ });
175
+ </script>
176
+ <style>
177
+ :root { color: #17201b; font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif; }
178
+ * { box-sizing: border-box; }
179
+ body { margin: 0; background: #f5f2eb; }
180
+ .sheet { width: min(210mm, 100%); min-height: 297mm; margin: 0 auto; padding: 14mm; background: #fffdf7; }
181
+ .sheet-header { display: flex; justify-content: space-between; gap: 20px; border-bottom: 2px solid #17201b; padding-bottom: 12px; margin-bottom: 14px; }
182
+ h1 { margin: 0; font-size: 26px; line-height: 1.25; }
183
+ h2 { margin: 24px 0 12px; font-size: 18px; }
184
+ p { margin: 0; line-height: 1.75; }
185
+ .meta { margin-top: 8px; color: #5f6b63; }
186
+ .questions { column-count: 2; column-gap: 10mm; margin: 0; }
187
+ .question { break-inside: avoid; page-break-inside: avoid; margin: 0 0 14px; padding-bottom: 12px; }
188
+ .question-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px; align-items: start; }
189
+ .question-number { font-weight: 700; line-height: 1.75; }
190
+ .question-body { min-width: 0; }
191
+ .stem { margin-bottom: 10px; }
192
+ .stem p { margin: 0 0 8px; }
193
+ .question-table { width: 100%; border-collapse: collapse; margin: 8px 0 10px; font-size: 14px; }
194
+ .question-table th, .question-table td { border: 1px solid #9f9689; padding: 6px 8px; text-align: left; vertical-align: top; }
195
+ .question-table th { background: #f0eadf; font-weight: 700; }
196
+ .answer-space { min-height: 42px; margin-top: 8px; }
197
+ .question-figure { margin: 10px 0; }
198
+ .question-figure svg { max-width: 100%; height: auto; display: block; }
199
+ .question-image { max-width: 100%; height: auto; display: block; margin: 8px 0; }
200
+ .answer-card { break-inside: avoid; page-break-inside: avoid; border-bottom: 1px solid #e1dacd; padding: 14px 0; }
201
+ .answer-card h3 { margin: 0 0 8px; font-size: 16px; }
202
+ .steps { margin: 8px 0 0; padding-left: 20px; }
203
+ .steps li { margin: 4px 0; line-height: 1.65; }
204
+ .answer-questions { margin: 0; }
205
+ .answer-questions .question { margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e1dacd; }
206
+ .explain-block { display: grid; gap: 8px; margin-top: 10px; padding: 10px 12px; border: 1px solid #ded7ca; border-radius: 6px; background: #fffaf0; }
207
+ .explain-block h3 { margin: 0; font-size: 15px; }
208
+ .answer-value { font-weight: 700; }
209
+ .tag-list { display: flex; flex-wrap: wrap; gap: 6px; }
210
+ .tag-list span { border: 1px solid #cfc7b9; border-radius: 999px; padding: 2px 8px; font-size: 12px; color: #4f5b54; background: white; }
211
+ .rubric-list { margin: 0; padding-left: 20px; }
212
+ .explain-missing { color: #7b665a; }
213
+ @page { size: A4; margin: 14mm; }
214
+ @media print {
215
+ body { background: white; }
216
+ .sheet { width: 100%; padding: 0; }
217
+ .questions { column-count: 2; column-gap: 10mm; }
218
+ ${print ? '.no-print { display: none !important; }' : ''}
219
+ }
220
+ </style>
221
+ </head>
222
+ <body>
223
+ <main class="sheet ${escapeHtml(sheetClass)}">
224
+ ${body}
225
+ </main>
226
+ </body>
227
+ </html>
228
+ `;
229
+ }
230
+
231
+ export function practiceQuestionsToHtml(practice) {
232
+ const title = paperDisplayTitle(practice.title);
233
+ const body = [
234
+ ' <header class="sheet-header">',
235
+ ' <div>',
236
+ ` <h1>${escapeHtml(title)}</h1>`,
237
+ ' </div>',
238
+ ' </header>',
239
+ ' <div class="questions">'
240
+ ];
241
+ for (const [index, question] of (practice.questions || []).entries()) {
242
+ const answerLines = answerSpaceLines(question);
243
+ body.push(
244
+ ` <article class="question" data-question-id="${escapeHtml(question.id)}">`,
245
+ ' <div class="question-row">',
246
+ ` <span class="question-number">${index + 1}.</span>`,
247
+ ' <div class="question-body">',
248
+ ` <div class="stem">${contentHtml(question.stem)}</div>`,
249
+ figureHtml(question),
250
+ ` <div class="answer-space" style="min-height:${Math.max(2, answerLines) * 24}px"></div>`,
251
+ ' </div>',
252
+ ' </div>',
253
+ ' </article>'
254
+ );
255
+ }
256
+ body.push(' </div>');
257
+ return practiceHtmlShell({ title, body: body.filter(Boolean).join('\n') });
258
+ }
259
+
260
+ export function practiceAnswersToHtml(practice) {
261
+ const title = paperDisplayTitle(practice.title);
262
+ const body = [
263
+ ' <header class="sheet-header">',
264
+ ' <div>',
265
+ ` <h1>${escapeHtml(title)} 讲解版</h1>`,
266
+ ` <p class="meta">${(practice.questions || []).length} 题 · 含标准答案、解题步骤和知识点</p>`,
267
+ ' </div>',
268
+ ' </header>'
269
+ ];
270
+ body.push(' <div class="answer-questions">');
271
+ for (const [index, question] of (practice.questions || []).entries()) {
272
+ const knowledgeTags = tagsHtml(question.knowledgePoints?.length ? question.knowledgePoints : question.knowledgePointIds);
273
+ const errorTags = tagsHtml(question.expectedErrorTypes);
274
+ const steps = listItemsHtml(question.solutionSteps);
275
+ const rubric = rubricHtml(question.rubric);
276
+ const hasExplanation = question.answer || steps || knowledgeTags || rubric;
277
+ body.push(
278
+ ` <article class="question" data-question-id="${escapeHtml(question.id)}">`,
279
+ ' <div class="question-row">',
280
+ ` <span class="question-number">${index + 1}.</span>`,
281
+ ' <div class="question-body">',
282
+ ` <div class="stem">${contentHtml(question.stem)}</div>`,
283
+ figureHtml(question),
284
+ ' <section class="explain-block">',
285
+ ' <h3>标准答案</h3>',
286
+ hasExplanation && question.answer
287
+ ? ` <div class="answer-value">${contentHtml(question.answer)}</div>`
288
+ : ' <p class="explain-missing">这份旧试卷没有生成阶段答案;新生成试卷会包含标准答案和元数据。</p>',
289
+ steps
290
+ ? ` <h3>解题过程</h3><ol class="steps">${steps}</ol>`
291
+ : '',
292
+ knowledgeTags
293
+ ? ` <h3>覆盖知识点</h3><div class="tag-list no-math-render">${knowledgeTags}</div>`
294
+ : '',
295
+ errorTags
296
+ ? ` <h3>易错点</h3><div class="tag-list no-math-render">${errorTags}</div>`
297
+ : '',
298
+ rubric
299
+ ? ` <h3>批改要点</h3><ul class="rubric-list">${rubric}</ul>`
300
+ : '',
301
+ ' </section>',
302
+ ' </div>',
303
+ ' </div>',
304
+ ' </article>'
305
+ );
306
+ }
307
+ body.push(' </div>');
308
+ return practiceHtmlShell({
309
+ title: `${title} 讲解版`,
310
+ body: body.filter(Boolean).join('\n'),
311
+ sheetClass: 'explain-sheet'
312
+ });
313
+ }
@@ -0,0 +1,307 @@
1
+ import path from 'node:path';
2
+ import { chapterDataPaths, readJson } from './fileStore.js';
3
+
4
+ const typeProfiles = {
5
+ knowledge_coverage: {
6
+ label: '知识点覆盖',
7
+ allowedDifficulties: ['basic', 'medium'],
8
+ requiredBasicRatio: 0.65,
9
+ intent: '按章节知识点字典逐项覆盖,帮助判断哪些知识点已经接触、掌握或需要复习。'
10
+ },
11
+ foundation: {
12
+ label: '知识点覆盖',
13
+ allowedDifficulties: ['basic', 'medium'],
14
+ requiredBasicRatio: 0.6,
15
+ intent: '巩固核心概念、基本计算、基本变形,不追求综合难题。'
16
+ },
17
+ mistake_repair: {
18
+ label: '错因修复',
19
+ allowedDifficulties: ['basic', 'medium'],
20
+ requiredBasicRatio: 0.5,
21
+ intent: '围绕历史错因和预期错因生成同类变式,避免无关拓展。'
22
+ },
23
+ ability_assessment: {
24
+ label: '能力评估',
25
+ allowedDifficulties: ['basic', 'medium'],
26
+ requiredBasicRatio: 0.75,
27
+ intent: '围绕章节启用能力生成短题、多题、低语境的能力诊断卷。'
28
+ },
29
+ mastery_check: {
30
+ label: '达标检测',
31
+ allowedDifficulties: ['basic', 'medium', 'challenge'],
32
+ requiredBasicRatio: 0.35,
33
+ intent: '覆盖核心知识点,允许少量综合题,用于判断是否达标。'
34
+ }
35
+ };
36
+
37
+ function normalizeText(text) {
38
+ return String(text || '')
39
+ .replace(/\$[^$]*\$/g, (match) => match.replace(/\s+/g, ''))
40
+ .replace(/\s+/g, '')
41
+ .replace(/[,。!?,.!?;;::()()]/g, '')
42
+ .toLowerCase();
43
+ }
44
+
45
+ function similarity(a, b) {
46
+ const leftText = normalizeText(a);
47
+ const rightText = normalizeText(b);
48
+ if (leftText === rightText) return 1;
49
+ const ngrams = (text) => {
50
+ const grams = [];
51
+ for (let index = 0; index < text.length - 1; index += 1) {
52
+ grams.push(text.slice(index, index + 2));
53
+ }
54
+ return grams.length ? grams : [text];
55
+ };
56
+ const left = new Set(ngrams(leftText));
57
+ const right = new Set(ngrams(rightText));
58
+ if (!left.size || !right.size) return 0;
59
+ let overlap = 0;
60
+ for (const char of left) {
61
+ if (right.has(char)) overlap += 1;
62
+ }
63
+ return (2 * overlap) / (left.size + right.size);
64
+ }
65
+
66
+ async function recentPracticeQuestions(chapterId, limit = 30) {
67
+ try {
68
+ const fs = await import('node:fs/promises');
69
+ const practiceDir = chapterDataPaths(chapterId).practices;
70
+ const files = (await fs.readdir(practiceDir))
71
+ .filter((file) => file.endsWith('.json'))
72
+ .map((file) => path.join(practiceDir, file))
73
+ .sort()
74
+ .reverse();
75
+ const questions = [];
76
+ for (const file of files) {
77
+ if (questions.length >= limit) break;
78
+ try {
79
+ const practice = await readJson(file);
80
+ if (practice.chapterId === chapterId) {
81
+ questions.push(...practice.questions.map((question) => ({
82
+ practiceId: practice.id,
83
+ stem: question.stem
84
+ })));
85
+ }
86
+ } catch {
87
+ // Ignore partial files.
88
+ }
89
+ }
90
+ return questions.slice(0, limit);
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ function duplicateFindings(questions, historicalQuestions) {
97
+ const findings = [];
98
+ const seen = [];
99
+ for (const question of questions) {
100
+ const normalized = normalizeText(question.stem);
101
+ if (seen.some((item) => item.normalized === normalized || similarity(item.stem, question.stem) > 0.92)) {
102
+ findings.push({
103
+ level: 'blocker',
104
+ type: 'duplicate_in_sheet',
105
+ questionId: question.id,
106
+ message: '本卷内题目高度重复。'
107
+ });
108
+ }
109
+ const historical = historicalQuestions.find((item) => similarity(item.stem, question.stem) > 0.9);
110
+ if (historical) {
111
+ findings.push({
112
+ level: 'warning',
113
+ type: 'duplicate_history',
114
+ questionId: question.id,
115
+ message: `与历史练习 ${historical.practiceId} 中的题目接近。`
116
+ });
117
+ }
118
+ seen.push({ stem: question.stem, normalized });
119
+ }
120
+ return findings;
121
+ }
122
+
123
+ function personalizationFindings(practice, options) {
124
+ const profile = typeProfiles[options.type] ?? typeProfiles.foundation;
125
+ const findings = [];
126
+ const allKnowledgeIds = (options.knowledgeDoc?.sections || [])
127
+ .flatMap((section) => section.points || [])
128
+ .map((point) => point.id)
129
+ .filter(Boolean);
130
+ const scopedIds = Array.isArray(options.knowledgePointIds) && options.knowledgePointIds.length
131
+ ? options.knowledgePointIds
132
+ : options.knowledgePointId
133
+ ? [options.knowledgePointId]
134
+ : allKnowledgeIds;
135
+ const knowledgeIds = new Set(scopedIds);
136
+ const enabledAbilityIds = new Set(Array.isArray(options.abilityIds) ? options.abilityIds : []);
137
+ const coveredIds = new Set();
138
+ const total = practice.questions.length || 1;
139
+ const basicCount = practice.questions.filter((question) => question.difficulty === 'basic').length;
140
+ const basicRatio = basicCount / total;
141
+ const requiredBasicRatio = scopedIds.length && scopedIds.length <= 5
142
+ ? Math.min(profile.requiredBasicRatio, 0.4)
143
+ : profile.requiredBasicRatio;
144
+ if (basicRatio < requiredBasicRatio) {
145
+ findings.push({
146
+ level: 'warning',
147
+ type: 'difficulty_mix',
148
+ message: `${profile.label} 的基础题比例不足。`
149
+ });
150
+ }
151
+ for (const question of practice.questions) {
152
+ if (!profile.allowedDifficulties.includes(question.difficulty)) {
153
+ findings.push({
154
+ level: 'warning',
155
+ type: 'difficulty_out_of_scope',
156
+ questionId: question.id,
157
+ message: `${profile.label} 中出现了不推荐难度:${question.difficulty}。`
158
+ });
159
+ }
160
+ if (!question.knowledgePoints?.length) {
161
+ findings.push({
162
+ level: 'blocker',
163
+ type: 'missing_knowledge_point',
164
+ questionId: question.id,
165
+ message: '题目缺少知识点标签。'
166
+ });
167
+ }
168
+ if (!question.knowledgePointIds?.length) {
169
+ findings.push({
170
+ level: 'blocker',
171
+ type: 'missing_knowledge_point_id',
172
+ questionId: question.id,
173
+ message: '题目缺少知识点 ID,无法回写掌握状态。'
174
+ });
175
+ }
176
+ for (const pointId of question.knowledgePointIds || []) {
177
+ coveredIds.add(pointId);
178
+ if (knowledgeIds.size && !knowledgeIds.has(pointId)) {
179
+ findings.push({
180
+ level: 'blocker',
181
+ type: 'unknown_knowledge_point_id',
182
+ questionId: question.id,
183
+ message: `题目引用了知识点字典中不存在的 ID:${pointId}。`
184
+ });
185
+ }
186
+ }
187
+ if (!question.expectedErrorTypes?.length) {
188
+ findings.push({
189
+ level: 'warning',
190
+ type: 'missing_error_type',
191
+ questionId: question.id,
192
+ message: '题目缺少预期错因标签。'
193
+ });
194
+ }
195
+ if (options.type === 'ability_assessment') {
196
+ if (!question.abilityIds?.length) {
197
+ findings.push({
198
+ level: 'blocker',
199
+ type: 'missing_ability_id',
200
+ questionId: question.id,
201
+ message: '能力评估题缺少能力 ID,无法回写能力状态。'
202
+ });
203
+ }
204
+ for (const abilityId of question.abilityIds || []) {
205
+ if (!enabledAbilityIds.has(abilityId)) {
206
+ findings.push({
207
+ level: 'blocker',
208
+ type: 'unknown_ability_id',
209
+ questionId: question.id,
210
+ message: `题目引用了本章未启用的能力 ID:${abilityId}。`
211
+ });
212
+ }
213
+ }
214
+ if (!question.skillAtoms?.length) {
215
+ findings.push({
216
+ level: 'warning',
217
+ type: 'missing_skill_atom',
218
+ questionId: question.id,
219
+ message: '能力评估题缺少计算技能原子标签。'
220
+ });
221
+ }
222
+ if (!question.expectedAbilityErrors?.length) {
223
+ findings.push({
224
+ level: 'warning',
225
+ type: 'missing_ability_error',
226
+ questionId: question.id,
227
+ message: '能力评估题缺少预期能力错误标签。'
228
+ });
229
+ }
230
+ }
231
+ }
232
+ if ((options.type === 'knowledge_coverage' || options.type === 'foundation') && knowledgeIds.size) {
233
+ const expectedCoverage = options.adaptiveQuestionCount
234
+ ? Math.min(knowledgeIds.size, Math.max(practice.questions.length, Math.ceil(practice.questions.length * 2)))
235
+ : knowledgeIds.size > 20
236
+ ? Math.min(knowledgeIds.size, Math.ceil(practice.questions.length * 3))
237
+ : Math.min(knowledgeIds.size, practice.questions.length);
238
+ const coverageCount = [...coveredIds].filter((id) => knowledgeIds.has(id)).length;
239
+ if (coverageCount < expectedCoverage) {
240
+ findings.push({
241
+ level: 'warning',
242
+ type: 'knowledge_coverage_gap',
243
+ message: `知识点覆盖不足:应覆盖约 ${expectedCoverage} 个,当前覆盖 ${coverageCount} 个。`
244
+ });
245
+ }
246
+ }
247
+ return findings;
248
+ }
249
+
250
+ function makeSummary(findings, options, historicalQuestions) {
251
+ const blockers = findings.filter((item) => item.level === 'blocker').length;
252
+ const warnings = findings.filter((item) => item.level === 'warning').length;
253
+ const profile = typeProfiles[options.type] ?? typeProfiles.foundation;
254
+ return {
255
+ passed: blockers === 0,
256
+ blockers,
257
+ warnings,
258
+ mode: profile.label,
259
+ intent: profile.intent,
260
+ historicalCompared: historicalQuestions.length,
261
+ coverage: coverageSummary(options, findings),
262
+ findings
263
+ };
264
+ }
265
+
266
+ function coverageSummary(options, findings) {
267
+ const points = (options.knowledgeDoc?.sections || []).flatMap((section) =>
268
+ (section.points || []).map((point) => ({
269
+ id: point.id,
270
+ title: point.title,
271
+ section: section.title
272
+ }))
273
+ );
274
+ const scopedIds = Array.isArray(options.knowledgePointIds) && options.knowledgePointIds.length
275
+ ? new Set(options.knowledgePointIds)
276
+ : options.knowledgePointId
277
+ ? new Set([options.knowledgePointId])
278
+ : null;
279
+ const scopedPoints = scopedIds
280
+ ? points.filter((point) => scopedIds.has(point.id))
281
+ : points;
282
+ const coveredIds = new Set();
283
+ for (const finding of findings) {
284
+ if (finding.type === 'unknown_knowledge_point_id') continue;
285
+ }
286
+ for (const question of options.practiceQuestions || []) {
287
+ for (const pointId of question.knowledgePointIds || []) coveredIds.add(pointId);
288
+ }
289
+ const covered = scopedPoints.filter((point) => coveredIds.has(point.id));
290
+ const missing = scopedPoints.filter((point) => !coveredIds.has(point.id));
291
+ return {
292
+ total: scopedPoints.length,
293
+ covered: covered.length,
294
+ coveredPointIds: covered.map((point) => point.id),
295
+ missingPointIds: missing.map((point) => point.id)
296
+ };
297
+ }
298
+
299
+ export async function reviewPractice(practice, options) {
300
+ const historicalQuestions = await recentPracticeQuestions(practice.chapterId);
301
+ const reviewOptions = { ...options, practiceQuestions: practice.questions };
302
+ const findings = [
303
+ ...duplicateFindings(practice.questions, historicalQuestions),
304
+ ...personalizationFindings(practice, reviewOptions)
305
+ ];
306
+ return makeSummary(findings, reviewOptions, historicalQuestions);
307
+ }