@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,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
|
+
}
|