@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.
- package/.env.local.example +6 -0
- package/AGENTS.md +273 -0
- package/README.md +34 -0
- package/bin/math-ati.js +194 -0
- package/dist/assets/index-BYFoutza.js +22 -0
- package/dist/assets/index-Bk2WFPoL.css +1 -0
- package/dist/index.html +13 -0
- package/package.json +72 -0
- package/prompts/grading.system.md +129 -0
- package/prompts/knowledge-extract.system.md +123 -0
- package/prompts/knowledge-summarize.system.md +127 -0
- package/prompts/learning-summary.system.md +123 -0
- package/prompts/pdf-grading.system.md +80 -0
- package/prompts/pdf-page-extract.system.md +52 -0
- package/prompts/pdf-recheck.system.md +43 -0
- package/prompts/practice-generate.system.md +161 -0
- package/prompts/practice-review.system.md +65 -0
- package/prompts/practice-revise.system.md +56 -0
- package/server/abilityService.js +259 -0
- package/server/agentClient.js +202 -0
- package/server/env.js +4 -0
- package/server/fileStore.js +726 -0
- package/server/grading.js +116 -0
- package/server/index.js +655 -0
- package/server/jobStore.js +169 -0
- package/server/knowledgeBase.js +30 -0
- package/server/knowledgeExtractor.js +360 -0
- package/server/knowledgeFeedback.js +299 -0
- package/server/llmConfig.js +96 -0
- package/server/mistakeLifecycle.js +251 -0
- package/server/pdfSubmissionGrader.js +846 -0
- package/server/practiceGenerator.js +908 -0
- package/server/practicePaperHtml.js +313 -0
- package/server/practiceReviewer.js +307 -0
- package/server/practiceService.js +331 -0
- package/server/promptStore.js +16 -0
- package/server/submissionService.js +184 -0
- package/templates/workspace/.env.local.example +6 -0
- package/templates/workspace/data/global/ability_index.json +5 -0
- package/templates/workspace/data/global/chapters.json +621 -0
- package/templates/workspace/data/global/mastery_index.json +6 -0
- package/templates/workspace/data/global/mistakes_index.json +7 -0
- package/templates/workspace/data/global/student_profile.json +11 -0
- package/templates/workspace/data/knowledge_points.json +1264 -0
- package/templates/workspace/data/mistakes.json +1 -0
- package/vite.config.js +21 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import { callChatAgent } from './agentClient.js';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { chapterDataPaths, isActionableMistake, readChapterMistakes, readJson } from './fileStore.js';
|
|
4
|
+
import { flattenKnowledgePoints } from './knowledgeBase.js';
|
|
5
|
+
import { promptPayload, readPrompt } from './promptStore.js';
|
|
6
|
+
import { ABILITY_CATALOG } from './abilityService.js';
|
|
7
|
+
|
|
8
|
+
function generationError(reason, detail = '') {
|
|
9
|
+
const error = new Error(`practice_generation_failed:${reason}`);
|
|
10
|
+
error.status = 502;
|
|
11
|
+
error.reason = reason;
|
|
12
|
+
error.detail = detail;
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function assertAgentQuestions(agent, phase) {
|
|
17
|
+
if (!agent.ok || !Array.isArray(agent.data?.questions)) {
|
|
18
|
+
const attempts = agent.attempts ? ` attempts=${agent.attempts}` : '';
|
|
19
|
+
throw generationError(`${phase}_${agent.reason || 'invalid_agent_response'}`, `${agent.detail || ''}${attempts}`);
|
|
20
|
+
}
|
|
21
|
+
return agent.data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function difficultyScore(question) {
|
|
25
|
+
if (question.difficulty === 'challenge') return 8;
|
|
26
|
+
if (question.difficulty === 'medium') return 6;
|
|
27
|
+
return 4;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizePracticeTitle(title) {
|
|
31
|
+
return String(title || '')
|
|
32
|
+
.replace(/最终版/g, '')
|
|
33
|
+
.replace(/[((][^))]*(?:修订版|第\s*\d+(?:\s*\/\s*\d+)?\s*批)[^))]*[))]/g, '')
|
|
34
|
+
.replace(/[((]\s*[·||::-]*\s*[))]/g, '')
|
|
35
|
+
.replace(/修订版/g, '')
|
|
36
|
+
.replace(/[((]\s*第\s*\d+\s*\/\s*\d+\s*批\s*[))]/g, '')
|
|
37
|
+
.replace(/[((]\s*第\s*\d+\s*批\s*[))]/g, '')
|
|
38
|
+
.replace(/第\s*\d+\s*\/\s*\d+\s*批/g, '')
|
|
39
|
+
.replace(/第\s*\d+\s*批/g, '')
|
|
40
|
+
.replace(/[||]\s*$/g, '')
|
|
41
|
+
.replace(/[::]\s*$/g, '')
|
|
42
|
+
.replace(/·\s*$/g, '')
|
|
43
|
+
.replace(/\s+(/g, '(')
|
|
44
|
+
.replace(/·\s+/g, '·')
|
|
45
|
+
.replace(/\s{2,}/g, ' ')
|
|
46
|
+
.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function plainMetadataLabel(value) {
|
|
50
|
+
return String(value ?? '')
|
|
51
|
+
.replace(/\$\$([^$]+)\$\$/g, '$1')
|
|
52
|
+
.replace(/\$([^$]+)\$/g, '$1')
|
|
53
|
+
.replace(/\\\((.*?)\\\)/g, '$1')
|
|
54
|
+
.replace(/\\\[(.*?)\\\]/g, '$1')
|
|
55
|
+
.replace(/\\left|\\right/g, '')
|
|
56
|
+
.replace(/\\frac\{([^{}]+)\}\{([^{}]+)\}/g, '$1/$2')
|
|
57
|
+
.replace(/<[^>]+>/g, '')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function plainMetadataLabels(items) {
|
|
63
|
+
if (!Array.isArray(items)) return [];
|
|
64
|
+
return items.map((item) => plainMetadataLabel(item)).filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function answerSpaceLines(question) {
|
|
68
|
+
const byKind = {
|
|
69
|
+
choice: 2,
|
|
70
|
+
blank: 3,
|
|
71
|
+
short_answer: 6
|
|
72
|
+
};
|
|
73
|
+
return Math.max(byKind[question.questionKind] || 5, Number(question.answerSpaceLines || 0));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function withProcessDefaults(content) {
|
|
77
|
+
const normalizeDifficulty = (difficulty, fallbackDifficulty) => {
|
|
78
|
+
if (['basic', 'medium', 'challenge'].includes(difficulty)) return difficulty;
|
|
79
|
+
if (['基础', '基础题', 'easy'].includes(difficulty)) return 'basic';
|
|
80
|
+
if (['中档', '中等', 'middle'].includes(difficulty)) return 'medium';
|
|
81
|
+
if (['挑战', '较难', 'hard'].includes(difficulty)) return 'challenge';
|
|
82
|
+
return fallbackDifficulty || 'basic';
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
...content,
|
|
86
|
+
title: normalizePracticeTitle(content.title),
|
|
87
|
+
questions: content.questions.map((question, index) => {
|
|
88
|
+
return {
|
|
89
|
+
...question,
|
|
90
|
+
id: question.id || `q${index + 1}`,
|
|
91
|
+
questionKind: ['choice', 'blank', 'short_answer'].includes(question.questionKind)
|
|
92
|
+
? question.questionKind
|
|
93
|
+
: 'short_answer',
|
|
94
|
+
knowledgePointIds: Array.isArray(question.knowledgePointIds) ? question.knowledgePointIds : [],
|
|
95
|
+
knowledgePoints: plainMetadataLabels(question.knowledgePoints),
|
|
96
|
+
expectedErrorTypes: plainMetadataLabels(question.expectedErrorTypes),
|
|
97
|
+
abilityIds: Array.isArray(question.abilityIds) ? question.abilityIds : [],
|
|
98
|
+
skillAtoms: plainMetadataLabels(question.skillAtoms),
|
|
99
|
+
expectedAbilityErrors: plainMetadataLabels(question.expectedAbilityErrors),
|
|
100
|
+
answer: String(question.answer || '').trim(),
|
|
101
|
+
solutionSteps: Array.isArray(question.solutionSteps)
|
|
102
|
+
? question.solutionSteps.map((step) => String(step || '').trim()).filter(Boolean)
|
|
103
|
+
: String(question.solution || '')
|
|
104
|
+
.split(/\n+/)
|
|
105
|
+
.map((step) => step.trim())
|
|
106
|
+
.filter(Boolean),
|
|
107
|
+
rubric: Array.isArray(question.rubric)
|
|
108
|
+
? question.rubric
|
|
109
|
+
: [],
|
|
110
|
+
difficulty: normalizeDifficulty(question.difficulty, 'basic'),
|
|
111
|
+
score: Number(question.score || difficultyScore(question)),
|
|
112
|
+
answerSpaceLines: answerSpaceLines(question)
|
|
113
|
+
};
|
|
114
|
+
})
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function chunkArray(items, size) {
|
|
119
|
+
const chunks = [];
|
|
120
|
+
for (let index = 0; index < items.length; index += size) {
|
|
121
|
+
chunks.push(items.slice(index, index + size));
|
|
122
|
+
}
|
|
123
|
+
return chunks;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function contextForQuestions(baseContext, questions) {
|
|
127
|
+
const usedIds = new Set();
|
|
128
|
+
for (const question of questions || []) {
|
|
129
|
+
for (const pointId of question.knowledgePointIds || []) usedIds.add(pointId);
|
|
130
|
+
}
|
|
131
|
+
const targetKnowledgePoints = baseContext.targetKnowledgePoints
|
|
132
|
+
.filter((point) => usedIds.size === 0 || usedIds.has(point.id));
|
|
133
|
+
return {
|
|
134
|
+
student: baseContext.student,
|
|
135
|
+
chapter: baseContext.chapter,
|
|
136
|
+
options: {
|
|
137
|
+
type: baseContext.options.type,
|
|
138
|
+
questionCount: baseContext.options.questionCount,
|
|
139
|
+
maxQuestionCount: baseContext.options.maxQuestionCount,
|
|
140
|
+
adaptiveQuestionCount: baseContext.options.adaptiveQuestionCount,
|
|
141
|
+
difficulty: baseContext.options.difficulty,
|
|
142
|
+
questionKind: baseContext.options.questionKind,
|
|
143
|
+
knowledgePointIds: [...usedIds],
|
|
144
|
+
abilityIds: baseContext.options.abilityIds || []
|
|
145
|
+
},
|
|
146
|
+
abilitySpecs: baseContext.abilitySpecs || [],
|
|
147
|
+
targetKnowledgePoints,
|
|
148
|
+
history: {
|
|
149
|
+
previousStems: baseContext.history.previousStems,
|
|
150
|
+
recentMistakes: baseContext.history.recentMistakes
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function compactPreviousStem(stem) {
|
|
156
|
+
return String(stem || '')
|
|
157
|
+
.replace(/\s+/g, ' ')
|
|
158
|
+
.slice(0, 220);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
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
|
+
return {
|
|
167
|
+
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
|
+
questions
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function contextForQuestionTargets(baseContext, targetKnowledgePoints, previousStems) {
|
|
184
|
+
return {
|
|
185
|
+
student: {
|
|
186
|
+
name: baseContext.student?.name,
|
|
187
|
+
stage: baseContext.student?.stage,
|
|
188
|
+
primaryGoal: baseContext.student?.primaryGoal
|
|
189
|
+
},
|
|
190
|
+
chapter: baseContext.chapter,
|
|
191
|
+
options: {
|
|
192
|
+
type: baseContext.options.type,
|
|
193
|
+
difficulty: baseContext.options.difficulty,
|
|
194
|
+
questionKind: baseContext.options.questionKind,
|
|
195
|
+
knowledgePointIds: targetKnowledgePoints.map((point) => point.id),
|
|
196
|
+
abilityIds: baseContext.options.abilityIds || []
|
|
197
|
+
},
|
|
198
|
+
abilitySpecs: baseContext.abilitySpecs || [],
|
|
199
|
+
knowledgeDoc: {
|
|
200
|
+
chapterId: baseContext.knowledgeDoc.chapterId,
|
|
201
|
+
targetOnly: true,
|
|
202
|
+
totalKnowledgePointCount: baseContext.knowledgeDoc.totalKnowledgePointCount,
|
|
203
|
+
selectedKnowledgePointCount: targetKnowledgePoints.length,
|
|
204
|
+
firstRoundCoveredCount: baseContext.knowledgeDoc.firstRoundCoveredCount,
|
|
205
|
+
firstRoundRemainingCount: baseContext.knowledgeDoc.firstRoundRemainingCount,
|
|
206
|
+
selectionPolicy: baseContext.knowledgeDoc.selectionPolicy
|
|
207
|
+
},
|
|
208
|
+
targetKnowledgePoints,
|
|
209
|
+
history: {
|
|
210
|
+
previousStems,
|
|
211
|
+
recentMistakes: baseContext.history.recentMistakes
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function compactRequestChars({ systemPrompt = '', task, context, requirements, schema }) {
|
|
217
|
+
return systemPrompt.length + promptPayload({ task, context, requirements, schema }).length;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function questionDraftSchema(questionCount) {
|
|
221
|
+
return {
|
|
222
|
+
title: 'string',
|
|
223
|
+
personalizationBasis: ['string'],
|
|
224
|
+
coveragePlan: {
|
|
225
|
+
recommendedQuestionCount: questionCount,
|
|
226
|
+
rationale: 'string',
|
|
227
|
+
priorityPointIds: ['chapter-01-kp-01'],
|
|
228
|
+
sampledMasteredPointIds: ['chapter-01-kp-02']
|
|
229
|
+
},
|
|
230
|
+
questions: [{
|
|
231
|
+
id: 'q1',
|
|
232
|
+
stem: 'plain text with LaTeX delimiters; only question',
|
|
233
|
+
questionKind: 'choice|blank|short_answer',
|
|
234
|
+
difficulty: 'basic|medium|challenge',
|
|
235
|
+
knowledgePointIds: ['chapter-01-kp-01'],
|
|
236
|
+
knowledgePoints: ['plain Chinese tag label, no LaTeX delimiters or HTML'],
|
|
237
|
+
expectedErrorTypes: ['plain Chinese tag label, no LaTeX delimiters or HTML'],
|
|
238
|
+
abilityIds: ['calculation_accuracy'],
|
|
239
|
+
skillAtoms: ['plain Chinese calculation skill atom'],
|
|
240
|
+
expectedAbilityErrors: ['plain Chinese ability error label, no LaTeX delimiters or HTML'],
|
|
241
|
+
svg: 'optional inline SVG string',
|
|
242
|
+
imagePrompt: 'optional gpt-image-2 prompt',
|
|
243
|
+
score: 6,
|
|
244
|
+
answerSpaceLines: 2
|
|
245
|
+
}]
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function distributeQuestionCounts(chunks, totalQuestionCount) {
|
|
250
|
+
const total = Math.max(1, Number(totalQuestionCount || chunks.length));
|
|
251
|
+
if (chunks.length <= 1) return [total];
|
|
252
|
+
const totalTargets = chunks.reduce((sum, chunk) => sum + chunk.length, 0) || chunks.length;
|
|
253
|
+
let remainingQuestions = total;
|
|
254
|
+
let remainingTargets = totalTargets;
|
|
255
|
+
return chunks.map((chunk, index) => {
|
|
256
|
+
const remainingChunks = chunks.length - index;
|
|
257
|
+
if (index === chunks.length - 1) return Math.max(1, remainingQuestions);
|
|
258
|
+
const proportional = Math.round((remainingQuestions * chunk.length) / Math.max(1, remainingTargets));
|
|
259
|
+
const count = Math.max(1, Math.min(proportional, remainingQuestions - (remainingChunks - 1)));
|
|
260
|
+
remainingQuestions -= count;
|
|
261
|
+
remainingTargets -= chunk.length;
|
|
262
|
+
return count;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function coverageSummaryForQuestions(targetKnowledgePoints, questions) {
|
|
267
|
+
const intendedPointIds = targetKnowledgePoints.map((point) => point.id);
|
|
268
|
+
const intendedPointIdSet = new Set(intendedPointIds);
|
|
269
|
+
const coveredPointIds = [
|
|
270
|
+
...new Set((questions || [])
|
|
271
|
+
.flatMap((question) => question.knowledgePointIds || [])
|
|
272
|
+
.filter((pointId) => intendedPointIdSet.has(pointId)))
|
|
273
|
+
];
|
|
274
|
+
const coveredPointIdSet = new Set(coveredPointIds);
|
|
275
|
+
const missingPointIds = intendedPointIds.filter((pointId) => !coveredPointIdSet.has(pointId));
|
|
276
|
+
const outsidePointIds = [
|
|
277
|
+
...new Set((questions || [])
|
|
278
|
+
.flatMap((question) => question.knowledgePointIds || [])
|
|
279
|
+
.filter((pointId) => !intendedPointIdSet.has(pointId)))
|
|
280
|
+
];
|
|
281
|
+
return {
|
|
282
|
+
intendedPointIds,
|
|
283
|
+
coveredPointIds,
|
|
284
|
+
missingPointIds,
|
|
285
|
+
outsidePointIds,
|
|
286
|
+
intendedCount: intendedPointIds.length,
|
|
287
|
+
coveredCount: coveredPointIds.length
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function answerMetadataSchema() {
|
|
292
|
+
return {
|
|
293
|
+
questions: [{
|
|
294
|
+
id: 'q1',
|
|
295
|
+
answer: 'string',
|
|
296
|
+
solutionSteps: ['string'],
|
|
297
|
+
rubric: [{
|
|
298
|
+
point: 'string',
|
|
299
|
+
score: 2
|
|
300
|
+
}]
|
|
301
|
+
}]
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function mergeAnswerMetadata(questions, answerChunks) {
|
|
306
|
+
const answersById = new Map();
|
|
307
|
+
for (const chunk of answerChunks) {
|
|
308
|
+
for (const answer of chunk.questions || []) {
|
|
309
|
+
answersById.set(answer.id, answer);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const merged = questions.map((question) => {
|
|
313
|
+
const answer = answersById.get(question.id);
|
|
314
|
+
if (!answer) {
|
|
315
|
+
throw generationError('answer_generation_missing', question.id);
|
|
316
|
+
}
|
|
317
|
+
const solutionSteps = Array.isArray(answer.solutionSteps)
|
|
318
|
+
? answer.solutionSteps.map((step) => String(step || '').trim()).filter(Boolean)
|
|
319
|
+
: [];
|
|
320
|
+
const rubric = Array.isArray(answer.rubric) ? answer.rubric : [];
|
|
321
|
+
if (!String(answer.answer || '').trim() || solutionSteps.length === 0 || rubric.length === 0) {
|
|
322
|
+
throw generationError('answer_generation_incomplete', question.id);
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
...question,
|
|
326
|
+
answer: String(answer.answer || '').trim(),
|
|
327
|
+
solutionSteps,
|
|
328
|
+
rubric
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
return merged;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function progressReporter(onProgress) {
|
|
335
|
+
return typeof onProgress === 'function' ? onProgress : () => {};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function compactKnowledgePoint(point, mastery = null) {
|
|
339
|
+
return {
|
|
340
|
+
id: point.id,
|
|
341
|
+
title: point.title,
|
|
342
|
+
sectionTitle: point.sectionTitle,
|
|
343
|
+
summary: point.summary,
|
|
344
|
+
pitfalls: Array.isArray(point.pitfalls) ? point.pitfalls.slice(0, 3) : [],
|
|
345
|
+
masteryStatus: mastery?.status || 'not_started',
|
|
346
|
+
masteryStats: mastery
|
|
347
|
+
? {
|
|
348
|
+
coveredCount: Number(mastery.coveredCount || 0),
|
|
349
|
+
correctCount: Number(mastery.correctCount || 0),
|
|
350
|
+
wrongCount: Number(mastery.wrongCount || 0),
|
|
351
|
+
errorTypes: Array.isArray(mastery.errorTypes) ? mastery.errorTypes.slice(0, 4) : []
|
|
352
|
+
}
|
|
353
|
+
: null
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function priorityScore(point, recentMistakes = []) {
|
|
358
|
+
const mastery = point.masteryStats || {};
|
|
359
|
+
const status = point.masteryStatus || 'not_started';
|
|
360
|
+
const mistakeHit = recentMistakes.some((mistake) => {
|
|
361
|
+
const text = `${mistake.knowledgePoint || ''} ${mistake.summary || ''}`;
|
|
362
|
+
return text.includes(point.id) || text.includes(point.title);
|
|
363
|
+
});
|
|
364
|
+
let score = 0;
|
|
365
|
+
if (status === 'needs_review') score += 6;
|
|
366
|
+
if (status === 'not_started') score += 3;
|
|
367
|
+
if (status === 'mastered') score -= 5;
|
|
368
|
+
score += Number(mastery.wrongCount || 0) * 3;
|
|
369
|
+
score += Number(mastery.coveredCount || 0) > 0 && Number(mastery.correctCount || 0) === 0 ? 2 : 0;
|
|
370
|
+
score += mistakeHit ? 8 : 0;
|
|
371
|
+
return score;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function selectGenerationTargets(points, { scopedIds, maxQuestionCount, recentMistakes, previousKnowledgePointIds = [] }) {
|
|
375
|
+
if (scopedIds) return points;
|
|
376
|
+
const previousIds = new Set(previousKnowledgePointIds);
|
|
377
|
+
const requestedCount = Number(maxQuestionCount || 12);
|
|
378
|
+
const limit = Math.max(8, Math.min(24, requestedCount));
|
|
379
|
+
const ordered = [...points]
|
|
380
|
+
.map((point, order) => ({
|
|
381
|
+
point,
|
|
382
|
+
order,
|
|
383
|
+
coveredInFirstRound: previousIds.has(point.id),
|
|
384
|
+
score: priorityScore(point, recentMistakes)
|
|
385
|
+
}))
|
|
386
|
+
.sort((a, b) => (
|
|
387
|
+
Number(a.coveredInFirstRound) - Number(b.coveredInFirstRound) ||
|
|
388
|
+
b.score - a.score ||
|
|
389
|
+
a.order - b.order
|
|
390
|
+
));
|
|
391
|
+
return ordered.slice(0, Math.min(limit, ordered.length)).map((item) => item.point);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function recommendedQuestionCount(targetCount, maxQuestionCount) {
|
|
395
|
+
const maxCount = Math.max(1, Number(maxQuestionCount || 12));
|
|
396
|
+
return Math.max(3, Math.min(maxCount, Math.ceil(targetCount / 2)));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function callPracticeAgent({ promptName, task, context, requirements, schema, timeoutMs = 120000, temperature = 0.2, retries = 2 }) {
|
|
400
|
+
const systemPrompt = await readPrompt(promptName);
|
|
401
|
+
return callChatAgent({
|
|
402
|
+
timeoutMs,
|
|
403
|
+
retries,
|
|
404
|
+
temperature,
|
|
405
|
+
system: systemPrompt,
|
|
406
|
+
user: promptPayload({
|
|
407
|
+
task,
|
|
408
|
+
context,
|
|
409
|
+
requirements,
|
|
410
|
+
schema
|
|
411
|
+
})
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function generationHistory(chapterId) {
|
|
416
|
+
const chapterPaths = chapterDataPaths(chapterId);
|
|
417
|
+
const mistakeSummary = await readJson(chapterPaths.mistakeSummary, null);
|
|
418
|
+
const mastery = await readJson(chapterPaths.mastery, null);
|
|
419
|
+
const mistakes = (await readChapterMistakes(chapterId)).filter(isActionableMistake).slice(-12);
|
|
420
|
+
let previousStems = [];
|
|
421
|
+
let previousKnowledgePointIds = [];
|
|
422
|
+
try {
|
|
423
|
+
const fs = await import('node:fs/promises');
|
|
424
|
+
const files = (await fs.readdir(chapterPaths.practices))
|
|
425
|
+
.filter((file) => file.endsWith('.json'))
|
|
426
|
+
.map((file) => path.join(chapterPaths.practices, file))
|
|
427
|
+
.sort()
|
|
428
|
+
.reverse();
|
|
429
|
+
for (const file of files.slice(0, 100)) {
|
|
430
|
+
const practice = await readJson(file);
|
|
431
|
+
if (practice.chapterId === chapterId) {
|
|
432
|
+
if (previousStems.length < 30) {
|
|
433
|
+
previousStems.push(...practice.questions.map((question) => compactPreviousStem(question.stem)));
|
|
434
|
+
}
|
|
435
|
+
if (practice.type === 'knowledge_coverage') {
|
|
436
|
+
previousKnowledgePointIds.push(...practice.questions.flatMap((question) => question.knowledgePointIds || []));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
previousStems = [];
|
|
442
|
+
previousKnowledgePointIds = [];
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
mistakeSummary,
|
|
446
|
+
mastery: mastery
|
|
447
|
+
? {
|
|
448
|
+
coverage: mastery.coverage || null,
|
|
449
|
+
points: Object.values(mastery.points || {})
|
|
450
|
+
.filter((point) => point.status !== 'mastered' || point.wrongCount || point.errorTypes?.length)
|
|
451
|
+
.sort((a, b) => Number(b.wrongCount || 0) - Number(a.wrongCount || 0))
|
|
452
|
+
.slice(0, 24)
|
|
453
|
+
.map((point) => ({
|
|
454
|
+
id: point.id,
|
|
455
|
+
title: point.title,
|
|
456
|
+
status: point.status || 'not_started',
|
|
457
|
+
coveredCount: Number(point.coveredCount || 0),
|
|
458
|
+
correctCount: Number(point.correctCount || 0),
|
|
459
|
+
wrongCount: Number(point.wrongCount || 0),
|
|
460
|
+
errorTypes: Array.isArray(point.errorTypes) ? point.errorTypes.slice(0, 5) : []
|
|
461
|
+
}))
|
|
462
|
+
}
|
|
463
|
+
: null,
|
|
464
|
+
recentMistakes: mistakes.map((item) => ({
|
|
465
|
+
knowledgePoint: item.knowledgePoint,
|
|
466
|
+
errorType: item.errorType,
|
|
467
|
+
summary: item.summary
|
|
468
|
+
})),
|
|
469
|
+
previousKnowledgePointIds: [...new Set(previousKnowledgePointIds)].filter(Boolean),
|
|
470
|
+
previousStems: previousStems.slice(0, 30)
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export async function generatePracticeContent(input) {
|
|
475
|
+
const { profile, chapter, options } = input;
|
|
476
|
+
const onProgress = progressReporter(input.onProgress);
|
|
477
|
+
onProgress({ step: 'practice_generate.history', message: '正在整理历史题目和近期错因。' });
|
|
478
|
+
const history = await generationHistory(chapter.id);
|
|
479
|
+
const knowledgeDoc = options.knowledgeDoc;
|
|
480
|
+
if (!knowledgeDoc?.sections?.length) {
|
|
481
|
+
throw generationError('missing_knowledge_doc');
|
|
482
|
+
}
|
|
483
|
+
const allTargetKnowledgePoints = flattenKnowledgePoints(knowledgeDoc);
|
|
484
|
+
const scopedIds = Array.isArray(options.knowledgePointIds) && options.knowledgePointIds.length
|
|
485
|
+
? new Set(options.knowledgePointIds)
|
|
486
|
+
: options.knowledgePointId
|
|
487
|
+
? new Set([options.knowledgePointId])
|
|
488
|
+
: null;
|
|
489
|
+
const scopedTargetKnowledgePoints = scopedIds
|
|
490
|
+
? allTargetKnowledgePoints.filter((point) => scopedIds.has(point.id))
|
|
491
|
+
: allTargetKnowledgePoints;
|
|
492
|
+
const masteryByPointId = new Map((history.mastery?.points || []).map((point) => [point.id, point]));
|
|
493
|
+
const compactTargetKnowledgePoints = scopedTargetKnowledgePoints.map((point) => ({
|
|
494
|
+
...compactKnowledgePoint(point, masteryByPointId.get(point.id) || null)
|
|
495
|
+
}));
|
|
496
|
+
const scopedTargetIds = new Set(compactTargetKnowledgePoints.map((point) => point.id));
|
|
497
|
+
const firstRoundCoveredIds = (history.previousKnowledgePointIds || [])
|
|
498
|
+
.filter((pointId) => scopedTargetIds.has(pointId));
|
|
499
|
+
const targetKnowledgePoints = selectGenerationTargets(compactTargetKnowledgePoints, {
|
|
500
|
+
scopedIds,
|
|
501
|
+
maxQuestionCount: options.maxQuestionCount || options.questionCount,
|
|
502
|
+
recentMistakes: history.recentMistakes || [],
|
|
503
|
+
previousKnowledgePointIds: firstRoundCoveredIds
|
|
504
|
+
});
|
|
505
|
+
const priorityKnowledgePointIds = [
|
|
506
|
+
...(history.mastery?.points || [])
|
|
507
|
+
.slice(0, 12)
|
|
508
|
+
.map((point) => point.id),
|
|
509
|
+
...(history.recentMistakes || [])
|
|
510
|
+
.map((item) => item.knowledgePoint)
|
|
511
|
+
.filter((value) => typeof value === 'string')
|
|
512
|
+
].filter(Boolean);
|
|
513
|
+
const localRecommendedQuestionCount = recommendedQuestionCount(
|
|
514
|
+
targetKnowledgePoints.length,
|
|
515
|
+
options.maxQuestionCount || options.questionCount
|
|
516
|
+
);
|
|
517
|
+
const agentOptions = {
|
|
518
|
+
type: options.type,
|
|
519
|
+
questionCount: options.questionCount,
|
|
520
|
+
maxQuestionCount: options.maxQuestionCount,
|
|
521
|
+
adaptiveQuestionCount: options.adaptiveQuestionCount,
|
|
522
|
+
difficulty: options.difficulty,
|
|
523
|
+
includeMistakes: options.includeMistakes,
|
|
524
|
+
knowledgePointId: options.knowledgePointId,
|
|
525
|
+
knowledgePointIds: options.knowledgePointIds,
|
|
526
|
+
abilityIds: options.abilityIds || [],
|
|
527
|
+
questionKind: options.questionKind
|
|
528
|
+
};
|
|
529
|
+
const abilitySpecs = (options.abilityIds || [])
|
|
530
|
+
.map((abilityId) => ABILITY_CATALOG.find((ability) => ability.id === abilityId))
|
|
531
|
+
.filter(Boolean)
|
|
532
|
+
.map((ability) => ({
|
|
533
|
+
id: ability.id,
|
|
534
|
+
title: ability.title,
|
|
535
|
+
description: ability.description,
|
|
536
|
+
category: ability.category,
|
|
537
|
+
passThreshold: ability.passThreshold,
|
|
538
|
+
requiredConsecutivePasses: ability.requiredConsecutivePasses,
|
|
539
|
+
firstVersionScope: ability.firstVersionScope
|
|
540
|
+
}));
|
|
541
|
+
const baseContext = {
|
|
542
|
+
student: profile,
|
|
543
|
+
chapter,
|
|
544
|
+
options: agentOptions,
|
|
545
|
+
knowledgeDoc: {
|
|
546
|
+
chapterId: knowledgeDoc.chapterId,
|
|
547
|
+
targetOnly: true,
|
|
548
|
+
totalKnowledgePointCount: compactTargetKnowledgePoints.length,
|
|
549
|
+
selectedKnowledgePointCount: targetKnowledgePoints.length,
|
|
550
|
+
firstRoundCoveredCount: firstRoundCoveredIds.length,
|
|
551
|
+
firstRoundRemainingCount: Math.max(0, compactTargetKnowledgePoints.length - firstRoundCoveredIds.length),
|
|
552
|
+
localRecommendedQuestionCount,
|
|
553
|
+
selectionPolicy: scopedIds
|
|
554
|
+
? 'specified knowledge point scope'
|
|
555
|
+
: 'uncovered first-round knowledge points first, then mastery status, wrong count, and recent mistakes'
|
|
556
|
+
},
|
|
557
|
+
targetKnowledgePoints,
|
|
558
|
+
abilitySpecs,
|
|
559
|
+
history
|
|
560
|
+
};
|
|
561
|
+
const isAbilityAssessment = options.type === 'ability_assessment';
|
|
562
|
+
const baseRequirements = [
|
|
563
|
+
'全部内容只能由 LLM 生成,不能使用本地模板或规则兜底。',
|
|
564
|
+
'题目之间不能重复,也不要和 previousStems 高度相似。',
|
|
565
|
+
options.questionCount
|
|
566
|
+
? `本次用户已选择练习时长,整份试卷目标题量为 ${options.questionCount} 题;如果任务要求“本批”,则只按本批指定题量生成。`
|
|
567
|
+
: `先根据 targetKnowledgePoints、history.mastery 和 recentMistakes 规划完整覆盖所需的最少题量;本次最多 ${options.maxQuestionCount || 18} 题,不要机械等于上限。`,
|
|
568
|
+
options.questionCount
|
|
569
|
+
? `本地预估建议题量为 ${localRecommendedQuestionCount} 题,但用户时长选择优先,题量以 ${options.questionCount} 题为准。`
|
|
570
|
+
: `本地预估建议题量为 ${localRecommendedQuestionCount} 题;只有确实需要时才接近上限。`,
|
|
571
|
+
scopedIds
|
|
572
|
+
? '本次是指定知识点范围覆盖,只能围绕 targetKnowledgePoints 出题,不要覆盖范围外知识点;必须用少题高覆盖策略,一道题可以覆盖多个紧密相关知识点。'
|
|
573
|
+
: isAbilityAssessment
|
|
574
|
+
? '本次是能力评估卷:目标来自 abilitySpecs 与本章知识范围,题目应围绕能力表现取样,不要机械追求知识点全覆盖。'
|
|
575
|
+
: '本次是章节知识点覆盖卷,不要要求用户提供知识点编号;首轮覆盖尚未完成时,knowledge_coverage 必须优先从 previousKnowledgePointIds 没有覆盖过的 targetKnowledgePoints 出题;已覆盖点只做必要合并抽样。首轮完成后,再按 needs_review、wrongCount 高和 recentMistakes 命中的点优先。',
|
|
576
|
+
isAbilityAssessment
|
|
577
|
+
? '能力评估第一版只评估计算准确性,不纳入用时、速度、限时达标;题目以短题、多题、低语境为主,尽量暴露计算准确性和常见计算错误。'
|
|
578
|
+
: '',
|
|
579
|
+
isAbilityAssessment
|
|
580
|
+
? '每道能力评估题必须填写 abilityIds、skillAtoms、expectedAbilityErrors;abilityIds 只能来自 context.options.abilityIds,首批通常是 calculation_accuracy。'
|
|
581
|
+
: '',
|
|
582
|
+
isAbilityAssessment
|
|
583
|
+
? 'skillAtoms 和 expectedAbilityErrors 是讲解版胶囊标签,只能写纯中文短标签;禁止使用 $...$、\\(...\\)、\\[...\\]、HTML 或复杂公式包装。'
|
|
584
|
+
: '',
|
|
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
|
+
'题面不给学生显示分值,不要在题干里写“本题多少分”。',
|
|
596
|
+
'第一阶段只生成题面、题型、知识点绑定、预期错因、分值和留白行数;answer、solutionSteps、rubric 会在题目定稿后由第二阶段分批补全。'
|
|
597
|
+
].filter(Boolean);
|
|
598
|
+
onProgress({
|
|
599
|
+
step: 'practice_generate.plan',
|
|
600
|
+
message: `正在分析首轮覆盖策略:已覆盖 ${firstRoundCoveredIds.length}/${compactTargetKnowledgePoints.length} 个知识点,本次候选 ${targetKnowledgePoints.length} 个。`
|
|
601
|
+
});
|
|
602
|
+
const questionGenerationRequirements = [
|
|
603
|
+
...baseRequirements,
|
|
604
|
+
!scopedIds && compactTargetKnowledgePoints.length > targetKnowledgePoints.length
|
|
605
|
+
? `为了保证生成稳定,本次只给出 ${targetKnowledgePoints.length} 个优先候选知识点;全章共有 ${compactTargetKnowledgePoints.length} 个知识点。后续掌握状态改善后,候选集应继续变小。`
|
|
606
|
+
: '本次候选知识点已经是完整目标范围。',
|
|
607
|
+
priorityKnowledgePointIds.length
|
|
608
|
+
? `本次优先覆盖这些知识点:${priorityKnowledgePointIds.slice(0, 16).join('、')}。`
|
|
609
|
+
: '本次按掌握状态和错因历史自动排序优先级。',
|
|
610
|
+
firstRoundCoveredIds.length
|
|
611
|
+
? `这些知识点已经在知识点覆盖试卷中出过题,除非为组合题必要条件,否则本轮不要优先覆盖:${firstRoundCoveredIds.slice(0, 30).join('、')}。`
|
|
612
|
+
: '还没有历史知识点覆盖记录,本轮从章节核心知识点开始首轮覆盖。',
|
|
613
|
+
'不要输出 answer、solutionSteps 或 rubric;本阶段只定稿题目本身和知识点绑定。'
|
|
614
|
+
];
|
|
615
|
+
const totalQuestionCount = options.questionCount || localRecommendedQuestionCount;
|
|
616
|
+
const questionTargetChunks = chunkArray(targetKnowledgePoints, 3);
|
|
617
|
+
const questionBatchCounts = distributeQuestionCounts(questionTargetChunks, totalQuestionCount);
|
|
618
|
+
const questionChunks = [];
|
|
619
|
+
const promptName = 'practice-generate.system.md';
|
|
620
|
+
const questionSystemPrompt = await readPrompt(promptName);
|
|
621
|
+
let generatedQuestionCount = 0;
|
|
622
|
+
let previousBatchStems = [...(baseContext.history.previousStems || [])];
|
|
623
|
+
for (let index = 0; index < questionTargetChunks.length; index += 1) {
|
|
624
|
+
const batchTargets = questionTargetChunks[index];
|
|
625
|
+
const batchQuestionCount = questionBatchCounts[index];
|
|
626
|
+
const batchContext = contextForQuestionTargets(baseContext, batchTargets, previousBatchStems.slice(-36));
|
|
627
|
+
const batchRequirements = [
|
|
628
|
+
...questionGenerationRequirements,
|
|
629
|
+
`本批只围绕 batch targetKnowledgePoints 出题,必须正好生成 ${batchQuestionCount} 题。`,
|
|
630
|
+
`本批目标知识点:${batchTargets.map((point) => point.id).join('、')}。`,
|
|
631
|
+
'除非题目确实需要组合,不要使用本批 targetKnowledgePoints 之外的 knowledgePointIds。',
|
|
632
|
+
generatedQuestionCount
|
|
633
|
+
? `前面批次已生成 ${generatedQuestionCount} 题,本批题号可先临时输出,服务端会统一改为全卷连续题号。`
|
|
634
|
+
: '这是本卷第一批题。'
|
|
635
|
+
];
|
|
636
|
+
const batchTask = `只生成练习题目草稿:第 ${index + 1}/${questionTargetChunks.length} 批,正好 ${batchQuestionCount} 题,暂不生成标准答案、解题步骤和批改要点`;
|
|
637
|
+
const batchSchema = questionDraftSchema(batchQuestionCount);
|
|
638
|
+
const requestChars = compactRequestChars({
|
|
639
|
+
systemPrompt: questionSystemPrompt,
|
|
640
|
+
task: batchTask,
|
|
641
|
+
context: batchContext,
|
|
642
|
+
requirements: batchRequirements,
|
|
643
|
+
schema: batchSchema
|
|
644
|
+
});
|
|
645
|
+
onProgress({
|
|
646
|
+
step: 'practice_generate.questions',
|
|
647
|
+
message: `正在生成题目草稿:第 ${index + 1}/${questionTargetChunks.length} 批(${batchQuestionCount} 题,约 ${Math.round(requestChars / 1000)}KB 请求)。`
|
|
648
|
+
});
|
|
649
|
+
const chunk = assertAgentQuestions(await callPracticeAgent({
|
|
650
|
+
promptName,
|
|
651
|
+
task: batchTask,
|
|
652
|
+
context: batchContext,
|
|
653
|
+
requirements: batchRequirements,
|
|
654
|
+
schema: batchSchema
|
|
655
|
+
}), `question_generation_batch_${index + 1}`);
|
|
656
|
+
if (chunk.questions.length !== batchQuestionCount) {
|
|
657
|
+
throw generationError(
|
|
658
|
+
'question_batch_count_mismatch',
|
|
659
|
+
`batch=${index + 1} expected=${batchQuestionCount} actual=${chunk.questions.length}`
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
chunk.questions = chunk.questions.map((question, questionIndex) => ({
|
|
663
|
+
...question,
|
|
664
|
+
id: `q${generatedQuestionCount + questionIndex + 1}`
|
|
665
|
+
}));
|
|
666
|
+
generatedQuestionCount += chunk.questions.length;
|
|
667
|
+
previousBatchStems.push(...chunk.questions.map((question) => compactPreviousStem(question.stem)));
|
|
668
|
+
questionChunks.push(chunk);
|
|
669
|
+
}
|
|
670
|
+
const questionDraft = {
|
|
671
|
+
title: normalizePracticeTitle(questionChunks[0]?.title || `${chapter.fullTitle || chapter.title}知识点覆盖练习`),
|
|
672
|
+
personalizationBasis: questionChunks
|
|
673
|
+
.flatMap((chunk) => Array.isArray(chunk.personalizationBasis) ? chunk.personalizationBasis : [])
|
|
674
|
+
.filter(Boolean)
|
|
675
|
+
.slice(0, 8),
|
|
676
|
+
coveragePlan: {
|
|
677
|
+
recommendedQuestionCount: totalQuestionCount,
|
|
678
|
+
rationale: questionChunks
|
|
679
|
+
.map((chunk) => chunk.coveragePlan?.rationale)
|
|
680
|
+
.filter(Boolean)
|
|
681
|
+
.join(';'),
|
|
682
|
+
priorityPointIds: targetKnowledgePoints.map((point) => point.id),
|
|
683
|
+
sampledMasteredPointIds: []
|
|
684
|
+
},
|
|
685
|
+
questions: questionChunks.flatMap((chunk) => chunk.questions)
|
|
686
|
+
};
|
|
687
|
+
if (questionDraft.questions.length !== totalQuestionCount) {
|
|
688
|
+
throw generationError(
|
|
689
|
+
'question_count_mismatch',
|
|
690
|
+
`expected=${totalQuestionCount} actual=${questionDraft.questions.length}`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
onProgress({
|
|
694
|
+
step: 'practice_generate.questions_done',
|
|
695
|
+
message: `题目草稿已生成:${questionDraft.questions.length} 题,开始 LLM 质量评审。`
|
|
696
|
+
});
|
|
697
|
+
const practiceDraft = withProcessDefaults({
|
|
698
|
+
...questionDraft,
|
|
699
|
+
source: 'agent'
|
|
700
|
+
});
|
|
701
|
+
onProgress({ step: 'practice_generate.review', message: '正在评审题目可作答性、覆盖规划和知识点绑定。' });
|
|
702
|
+
const review = await callPracticeAgent({
|
|
703
|
+
promptName: 'practice-review.system.md',
|
|
704
|
+
task: '评审练习题目草稿和知识点绑定;本阶段不评审答案元数据',
|
|
705
|
+
context: {
|
|
706
|
+
...contextForQuestions(baseContext, practiceDraft.questions),
|
|
707
|
+
practiceDraft
|
|
708
|
+
},
|
|
709
|
+
requirements: [
|
|
710
|
+
'至少完成一次 LLM review;发现问题必须给出具体修正指令。',
|
|
711
|
+
'评审题目质量、可作答性、题量规划和 knowledgePointIds。',
|
|
712
|
+
'必须检查 knowledgePoints、expectedErrorTypes、skillAtoms、expectedAbilityErrors 是否为纯文本标签;出现 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装时必须要求修正。',
|
|
713
|
+
isAbilityAssessment ? '能力评估题必须检查 abilityIds 是否完整且只来自 context.options.abilityIds。' : '',
|
|
714
|
+
'如果题目缺少必要条件、答案无法唯一推出、选项不完整或题型不合适,必须判 blocker。',
|
|
715
|
+
'必须检查 LaTeX 反斜杠是否损坏,尤其是 \\triangle、\\angle、\\perp、\\parallel、\\text{};发现丢反斜杠或制表符残留必须判 blocker。',
|
|
716
|
+
'必须检查几何/SVG 题的题干与图中角标、垂足、切点、边名、长度和平行/垂直标注是否一致;不一致必须判 blocker。'
|
|
717
|
+
].filter(Boolean),
|
|
718
|
+
schema: {
|
|
719
|
+
passed: false,
|
|
720
|
+
summary: 'string',
|
|
721
|
+
findings: [{
|
|
722
|
+
level: 'blocker|warning',
|
|
723
|
+
type: 'string',
|
|
724
|
+
questionId: 'q1',
|
|
725
|
+
message: 'string',
|
|
726
|
+
fixInstruction: 'string'
|
|
727
|
+
}],
|
|
728
|
+
coverage: {
|
|
729
|
+
coveredPointIds: ['chapter-01-kp-01'],
|
|
730
|
+
missingPointIds: ['chapter-01-kp-02']
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
if (!review.ok || !Array.isArray(review.data?.findings)) {
|
|
735
|
+
const attempts = review.attempts ? ` attempts=${review.attempts}` : '';
|
|
736
|
+
throw generationError(`review_${review.reason || 'invalid_agent_response'}`, `${review.detail || ''}${attempts}`);
|
|
737
|
+
}
|
|
738
|
+
onProgress({
|
|
739
|
+
step: 'practice_generate.review_done',
|
|
740
|
+
message: `质量评审完成:${review.data.findings.length} 条意见,开始修订题目。`
|
|
741
|
+
});
|
|
742
|
+
const revisionChunks = [];
|
|
743
|
+
const questionRevisionChunks = chunkArray(practiceDraft.questions, 3);
|
|
744
|
+
for (let index = 0; index < questionRevisionChunks.length; index += 1) {
|
|
745
|
+
const questions = questionRevisionChunks[index];
|
|
746
|
+
const questionIds = questions.map((question) => question.id);
|
|
747
|
+
const reviewFindings = (review.data.findings || [])
|
|
748
|
+
.filter((finding) => !finding.questionId || questionIds.includes(finding.questionId));
|
|
749
|
+
onProgress({
|
|
750
|
+
step: 'practice_generate.revision',
|
|
751
|
+
message: `正在修订题目:第 ${index + 1}/${questionRevisionChunks.length} 批(${questionIds.join('、')})。`
|
|
752
|
+
});
|
|
753
|
+
const revisionChunk = assertAgentQuestions(await callPracticeAgent({
|
|
754
|
+
promptName: 'practice-revise.system.md',
|
|
755
|
+
task: `根据评审意见修正练习卷并输出修订版(第 ${index + 1}/${questionRevisionChunks.length} 批)`,
|
|
756
|
+
context: {
|
|
757
|
+
...contextForQuestions(baseContext, questions),
|
|
758
|
+
practiceDraft: {
|
|
759
|
+
...practiceDraft,
|
|
760
|
+
questions
|
|
761
|
+
},
|
|
762
|
+
review: {
|
|
763
|
+
...review.data,
|
|
764
|
+
findings: reviewFindings
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
requirements: [
|
|
768
|
+
'必须根据 review 修正后输出最终题目。',
|
|
769
|
+
'只输出 practiceDraft.questions 中的题目,不新增题目,不删除题目,不改变题目 ID。',
|
|
770
|
+
'修订版必须包含题目、题型、难度、knowledgePointIds、knowledgePoints、expectedErrorTypes、配图字段和答题空间行数。',
|
|
771
|
+
isAbilityAssessment ? '能力评估修订版还必须保留 abilityIds、skillAtoms、expectedAbilityErrors。' : '',
|
|
772
|
+
'knowledgePoints 和 expectedErrorTypes 必须是纯文本短标签,不能包含 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装。',
|
|
773
|
+
isAbilityAssessment ? 'skillAtoms 和 expectedAbilityErrors 必须是纯文本短标签,不能包含 $...$、\\(...\\)、\\[...\\]、HTML 或公式包装。' : '',
|
|
774
|
+
'不要输出 answer、solutionSteps 或 rubric;这些字段会在题目定稿后分批补全。',
|
|
775
|
+
'必须修复 LaTeX 反斜杠损坏,并同步校准几何/SVG 题的题干与图中角标、垂足、切点、边名、长度和平行/垂直标注。',
|
|
776
|
+
'不要输出未修正草稿。'
|
|
777
|
+
].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
|
+
}
|
|
799
|
+
}), 'revision');
|
|
800
|
+
revisionChunks.push(revisionChunk);
|
|
801
|
+
}
|
|
802
|
+
const revisionQuestions = revisionChunks.flatMap((chunk) => chunk.questions);
|
|
803
|
+
const firstRevision = revisionChunks[0] || {};
|
|
804
|
+
const revision = {
|
|
805
|
+
title: normalizePracticeTitle(firstRevision.title || practiceDraft.title),
|
|
806
|
+
personalizationBasis: firstRevision.personalizationBasis || practiceDraft.personalizationBasis || [],
|
|
807
|
+
revisionSummary: revisionChunks
|
|
808
|
+
.map((chunk) => chunk.revisionSummary)
|
|
809
|
+
.filter(Boolean)
|
|
810
|
+
.join('\n'),
|
|
811
|
+
questions: revisionQuestions
|
|
812
|
+
};
|
|
813
|
+
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
|
+
});
|
|
850
|
+
if (!finalReview.ok || !Array.isArray(finalReview.data?.findings)) {
|
|
851
|
+
const attempts = finalReview.attempts ? ` attempts=${finalReview.attempts}` : '';
|
|
852
|
+
throw generationError(`final_review_${finalReview.reason || 'invalid_agent_response'}`, `${finalReview.detail || ''}${attempts}`);
|
|
853
|
+
}
|
|
854
|
+
if (revisionCoverage.outsidePointIds.length) {
|
|
855
|
+
throw generationError('coverage_outside_target', revisionCoverage.outsidePointIds.join(','));
|
|
856
|
+
}
|
|
857
|
+
if (!isAbilityAssessment && revisionCoverage.missingPointIds.length) {
|
|
858
|
+
throw generationError('coverage_missing_targets', revisionCoverage.missingPointIds.join(','));
|
|
859
|
+
}
|
|
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
|
+
);
|
|
868
|
+
}
|
|
869
|
+
onProgress({ step: 'practice_generate.final_review_done', message: `最终复审通过:${finalReview.data.findings.length} 条提示。` });
|
|
870
|
+
const answerChunks = [];
|
|
871
|
+
const answerQuestionChunks = chunkArray(revision.questions, 3);
|
|
872
|
+
for (let index = 0; index < answerQuestionChunks.length; index += 1) {
|
|
873
|
+
const questions = answerQuestionChunks[index];
|
|
874
|
+
const questionIds = questions.map((question) => question.id);
|
|
875
|
+
onProgress({
|
|
876
|
+
step: 'practice_generate.answers',
|
|
877
|
+
message: `正在生成标准答案和讲解:第 ${index + 1}/${answerQuestionChunks.length} 批(${questionIds.join('、')})。`
|
|
878
|
+
});
|
|
879
|
+
const answerChunk = assertAgentQuestions(await callPracticeAgent({
|
|
880
|
+
promptName: 'practice-generate.system.md',
|
|
881
|
+
task: `为定稿题目补全答案元数据(第 ${index + 1}/${answerQuestionChunks.length} 批)`,
|
|
882
|
+
context: contextForAnswerMetadata(baseContext, questions),
|
|
883
|
+
requirements: [
|
|
884
|
+
'只输出输入 questions 中已有题目的 id、answer、solutionSteps、rubric。',
|
|
885
|
+
'不得修改题目 ID、题干、题型、难度、knowledgePointIds、knowledgePoints、expectedErrorTypes、abilityIds、skillAtoms、expectedAbilityErrors、score、answerSpaceLines、svg 或 imagePrompt。',
|
|
886
|
+
'answer 必须是可核对的标准答案;选择题写正确选项和结论,填空/问答题写完整答案。',
|
|
887
|
+
'solutionSteps 必须是 2-5 步中文解题过程,能够解释孩子错在哪里。',
|
|
888
|
+
'rubric 必须可用于批改,至少包含关键结论和关键过程;各项 score 之和应接近题目 score。',
|
|
889
|
+
'公式继续使用 LaTeX 分隔符,并保留完整反斜杠。'
|
|
890
|
+
],
|
|
891
|
+
schema: answerMetadataSchema()
|
|
892
|
+
}), 'answer_generation');
|
|
893
|
+
answerChunks.push(answerChunk);
|
|
894
|
+
}
|
|
895
|
+
const enrichedQuestions = mergeAnswerMetadata(revision.questions, answerChunks);
|
|
896
|
+
onProgress({ step: 'practice_generate.answers_done', message: `答案和讲解已补全:${enrichedQuestions.length} 题。` });
|
|
897
|
+
return withProcessDefaults({
|
|
898
|
+
...revision,
|
|
899
|
+
questions: enrichedQuestions,
|
|
900
|
+
coveragePlan: practiceDraft.coveragePlan || questionDraft.coveragePlan || null,
|
|
901
|
+
source: 'agent',
|
|
902
|
+
llmReview: finalReview.data,
|
|
903
|
+
initialLlmReview: review.data,
|
|
904
|
+
revision: {
|
|
905
|
+
summary: revision.revisionSummary || ''
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
}
|