@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,846 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdir, readdir, writeFile } from 'node:fs/promises';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import {
7
+ buildCorrectionPackMarkdown,
8
+ buildKnowledgeEvidence,
9
+ buildPositiveReportMarkdown,
10
+ buildWeakPoints,
11
+ gradingStats,
12
+ syncKnowledgeMasteryMarkers
13
+ } from './knowledgeFeedback.js';
14
+ import {
15
+ chapterDataPaths,
16
+ ensureChapterDataDirs,
17
+ paths,
18
+ readJson,
19
+ relativeDataPath,
20
+ writeJson
21
+ } from './fileStore.js';
22
+ import { callChatAgent, callVisionAgent } from './agentClient.js';
23
+ import { promptPayload, readPrompt } from './promptStore.js';
24
+ import { readPractice } from './practiceService.js';
25
+ import {
26
+ confirmSubmission,
27
+ writeSubmissionMirrors
28
+ } from './submissionService.js';
29
+
30
+ const execFileAsync = promisify(execFile);
31
+
32
+ async function appendLog(logPath, event) {
33
+ if (!logPath) return;
34
+ await writeFile(logPath, `${JSON.stringify(event)}\n`, { flag: 'a' });
35
+ }
36
+
37
+ function progressMessage(event) {
38
+ switch (event.step) {
39
+ case 'submission.start':
40
+ return `开始批改提交 ${event.submissionId},练习 ${event.practiceId}`;
41
+ case 'pdf_render.start':
42
+ return '正在把 PDF 渲染成逐页图片';
43
+ case 'pdf_render.done':
44
+ return `PDF 渲染完成,共 ${event.pageCount} 页`;
45
+ case 'page_extract.start':
46
+ return `正在识别第 ${event.page}/${event.totalPages || '?'} 页作答`;
47
+ case 'page_extract.done':
48
+ return `第 ${event.page}/${event.totalPages || '?'} 页识别完成,提取 ${event.answers} 条作答片段`;
49
+ case 'practice_match.failed':
50
+ return `上传 PDF 与当前试卷不匹配:${event.mismatchPages?.join('、') || '未知页'} 页题干不一致`;
51
+ case 'stitch.start':
52
+ return '正在按题号合并跨页作答片段';
53
+ case 'stitch.done':
54
+ return `作答拼接完成,${event.answersFound}/${event.questions} 题找到作答内容`;
55
+ case 'grading.start':
56
+ return `开始逐题批改,共 ${event.questions} 题,每批 ${event.batchSize} 题`;
57
+ case 'grading_batch.start':
58
+ return `正在批改第 ${event.batch}/${event.totalBatches} 批:${(event.questionIds || []).join('、')}`;
59
+ case 'grading_batch.done':
60
+ return `第 ${event.batch}/${event.totalBatches} 批批改完成,已批 ${event.gradedQuestions} 题`;
61
+ case 'grading.done':
62
+ return `逐题批改完成,共 ${event.gradedQuestions} 题,来源:${event.source}`;
63
+ case 'recheck.plan':
64
+ return event.maxRechecks > 0 ? `准备二次复核,最多复核 ${event.maxRechecks} 题` : '跳过二次复核';
65
+ case 'recheck.start':
66
+ return `正在二次复核 ${event.questionId}`;
67
+ case 'recheck.skipped':
68
+ return `${event.questionId} 二次复核跳过:${event.reason}`;
69
+ case 'recheck.done':
70
+ return `${event.questionId} 二次复核完成:${event.initialStatus} -> ${event.finalStatus}`;
71
+ case 'artifacts.start':
72
+ return '正在生成批改文件、知识点证据、薄弱点和讲解包';
73
+ case 'artifacts.done':
74
+ return `文件输出完成,结构核对${event.selfCheckPassed ? '通过' : '未通过'},薄弱点 ${event.weakPoints} 个`;
75
+ case 'archive.start':
76
+ return '正在归档并更新知识点掌握状态';
77
+ case 'archive.done':
78
+ return `归档完成,正确率 ${event.accuracy}%`;
79
+ case 'archive.skipped':
80
+ return '跳过归档:本次只输出批改文件,不更新掌握状态';
81
+ case 'submission.done':
82
+ return `批改流程完成,正确率 ${event.stats?.accuracy ?? 0}%`;
83
+ default:
84
+ return event.step;
85
+ }
86
+ }
87
+
88
+ function createProgressLogger({ logPath, onProgress }) {
89
+ return async function log(step, data = {}) {
90
+ const event = {
91
+ ts: new Date().toISOString(),
92
+ step,
93
+ ...data
94
+ };
95
+ event.message ||= progressMessage(event);
96
+ await appendLog(logPath, event);
97
+ if (onProgress) onProgress(event);
98
+ };
99
+ }
100
+
101
+ function argSafeFilename(filePath) {
102
+ return path.basename(filePath, path.extname(filePath)).replace(/[^\p{L}\p{N}.-]+/gu, '-').slice(0, 80);
103
+ }
104
+
105
+ function questionNumber(questionId) {
106
+ return Number(String(questionId || '').replace(/^q/, '')) || 0;
107
+ }
108
+
109
+ function sortQuestionIds(ids) {
110
+ return [...ids].sort((a, b) => questionNumber(a) - questionNumber(b));
111
+ }
112
+
113
+ function sortPageNumbers(pages) {
114
+ return [...pages].sort((a, b) => Number(a) - Number(b));
115
+ }
116
+
117
+ function chunk(items, size) {
118
+ const chunks = [];
119
+ for (let index = 0; index < items.length; index += size) {
120
+ chunks.push(items.slice(index, index + size));
121
+ }
122
+ return chunks;
123
+ }
124
+
125
+ function pdfGradingError(reason, detail = '') {
126
+ const error = new Error(`pdf_grading_failed:${reason}`);
127
+ error.status = 502;
128
+ error.reason = reason;
129
+ error.detail = detail;
130
+ return error;
131
+ }
132
+
133
+ function compactPracticeForPrompt(practice) {
134
+ return {
135
+ id: practice.id,
136
+ title: practice.title,
137
+ chapterId: practice.chapterId,
138
+ chapterTitle: practice.chapterTitle,
139
+ questions: (practice.questions || []).map((question) => ({
140
+ id: question.id,
141
+ displayNumber: questionNumber(question.id),
142
+ stem: question.stem,
143
+ questionKind: question.questionKind || 'short_answer',
144
+ difficulty: question.difficulty || 'basic',
145
+ score: question.score,
146
+ knowledgePointIds: question.knowledgePointIds || [],
147
+ knowledgePoints: question.knowledgePoints || [],
148
+ expectedErrorTypes: question.expectedErrorTypes || [],
149
+ abilityIds: question.abilityIds || [],
150
+ skillAtoms: question.skillAtoms || [],
151
+ expectedAbilityErrors: question.expectedAbilityErrors || [],
152
+ answer: question.answer || '',
153
+ solutionSteps: Array.isArray(question.solutionSteps) ? question.solutionSteps : [],
154
+ rubric: Array.isArray(question.rubric) ? question.rubric : []
155
+ }))
156
+ };
157
+ }
158
+
159
+ async function commandExists(command) {
160
+ try {
161
+ await execFileAsync(command, ['-v']);
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ async function renderPdfPages({ pdfPath, pagesDir }) {
169
+ await mkdir(pagesDir, { recursive: true });
170
+ if (!(await commandExists('pdftoppm'))) {
171
+ const error = new Error('missing_pdftoppm');
172
+ error.hint = 'Install poppler: brew install poppler';
173
+ throw error;
174
+ }
175
+ const prefix = path.join(pagesDir, 'page');
176
+ await execFileAsync('pdftoppm', ['-png', '-r', '180', pdfPath, prefix], { maxBuffer: 1024 * 1024 * 20 });
177
+ const files = (await readdir(pagesDir))
178
+ .filter((file) => /^page-\d+\.png$/.test(file))
179
+ .sort();
180
+ return files.map((file, index) => ({
181
+ page: index + 1,
182
+ imagePath: path.join(pagesDir, file)
183
+ }));
184
+ }
185
+
186
+ function normalizePageExtract(data, page) {
187
+ const visibleQuestionIds = Array.isArray(data?.visibleQuestionIds) ? data.visibleQuestionIds : [];
188
+ const answers = Array.isArray(data?.answers) ? data.answers : [];
189
+ return {
190
+ page,
191
+ visibleQuestionIds: sortQuestionIds(visibleQuestionIds.filter(Boolean)),
192
+ answers: answers
193
+ .filter((item) => item?.questionId)
194
+ .map((item) => ({
195
+ questionId: item.questionId,
196
+ recognizedAnswerFragment: item.recognizedAnswerFragment || item.recognizedAnswer || '',
197
+ position: item.position || '',
198
+ confidence: item.confidence || 'medium',
199
+ isContinuation: Boolean(item.isContinuation),
200
+ continuesToNextPage: Boolean(item.continuesToNextPage),
201
+ evidence: item.evidence || '',
202
+ needsReview: Boolean(item.needsReview)
203
+ })),
204
+ warnings: Array.isArray(data?.warnings) ? data.warnings : [],
205
+ source: data?.source || 'agent'
206
+ };
207
+ }
208
+
209
+ function practiceMismatchWarnings(pageExtracts) {
210
+ const mismatchPattern = /practice_mismatch|题干不一致|题面不一致|内容不一致|同编号题干不一致|题号对应冲突|not match|mismatch/i;
211
+ return pageExtracts
212
+ .map((extract) => {
213
+ const warnings = (extract.warnings || []).filter((warning) => mismatchPattern.test(String(warning || '')));
214
+ return warnings.length ? { page: extract.page, warnings } : null;
215
+ })
216
+ .filter(Boolean);
217
+ }
218
+
219
+ function assertPracticeMatchesSubmission(practice, pageExtracts) {
220
+ const mismatches = practiceMismatchWarnings(pageExtracts);
221
+ if (!mismatches.length) return;
222
+ const pageList = mismatches.map((item) => `page ${item.page}: ${item.warnings.join(';')}`).join(' | ');
223
+ throw pdfGradingError(
224
+ 'practice_mismatch',
225
+ `Uploaded PDF does not match selected practice ${practice.id}. ${pageList}`
226
+ );
227
+ }
228
+
229
+ async function extractPageAnswers({
230
+ practice,
231
+ pageImage,
232
+ outputPath,
233
+ useAgent = true,
234
+ totalPages = null,
235
+ log = async () => {}
236
+ }) {
237
+ if (!useAgent) {
238
+ throw pdfGradingError('agent_required', 'PDF extraction requires the configured AI agent.');
239
+ }
240
+
241
+ const systemPrompt = await readPrompt('pdf-page-extract.system.md');
242
+ const agent = await callVisionAgent({
243
+ timeoutMs: 90000,
244
+ retries: 1,
245
+ system: systemPrompt,
246
+ text: promptPayload({
247
+ task: '逐页识别学生作答。只记录本页可见题号和答案片段,不进行批改。要特别标记跨页、续写、低置信度和看不清。',
248
+ requirements: [
249
+ '不要推测看不清的内容。',
250
+ '题号必须来自 practice.questions[].id。',
251
+ '必须核对页面中可见题干和 practice 中同编号题干是否一致;如果同编号题干明显不一致,在 warnings 中写入 practice_mismatch,并不要把该页作答当作该题答案。',
252
+ '一题可能跨页;只输出本页片段,使用 isContinuation 和 continuesToNextPage 标记。',
253
+ '如果只看到题目没有看到作答,也可以把题号放入 visibleQuestionIds。'
254
+ ],
255
+ context: {
256
+ page: pageImage.page,
257
+ practice: compactPracticeForPrompt(practice)
258
+ },
259
+ schema: {
260
+ page: pageImage.page,
261
+ visibleQuestionIds: ['q1'],
262
+ answers: [
263
+ {
264
+ questionId: 'q1',
265
+ recognizedAnswerFragment: 'string',
266
+ position: 'page_top|page_middle|page_bottom|unknown',
267
+ confidence: 'high|medium|low',
268
+ isContinuation: false,
269
+ continuesToNextPage: false,
270
+ evidence: 'string',
271
+ needsReview: false
272
+ }
273
+ ],
274
+ warnings: ['string']
275
+ }
276
+ }),
277
+ imagePaths: [pageImage.imagePath]
278
+ });
279
+
280
+ if (!agent.ok) {
281
+ throw pdfGradingError(`page_extract_${agent.reason || 'agent_failed'}`, agent.detail || '');
282
+ }
283
+ const extract = normalizePageExtract(agent.data, pageImage.page);
284
+ extract.agentReason = null;
285
+ await writeJson(outputPath, extract);
286
+ await log('page_extract.done', {
287
+ page: pageImage.page,
288
+ totalPages,
289
+ source: extract.source,
290
+ answers: extract.answers.length,
291
+ visibleQuestionIds: extract.visibleQuestionIds.length,
292
+ warnings: extract.warnings.length,
293
+ agentReason: extract.agentReason
294
+ });
295
+ return extract;
296
+ }
297
+
298
+ export function stitchPageAnswers(practice, pageExtracts) {
299
+ const byQuestion = new Map();
300
+ for (const extract of pageExtracts) {
301
+ for (const answer of extract.answers || []) {
302
+ const current = byQuestion.get(answer.questionId) || {
303
+ questionId: answer.questionId,
304
+ sourcePages: [],
305
+ fragments: [],
306
+ mergedAnswer: '',
307
+ stitchingConfidence: 'high',
308
+ warnings: []
309
+ };
310
+ current.sourcePages.push(extract.page);
311
+ current.fragments.push({
312
+ page: extract.page,
313
+ text: answer.recognizedAnswerFragment || '',
314
+ confidence: answer.confidence || 'medium',
315
+ isContinuation: Boolean(answer.isContinuation),
316
+ continuesToNextPage: Boolean(answer.continuesToNextPage),
317
+ evidence: answer.evidence || ''
318
+ });
319
+ if (answer.needsReview) current.warnings.push(`page_${extract.page}_needs_review`);
320
+ if (answer.confidence === 'low') current.stitchingConfidence = 'low';
321
+ if (answer.confidence === 'medium' && current.stitchingConfidence !== 'low') current.stitchingConfidence = 'medium';
322
+ byQuestion.set(answer.questionId, current);
323
+ }
324
+ }
325
+
326
+ const stitched = (practice.questions || []).map((question) => {
327
+ const current = byQuestion.get(question.id) || {
328
+ questionId: question.id,
329
+ sourcePages: [],
330
+ fragments: [],
331
+ mergedAnswer: '',
332
+ stitchingConfidence: 'low',
333
+ warnings: ['answer_not_found']
334
+ };
335
+ current.sourcePages = sortPageNumbers([...new Set(current.sourcePages.map(String))])
336
+ .map((page) => Number(page));
337
+ current.fragments.sort((a, b) => a.page - b.page);
338
+ current.mergedAnswer = current.fragments.map((fragment) => fragment.text).filter(Boolean).join('\n');
339
+ if (current.fragments.some((fragment) => fragment.continuesToNextPage || fragment.isContinuation)) {
340
+ current.warnings.push('cross_page_or_continuation');
341
+ }
342
+ current.warnings = [...new Set(current.warnings)];
343
+ return current;
344
+ });
345
+
346
+ return stitched.sort((a, b) => questionNumber(a.questionId) - questionNumber(b.questionId));
347
+ }
348
+
349
+ function normalizeGradingResult(data, practice, stitchedAnswers) {
350
+ const stitchedByQuestion = new Map(stitchedAnswers.map((item) => [item.questionId, item]));
351
+ const items = Array.isArray(data?.grading) ? data.grading : [];
352
+ const byQuestion = new Map(items.filter((item) => item?.questionId).map((item) => [item.questionId, item]));
353
+ const grading = (practice.questions || []).map((question) => {
354
+ const item = byQuestion.get(question.id) || {};
355
+ const maxScore = Number(item.maxScore ?? question.score ?? 0);
356
+ const score = Math.max(0, Math.min(maxScore, Number(item.score ?? 0)));
357
+ const stitched = stitchedByQuestion.get(question.id);
358
+ return {
359
+ questionId: question.id,
360
+ sourcePages: item.sourcePages || stitched?.sourcePages || [],
361
+ recognizedAnswer: item.recognizedAnswer || stitched?.mergedAnswer || '',
362
+ status: ['correct', 'partial', 'wrong', 'unrecognized', 'needs_review'].includes(item.status)
363
+ ? item.status
364
+ : 'needs_review',
365
+ score,
366
+ maxScore,
367
+ confidence: item.confidence || stitched?.stitchingConfidence || 'medium',
368
+ errorTypes: Array.isArray(item.errorTypes) ? item.errorTypes : [],
369
+ comment: item.comment || '',
370
+ positiveFeedback: item.positiveFeedback || '',
371
+ mistakeSummary: item.mistakeSummary || '',
372
+ referenceAnswer: item.referenceAnswer || question.answer || '',
373
+ solutionMethod: Array.isArray(item.solutionMethod)
374
+ ? item.solutionMethod
375
+ : Array.isArray(question.solutionSteps)
376
+ ? question.solutionSteps
377
+ : [],
378
+ teachingHint: item.teachingHint || '',
379
+ reviewPasses: Number(item.reviewPasses || 1),
380
+ initialStatus: item.initialStatus || item.status || null,
381
+ reviewReason: item.reviewReason || ''
382
+ };
383
+ });
384
+
385
+ return {
386
+ source: data?.source || 'agent',
387
+ summary: data?.summary || '',
388
+ masterySignals: Array.isArray(data?.masterySignals) ? data.masterySignals : [],
389
+ nextPracticeSuggestion: data?.nextPracticeSuggestion || '',
390
+ grading
391
+ };
392
+ }
393
+
394
+ async function gradeStitchedAnswersBatch({ practice, stitchedAnswers }) {
395
+ const systemPrompt = await readPrompt('pdf-grading.system.md');
396
+ const agent = await callChatAgent({
397
+ timeoutMs: 120000,
398
+ retries: 1,
399
+ system: systemPrompt,
400
+ user: promptPayload({
401
+ task: '根据练习题目、标准答案和拼接后的学生作答完成逐题批改。练习 JSON 必须提供答案元数据;尽量给出 correct/partial/wrong/unrecognized,少用 needs_review。',
402
+ requirements: [
403
+ 'practice 是批改的唯一题目结构来源。',
404
+ '必须使用题目自带 answer、solutionSteps、rubric 作为 referenceAnswer、讲解和评分依据。',
405
+ '不能臆造学生答案。',
406
+ '每题必须输出 grading。',
407
+ '正确题可以只给简短 referenceAnswer 和 positiveFeedback;错误或部分正确题必须输出 mistakeSummary、solutionMethod、teachingHint,方便讲解纠错。',
408
+ 'errorTypes 应尽量使用题目 expectedErrorTypes 中的错因标签,也可补充更具体的错因。',
409
+ '分数不能超过 maxScore。'
410
+ ],
411
+ context: {
412
+ practice: compactPracticeForPrompt(practice),
413
+ stitchedAnswers
414
+ },
415
+ schema: {
416
+ summary: 'string',
417
+ masterySignals: ['string'],
418
+ nextPracticeSuggestion: 'string',
419
+ grading: [
420
+ {
421
+ questionId: 'q1',
422
+ sourcePages: [1],
423
+ recognizedAnswer: 'string',
424
+ status: 'correct|partial|wrong|unrecognized|needs_review',
425
+ score: 0,
426
+ maxScore: 8,
427
+ confidence: 'high|medium|low',
428
+ positiveFeedback: 'string',
429
+ errorTypes: ['string'],
430
+ comment: 'string',
431
+ mistakeSummary: 'string',
432
+ referenceAnswer: 'string',
433
+ solutionMethod: ['string'],
434
+ teachingHint: 'string'
435
+ }
436
+ ]
437
+ }
438
+ })
439
+ });
440
+
441
+ if (!agent.ok) {
442
+ throw pdfGradingError(`grading_${agent.reason || 'agent_failed'}`, agent.detail || '');
443
+ }
444
+ return normalizeGradingResult({ ...agent.data, source: 'agent' }, practice, stitchedAnswers);
445
+ }
446
+
447
+ async function gradeStitchedAnswers({
448
+ practice,
449
+ stitchedAnswers,
450
+ useAgent = true,
451
+ batchSize = 5,
452
+ log = async () => {}
453
+ }) {
454
+ if (!useAgent) {
455
+ throw pdfGradingError('agent_required', 'PDF grading requires the configured AI agent.');
456
+ }
457
+
458
+ const batches = chunk(practice.questions || [], batchSize);
459
+ const grading = [];
460
+ const summaries = [];
461
+ const masterySignals = [];
462
+ const suggestions = [];
463
+
464
+ for (const questions of batches) {
465
+ await log('grading_batch.start', {
466
+ batch: grading.length ? Math.floor(grading.length / batchSize) + 1 : 1,
467
+ totalBatches: batches.length,
468
+ questionIds: questions.map((question) => question.id)
469
+ });
470
+ const ids = new Set(questions.map((question) => question.id));
471
+ const batchPractice = { ...practice, questions };
472
+ const batchStitched = stitchedAnswers.filter((item) => ids.has(item.questionId));
473
+ const result = await gradeStitchedAnswersBatch({
474
+ practice: batchPractice,
475
+ stitchedAnswers: batchStitched
476
+ });
477
+ grading.push(...result.grading);
478
+ if (result.summary) summaries.push(result.summary);
479
+ masterySignals.push(...(result.masterySignals || []));
480
+ if (result.nextPracticeSuggestion) suggestions.push(result.nextPracticeSuggestion);
481
+ await log('grading_batch.done', {
482
+ batch: Math.ceil(grading.length / batchSize),
483
+ totalBatches: batches.length,
484
+ source: result.source,
485
+ gradedQuestions: result.grading.length,
486
+ agentReason: null
487
+ });
488
+ }
489
+
490
+ return {
491
+ source: 'agent',
492
+ agentReason: null,
493
+ summary: summaries.join('\n'),
494
+ masterySignals: [...new Set(masterySignals)].slice(0, 20),
495
+ nextPracticeSuggestion: suggestions.filter(Boolean).slice(0, 3).join('\n'),
496
+ grading: grading.sort((a, b) => questionNumber(a.questionId) - questionNumber(b.questionId))
497
+ };
498
+ }
499
+
500
+ function questionsForRecheck(gradingResult, stitchedAnswers) {
501
+ const stitchedByQuestion = new Map(stitchedAnswers.map((item) => [item.questionId, item]));
502
+ return gradingResult.grading
503
+ .filter((item) => {
504
+ const stitched = stitchedByQuestion.get(item.questionId);
505
+ return item.status === 'needs_review' ||
506
+ item.status === 'unrecognized' ||
507
+ item.confidence === 'low' ||
508
+ stitched?.warnings?.includes('cross_page_or_continuation');
509
+ })
510
+ .map((item) => item.questionId);
511
+ }
512
+
513
+ async function recheckQuestions({
514
+ practice,
515
+ gradingResult,
516
+ stitchedAnswers,
517
+ pageImages,
518
+ maxRechecks = 12,
519
+ log = async () => {}
520
+ }) {
521
+ if (maxRechecks <= 0) return { gradingResult, recheckedQuestionIds: [] };
522
+ if (gradingResult.source !== 'agent') {
523
+ return { gradingResult, recheckedQuestionIds: [] };
524
+ }
525
+ const questionIds = questionsForRecheck(gradingResult, stitchedAnswers).slice(0, maxRechecks);
526
+ if (!questionIds.length) return { gradingResult, recheckedQuestionIds: [] };
527
+
528
+ const systemPrompt = await readPrompt('pdf-recheck.system.md');
529
+ const questions = new Map((practice.questions || []).map((question) => [question.id, question]));
530
+ const stitchedByQuestion = new Map(stitchedAnswers.map((item) => [item.questionId, item]));
531
+ const pageByNumber = new Map(pageImages.map((item) => [item.page, item.imagePath]));
532
+ const nextByQuestion = new Map(gradingResult.grading.map((item) => [item.questionId, item]));
533
+ const recheckedQuestionIds = [];
534
+
535
+ for (const questionId of questionIds) {
536
+ await log('recheck.start', { questionId });
537
+ const question = questions.get(questionId);
538
+ const stitched = stitchedByQuestion.get(questionId);
539
+ if (!question || !stitched) continue;
540
+ const imagePaths = (stitched.sourcePages || []).map((page) => pageByNumber.get(page)).filter(Boolean);
541
+ if (!imagePaths.length) continue;
542
+ const current = nextByQuestion.get(questionId);
543
+ const agent = await callVisionAgent({
544
+ timeoutMs: 90000,
545
+ retries: 1,
546
+ system: systemPrompt,
547
+ text: promptPayload({
548
+ task: '对单题进行自动二次复核。目标是尽量减少 needs_review,但不能猜测看不清内容。',
549
+ requirements: [
550
+ '只复核这一题。',
551
+ '可以改判为 correct/partial/wrong/unrecognized。',
552
+ '只有题号或作答证据仍矛盾时才保留 needs_review。',
553
+ '保留 initialStatus 和说明改判原因。'
554
+ ],
555
+ context: {
556
+ question: compactPracticeForPrompt({ ...practice, questions: [question] }).questions[0],
557
+ stitchedAnswer: stitched,
558
+ initialGrading: current
559
+ },
560
+ schema: {
561
+ questionId,
562
+ recognizedAnswer: 'string',
563
+ status: 'correct|partial|wrong|unrecognized|needs_review',
564
+ score: 0,
565
+ maxScore: question.score || 0,
566
+ confidence: 'high|medium|low',
567
+ positiveFeedback: 'string',
568
+ errorTypes: ['string'],
569
+ comment: 'string',
570
+ mistakeSummary: 'string',
571
+ referenceAnswer: 'string',
572
+ solutionMethod: ['string'],
573
+ teachingHint: 'string',
574
+ reviewReason: 'string'
575
+ }
576
+ }),
577
+ imagePaths
578
+ });
579
+ if (!agent.ok || agent.data?.questionId !== questionId) {
580
+ await log('recheck.skipped', {
581
+ questionId,
582
+ reason: agent.ok ? 'question_id_mismatch' : agent.reason
583
+ });
584
+ continue;
585
+ }
586
+ const normalized = normalizeGradingResult(
587
+ { grading: [{ ...agent.data, sourcePages: stitched.sourcePages }] },
588
+ { ...practice, questions: [question] },
589
+ [stitched]
590
+ ).grading[0];
591
+ nextByQuestion.set(questionId, {
592
+ ...current,
593
+ ...normalized,
594
+ initialStatus: current.status,
595
+ reviewPasses: Number(current.reviewPasses || 1) + 1,
596
+ reviewReason: normalized.reviewReason || agent.data.reviewReason || '自动二次复核'
597
+ });
598
+ recheckedQuestionIds.push(questionId);
599
+ await log('recheck.done', {
600
+ questionId,
601
+ initialStatus: current.status,
602
+ finalStatus: normalized.status
603
+ });
604
+ }
605
+
606
+ return {
607
+ gradingResult: {
608
+ ...gradingResult,
609
+ grading: gradingResult.grading.map((item) => nextByQuestion.get(item.questionId) || item)
610
+ },
611
+ recheckedQuestionIds
612
+ };
613
+ }
614
+
615
+ function selfCheck({ practice, gradingResult, recheckedQuestionIds }) {
616
+ const expectedIds = new Set((practice.questions || []).map((question) => question.id));
617
+ const actualIds = new Set((gradingResult.grading || []).map((item) => item.questionId));
618
+ const missingQuestionIds = [...expectedIds].filter((id) => !actualIds.has(id));
619
+ const unknownQuestionIds = [...actualIds].filter((id) => !expectedIds.has(id));
620
+ const scoreIssues = (gradingResult.grading || [])
621
+ .filter((item) => Number(item.score || 0) > Number(item.maxScore || 0) || Number(item.score || 0) < 0)
622
+ .map((item) => item.questionId);
623
+ return {
624
+ passed: !missingQuestionIds.length && !unknownQuestionIds.length && !scoreIssues.length,
625
+ missingQuestionIds,
626
+ unknownQuestionIds,
627
+ scoreIssues,
628
+ recheckedQuestionIds
629
+ };
630
+ }
631
+
632
+ function shouldAutoArchive({ gradingResult, forceArchive }) {
633
+ if (forceArchive) return true;
634
+ if (gradingResult.source !== 'agent') return false;
635
+ const stats = gradingStats(gradingResult.grading || []);
636
+ return stats.totalQuestions > 0 && stats.needsReview <= Math.ceil(stats.totalQuestions * 0.15);
637
+ }
638
+
639
+ export async function gradePdfSubmission({
640
+ practiceId,
641
+ pdfPath,
642
+ notes = '',
643
+ autoArchive = true,
644
+ forceArchive = false,
645
+ submissionId = '',
646
+ useAgent = true,
647
+ maxRechecks = 12,
648
+ gradingBatchSize = 5,
649
+ onProgress = null
650
+ }) {
651
+ const resolvedPdfPath = path.resolve(pdfPath);
652
+ const practice = await readPractice(practiceId);
653
+ const chapterPaths = await ensureChapterDataDirs(practice.chapterId);
654
+ const id = submissionId || `submission-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}`;
655
+ const submissionDir = path.join(chapterPaths.submissions, id);
656
+ const pagesDir = path.join(submissionDir, 'pages');
657
+ const pageExtractsDir = path.join(submissionDir, 'page_extracts');
658
+ await mkdir(pageExtractsDir, { recursive: true });
659
+ const processLogPath = path.join(submissionDir, 'process_log.jsonl');
660
+ await writeFile(processLogPath, '', 'utf8');
661
+ const log = createProgressLogger({ logPath: processLogPath, onProgress });
662
+ await log('submission.start', {
663
+ submissionId: id,
664
+ practiceId: practice.id,
665
+ pdfPath: relativeDataPath(resolvedPdfPath),
666
+ useAgent,
667
+ autoArchive,
668
+ forceArchive
669
+ });
670
+
671
+ const submission = {
672
+ id,
673
+ practiceId: practice.id,
674
+ chapterId: practice.chapterId,
675
+ status: 'pdf_received',
676
+ notes,
677
+ pdfPath: relativeDataPath(resolvedPdfPath),
678
+ photoPaths: [],
679
+ gradingResult: null,
680
+ parentConfirmed: false,
681
+ createdAt: new Date().toISOString()
682
+ };
683
+ await writeSubmissionMirrors(submission);
684
+
685
+ await log('pdf_render.start', { pdfPath: relativeDataPath(resolvedPdfPath) });
686
+ const pageImages = await renderPdfPages({ pdfPath: resolvedPdfPath, pagesDir });
687
+ await log('pdf_render.done', {
688
+ pageCount: pageImages.length,
689
+ pagesDir: relativeDataPath(pagesDir)
690
+ });
691
+ const pageExtracts = [];
692
+ for (const pageImage of pageImages) {
693
+ await log('page_extract.start', {
694
+ page: pageImage.page,
695
+ totalPages: pageImages.length,
696
+ imagePath: relativeDataPath(pageImage.imagePath)
697
+ });
698
+ const outputPath = path.join(pageExtractsDir, `page-${String(pageImage.page).padStart(3, '0')}.json`);
699
+ pageExtracts.push(await extractPageAnswers({
700
+ practice,
701
+ pageImage,
702
+ outputPath,
703
+ useAgent,
704
+ totalPages: pageImages.length,
705
+ log
706
+ }));
707
+ }
708
+
709
+ const mismatches = practiceMismatchWarnings(pageExtracts);
710
+ if (mismatches.length) {
711
+ await log('practice_match.failed', {
712
+ mismatchPages: mismatches.map((item) => item.page),
713
+ warnings: mismatches
714
+ });
715
+ }
716
+ assertPracticeMatchesSubmission(practice, pageExtracts);
717
+
718
+ await log('stitch.start', { pageExtracts: pageExtracts.length });
719
+ const stitchedAnswers = stitchPageAnswers(practice, pageExtracts);
720
+ const stitchedPath = path.join(submissionDir, 'stitched_answers.json');
721
+ await writeJson(stitchedPath, stitchedAnswers);
722
+ await log('stitch.done', {
723
+ questions: stitchedAnswers.length,
724
+ answersFound: stitchedAnswers.filter((item) => item.mergedAnswer).length,
725
+ warnings: stitchedAnswers.filter((item) => item.warnings?.length).length
726
+ });
727
+
728
+ await log('grading.start', {
729
+ questions: practice.questions.length,
730
+ batchSize: gradingBatchSize
731
+ });
732
+ let gradingResult = await gradeStitchedAnswers({
733
+ practice,
734
+ stitchedAnswers,
735
+ useAgent,
736
+ batchSize: gradingBatchSize,
737
+ log
738
+ });
739
+ await log('grading.done', {
740
+ source: gradingResult.source,
741
+ gradedQuestions: gradingResult.grading.length,
742
+ agentReason: gradingResult.agentReason || null
743
+ });
744
+
745
+ await log('recheck.plan', {
746
+ maxRechecks
747
+ });
748
+ const recheck = await recheckQuestions({
749
+ practice,
750
+ gradingResult,
751
+ stitchedAnswers,
752
+ pageImages,
753
+ maxRechecks,
754
+ log
755
+ });
756
+ gradingResult = recheck.gradingResult;
757
+
758
+ await log('artifacts.start');
759
+ const check = selfCheck({ practice, gradingResult, recheckedQuestionIds: recheck.recheckedQuestionIds });
760
+ const knowledgeEvidence = buildKnowledgeEvidence({ practice, grading: gradingResult.grading, stitchedAnswers });
761
+ const weakPoints = buildWeakPoints({ practice, grading: gradingResult.grading, knowledgeEvidence });
762
+ const correctionPack = buildCorrectionPackMarkdown({ practice, grading: gradingResult.grading, weakPoints });
763
+ const positiveReport = buildPositiveReportMarkdown({
764
+ practice,
765
+ gradingResult,
766
+ knowledgeEvidence,
767
+ weakPoints,
768
+ selfCheck: check
769
+ });
770
+
771
+ await writeJson(path.join(submissionDir, 'grading.json'), gradingResult);
772
+ await writeJson(path.join(submissionDir, 'knowledge_evidence.json'), knowledgeEvidence);
773
+ await writeJson(path.join(submissionDir, 'weak_points.json'), weakPoints);
774
+ await writeJson(path.join(submissionDir, 'self_check.json'), check);
775
+ await writeFile(path.join(submissionDir, 'correction_pack.md'), correctionPack, 'utf8');
776
+ await writeFile(path.join(submissionDir, 'positive_report.md'), positiveReport, 'utf8');
777
+ await log('artifacts.done', {
778
+ selfCheckPassed: check.passed,
779
+ weakPoints: weakPoints.length
780
+ });
781
+
782
+ const gradedSubmission = {
783
+ ...submission,
784
+ status: 'graded',
785
+ pageImagePaths: pageImages.map((item) => relativeDataPath(item.imagePath)),
786
+ gradingResult,
787
+ artifactPaths: {
788
+ submissionDir: relativeDataPath(submissionDir),
789
+ originalPdf: relativeDataPath(resolvedPdfPath),
790
+ stitchedAnswers: relativeDataPath(stitchedPath),
791
+ grading: relativeDataPath(path.join(submissionDir, 'grading.json')),
792
+ knowledgeEvidence: relativeDataPath(path.join(submissionDir, 'knowledge_evidence.json')),
793
+ weakPoints: relativeDataPath(path.join(submissionDir, 'weak_points.json')),
794
+ correctionPack: relativeDataPath(path.join(submissionDir, 'correction_pack.md')),
795
+ positiveReport: relativeDataPath(path.join(submissionDir, 'positive_report.md')),
796
+ selfCheck: relativeDataPath(path.join(submissionDir, 'self_check.json')),
797
+ processLog: relativeDataPath(processLogPath)
798
+ },
799
+ gradedAt: new Date().toISOString()
800
+ };
801
+ await writeSubmissionMirrors(gradedSubmission);
802
+
803
+ let archivedSubmission = null;
804
+ if (autoArchive && shouldAutoArchive({ gradingResult, forceArchive })) {
805
+ await log('archive.start');
806
+ archivedSubmission = await confirmSubmission({
807
+ submissionId: id,
808
+ grading: gradingResult.grading,
809
+ parentSummary: gradingResult.summary || ''
810
+ });
811
+ if (practice.type !== 'ability_assessment') {
812
+ await syncKnowledgeMasteryMarkers(practice.chapterId);
813
+ }
814
+ await log('archive.done', {
815
+ accuracy: archivedSubmission.accuracy,
816
+ status: archivedSubmission.status
817
+ });
818
+ } else {
819
+ await log('archive.skipped', {
820
+ autoArchive,
821
+ source: gradingResult.source,
822
+ forceArchive,
823
+ needsReview: gradingStats(gradingResult.grading).needsReview
824
+ });
825
+ }
826
+
827
+ await log('submission.done', {
828
+ archived: Boolean(archivedSubmission),
829
+ stats: gradingStats(gradingResult.grading)
830
+ });
831
+
832
+ return {
833
+ submission: archivedSubmission || gradedSubmission,
834
+ archived: Boolean(archivedSubmission),
835
+ practiceId: practice.id,
836
+ chapterId: practice.chapterId,
837
+ pageCount: pageImages.length,
838
+ stats: gradingStats(gradingResult.grading),
839
+ source: gradingResult.source,
840
+ agentReason: gradingResult.agentReason || null,
841
+ selfCheck: check,
842
+ artifacts: gradedSubmission.artifactPaths,
843
+ weakPoints: weakPoints.slice(0, 10),
844
+ pdfLabel: argSafeFilename(resolvedPdfPath)
845
+ };
846
+ }