@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,655 @@
1
+ import './env.js';
2
+ import express from 'express';
3
+ import multer from 'multer';
4
+ import path from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { mkdir, readdir } from 'node:fs/promises';
7
+ import {
8
+ ensureDataDirs,
9
+ paths,
10
+ readJson,
11
+ seedChaptersFromManifest,
12
+ getKnowledgeBundle,
13
+ readChapterMistakes,
14
+ writeJson,
15
+ chapterDataPaths
16
+ } from './fileStore.js';
17
+ import { gradeSubmission } from './grading.js';
18
+ import { extractChapterKnowledge } from './knowledgeExtractor.js';
19
+ import { buildChapterAbilities, syncAbilityIndex, syncChapterAbilities } from './abilityService.js';
20
+ import {
21
+ buildGeneratedCoverage,
22
+ createPractice,
23
+ ensurePracticeHtml,
24
+ practiceSummary,
25
+ readPractice,
26
+ syncGeneratedCoverage,
27
+ updatePracticeStatus
28
+ } from './practiceService.js';
29
+ import { gradePdfSubmission } from './pdfSubmissionGrader.js';
30
+ import {
31
+ addJobEvent,
32
+ completeJob,
33
+ createJob,
34
+ failJob,
35
+ getJob,
36
+ getJobInternal,
37
+ startJob,
38
+ subscribeJob,
39
+ updateJob
40
+ } from './jobStore.js';
41
+ import {
42
+ confirmSubmission,
43
+ deleteSubmission,
44
+ readSubmission,
45
+ writeSubmissionMirrors
46
+ } from './submissionService.js';
47
+ import { weakPointsFromMistakes } from './mistakeLifecycle.js';
48
+ import { readLlmSettings, writeLlmSettings } from './llmConfig.js';
49
+
50
+ const app = express();
51
+ const port = Number(process.env.PORT || 4173);
52
+
53
+ await seedChaptersFromManifest();
54
+
55
+ app.use(express.json({ limit: '2mb' }));
56
+ app.use('/chapter-images', express.static(paths.imageRoot));
57
+ app.use('/chapter-data', express.static(paths.chapterData));
58
+ app.use('/vendor/katex', express.static(path.join(paths.rootDir, 'node_modules/katex/dist')));
59
+
60
+ const upload = multer({
61
+ storage: multer.diskStorage({
62
+ async destination(req, file, callback) {
63
+ const submissionId = req.params.submissionId || `upload-${Date.now()}`;
64
+ const submission = await readSubmission(submissionId).catch(() => null);
65
+ if (!submission?.chapterId) {
66
+ callback(new Error(`submission_not_found:${submissionId}`));
67
+ return;
68
+ }
69
+ const dir = path.join(chapterDataPaths(submission.chapterId).submissions, submissionId, 'photos');
70
+ await mkdir(dir, { recursive: true });
71
+ callback(null, dir);
72
+ },
73
+ filename(req, file, callback) {
74
+ const ext = path.extname(file.originalname) || '.jpg';
75
+ callback(null, `${Date.now()}-${randomUUID()}${ext}`);
76
+ }
77
+ })
78
+ });
79
+
80
+ const pdfUpload = multer({
81
+ storage: multer.diskStorage({
82
+ async destination(req, file, callback) {
83
+ const practice = await readPractice(req.params.id);
84
+ const submissionId = `submission-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}`;
85
+ req.createdSubmissionId = submissionId;
86
+ req.pdfPractice = practice;
87
+ const dir = path.join(chapterDataPaths(practice.chapterId).submissions, submissionId, 'source');
88
+ await mkdir(dir, { recursive: true });
89
+ callback(null, dir);
90
+ },
91
+ filename(req, file, callback) {
92
+ callback(null, 'submission.pdf');
93
+ }
94
+ }),
95
+ fileFilter(req, file, callback) {
96
+ if (file.mimetype === 'application/pdf' || /\.pdf$/i.test(file.originalname)) {
97
+ callback(null, true);
98
+ } else {
99
+ callback(new Error('only_pdf_supported'));
100
+ }
101
+ },
102
+ limits: { fileSize: 50 * 1024 * 1024 }
103
+ });
104
+
105
+ async function refreshAbilityStateAfterLearningReset(chapterId, didReset) {
106
+ if (!didReset) return;
107
+ await syncChapterAbilities(chapterId);
108
+ await syncAbilityIndex();
109
+ }
110
+
111
+ app.get('/api/profile', async (req, res, next) => {
112
+ try {
113
+ res.json(await readJson(paths.profile));
114
+ } catch (error) {
115
+ next(error);
116
+ }
117
+ });
118
+
119
+ app.put('/api/profile', async (req, res, next) => {
120
+ try {
121
+ const current = await readJson(paths.profile);
122
+ const body = req.body || {};
123
+ const updated = {
124
+ ...current,
125
+ name: String(body.name || current.name || '').trim(),
126
+ gender: String(body.gender || current.gender || '').trim(),
127
+ age: Number.isFinite(Number(body.age)) ? Number(body.age) : current.age,
128
+ stage: String(body.stage || current.stage || '').trim(),
129
+ primaryGoal: String(body.primaryGoal || current.primaryGoal || '').trim(),
130
+ priorityTrack: String(body.priorityTrack || current.priorityTrack || '').trim(),
131
+ preferences: String(body.preferences || current.preferences || '').trim(),
132
+ profileNotes: String(body.profileNotes || current.profileNotes || '').trim(),
133
+ updatedAt: new Date().toISOString()
134
+ };
135
+ await writeJson(paths.profile, updated);
136
+ res.json(updated);
137
+ } catch (error) {
138
+ next(error);
139
+ }
140
+ });
141
+
142
+ app.get('/api/settings/llm', async (req, res, next) => {
143
+ try {
144
+ res.json(await readLlmSettings());
145
+ } catch (error) {
146
+ next(error);
147
+ }
148
+ });
149
+
150
+ app.put('/api/settings/llm', async (req, res, next) => {
151
+ try {
152
+ const body = req.body || {};
153
+ res.json(await writeLlmSettings({
154
+ baseUrl: body.baseUrl,
155
+ model: body.model,
156
+ apiKey: body.apiKey,
157
+ clearApiKey: body.clearApiKey
158
+ }));
159
+ } catch (error) {
160
+ next(error);
161
+ }
162
+ });
163
+
164
+ app.get('/api/chapters', async (req, res, next) => {
165
+ try {
166
+ res.json(await readJson(paths.chapters, []));
167
+ } catch (error) {
168
+ next(error);
169
+ }
170
+ });
171
+
172
+ app.get('/api/knowledge', async (req, res, next) => {
173
+ try {
174
+ res.json(await getKnowledgeBundle());
175
+ } catch (error) {
176
+ next(error);
177
+ }
178
+ });
179
+
180
+ app.get('/api/knowledge/:chapterId', async (req, res, next) => {
181
+ try {
182
+ res.json(await getKnowledgeBundle(req.params.chapterId));
183
+ } catch (error) {
184
+ next(error);
185
+ }
186
+ });
187
+
188
+ app.post('/api/knowledge/:chapterId/extract', async (req, res, next) => {
189
+ try {
190
+ const limitPages = Math.max(0, Math.min(50, Number(req.body.limitPages || 0)));
191
+ const resetLearningState = Boolean(req.body.resetLearningState);
192
+ const result = await extractChapterKnowledge({
193
+ chapterId: req.params.chapterId,
194
+ limitPages,
195
+ force: Boolean(req.body.force),
196
+ extractProfile: req.body.extractProfile || req.body.profile || {},
197
+ resetLearningState
198
+ });
199
+ await refreshAbilityStateAfterLearningReset(req.params.chapterId, resetLearningState);
200
+ res.json(result[0]);
201
+ } catch (error) {
202
+ next(error);
203
+ }
204
+ });
205
+
206
+ app.post('/api/jobs/knowledge-extract', async (req, res, next) => {
207
+ try {
208
+ const chapterId = req.body.chapterId;
209
+ const job = createJob({ type: 'knowledge-extract', chapterId });
210
+ res.json(job);
211
+ queueMicrotask(async () => {
212
+ try {
213
+ startJob(job.id);
214
+ addJobEvent(job.id, { step: 'knowledge_extract.start', message: '正在提取章节知识点。' });
215
+ if (req.body.resetLearningState) {
216
+ addJobEvent(job.id, {
217
+ step: 'knowledge_extract.reset_pending',
218
+ message: '重新提取完成后会清空本章练习、批改、薄弱点和能力评估状态。'
219
+ });
220
+ }
221
+ const result = await extractChapterKnowledge({
222
+ chapterId,
223
+ limitPages: Math.max(0, Math.min(50, Number(req.body.limitPages || 0))),
224
+ force: Boolean(req.body.force),
225
+ extractProfile: req.body.extractProfile || req.body.profile || {},
226
+ resetLearningState: Boolean(req.body.resetLearningState)
227
+ });
228
+ await refreshAbilityStateAfterLearningReset(chapterId, Boolean(req.body.resetLearningState));
229
+ addJobEvent(job.id, { step: 'knowledge_extract.done', message: '章节知识点提取完成。' });
230
+ completeJob(job.id, result[0]);
231
+ } catch (error) {
232
+ failJob(job.id, error);
233
+ }
234
+ });
235
+ } catch (error) {
236
+ next(error);
237
+ }
238
+ });
239
+
240
+ app.get('/api/practices', async (req, res, next) => {
241
+ try {
242
+ await ensureDataDirs();
243
+ const practices = [];
244
+ const chapterDirs = (await readdir(paths.chapterData, { withFileTypes: true }))
245
+ .filter((entry) => entry.isDirectory())
246
+ .map((entry) => entry.name);
247
+ for (const chapterId of chapterDirs) {
248
+ const chapterPaths = chapterDataPaths(chapterId);
249
+ const files = (await readdir(chapterPaths.practices))
250
+ .filter((file) => file.endsWith('.json'));
251
+ for (const file of files) {
252
+ const practice = await ensurePracticeHtml(await readJson(path.join(chapterPaths.practices, file)));
253
+ practices.push(practiceSummary(practice));
254
+ }
255
+ }
256
+ res.json(practices
257
+ .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
258
+ .slice(0, 50));
259
+ } catch (error) {
260
+ next(error);
261
+ }
262
+ });
263
+
264
+ app.get('/api/practices/:id', async (req, res, next) => {
265
+ try {
266
+ res.json(await readPractice(req.params.id));
267
+ } catch (error) {
268
+ next(error);
269
+ }
270
+ });
271
+
272
+ app.post('/api/practices/generate', async (req, res, next) => {
273
+ try {
274
+ const practice = await createPractice({
275
+ chapterId: req.body.chapterId,
276
+ type: req.body.type || 'knowledge_coverage',
277
+ questionCount: req.body.questionCount,
278
+ difficulty: req.body.difficulty || '基础巩固',
279
+ includeMistakes: Boolean(req.body.includeMistakes),
280
+ knowledgePointId: req.body.knowledgePointId || '',
281
+ knowledgePointIds: Array.isArray(req.body.knowledgePointIds) ? req.body.knowledgePointIds : [],
282
+ abilityIds: Array.isArray(req.body.abilityIds) ? req.body.abilityIds : [],
283
+ questionKind: req.body.questionKind || 'auto'
284
+ });
285
+ console.log(
286
+ `[practice.generated] ${practice.id} chapter=${practice.chapterId} questions=${practice.questions.length} source=${practice.source} review=${practice.review?.passed ? 'pass' : 'fail'}` +
287
+ (practice.agentReason ? ` reason=${practice.agentReason}` : '')
288
+ );
289
+ res.json(practice);
290
+ } catch (error) {
291
+ next(error);
292
+ }
293
+ });
294
+
295
+ app.post('/api/jobs/practice-generate', async (req, res, next) => {
296
+ try {
297
+ const job = createJob({ type: 'practice-generate', chapterId: req.body.chapterId });
298
+ res.json(job);
299
+ queueMicrotask(async () => {
300
+ try {
301
+ startJob(job.id);
302
+ addJobEvent(job.id, { step: 'practice_generate.start', message: '正在生成练习卷。' });
303
+ const practice = await createPractice({
304
+ chapterId: req.body.chapterId,
305
+ type: req.body.type || 'knowledge_coverage',
306
+ questionCount: req.body.questionCount,
307
+ difficulty: req.body.difficulty || '基础巩固',
308
+ includeMistakes: Boolean(req.body.includeMistakes),
309
+ knowledgePointId: req.body.knowledgePointId || '',
310
+ knowledgePointIds: Array.isArray(req.body.knowledgePointIds) ? req.body.knowledgePointIds : [],
311
+ abilityIds: Array.isArray(req.body.abilityIds) ? req.body.abilityIds : [],
312
+ questionKind: req.body.questionKind || 'auto',
313
+ onProgress: (event) => addJobEvent(job.id, event)
314
+ });
315
+ addJobEvent(job.id, { step: 'practice_generate.done', message: `练习卷生成完成,共 ${practice.questions.length} 题。` });
316
+ updateJob(job.id, { practiceId: practice.id });
317
+ completeJob(job.id, practice);
318
+ } catch (error) {
319
+ failJob(job.id, error);
320
+ }
321
+ });
322
+ } catch (error) {
323
+ next(error);
324
+ }
325
+ });
326
+
327
+ app.get('/api/jobs/:jobId', (req, res) => {
328
+ const job = getJob(req.params.jobId);
329
+ if (!job) return res.status(404).json({ error: 'job_not_found' });
330
+ res.json(job);
331
+ });
332
+
333
+ app.get('/api/jobs/:jobId/events', (req, res) => {
334
+ res.writeHead(200, {
335
+ 'Content-Type': 'text/event-stream',
336
+ 'Cache-Control': 'no-cache, no-transform',
337
+ Connection: 'keep-alive'
338
+ });
339
+ const ok = subscribeJob(req.params.jobId, res);
340
+ if (!ok) {
341
+ res.write(`data: ${JSON.stringify({ step: 'job.not_found', message: '任务不存在。' })}\n\n`);
342
+ res.end();
343
+ }
344
+ });
345
+
346
+ app.get('/api/chapters/:chapterId/practices', async (req, res, next) => {
347
+ try {
348
+ const chapterPaths = chapterDataPaths(req.params.chapterId);
349
+ const files = (await readdir(chapterPaths.practices))
350
+ .filter((file) => file.endsWith('.json'))
351
+ .sort()
352
+ .reverse();
353
+ const practices = [];
354
+ for (const file of files) {
355
+ const practice = await readJson(path.join(chapterPaths.practices, file));
356
+ if (req.query.type && practice.type !== req.query.type) continue;
357
+ practices.push(practiceSummary(await ensurePracticeHtml(practice)));
358
+ }
359
+ res.json(practices.slice(0, Number(req.query.limit || 50)));
360
+ } catch (error) {
361
+ if (error.code === 'ENOENT') return res.json([]);
362
+ next(error);
363
+ }
364
+ });
365
+
366
+ app.get('/api/chapters/:chapterId/generated-coverage', async (req, res, next) => {
367
+ try {
368
+ res.json(await buildGeneratedCoverage(req.params.chapterId));
369
+ } catch (error) {
370
+ next(error);
371
+ }
372
+ });
373
+
374
+ app.get('/api/chapters/:chapterId/abilities', async (req, res, next) => {
375
+ try {
376
+ res.json(await buildChapterAbilities(req.params.chapterId));
377
+ } catch (error) {
378
+ next(error);
379
+ }
380
+ });
381
+
382
+ app.get('/api/chapters/:chapterId/submissions', async (req, res, next) => {
383
+ try {
384
+ const chapterPaths = chapterDataPaths(req.params.chapterId);
385
+ const entries = (await readdir(chapterPaths.submissions, { withFileTypes: true }))
386
+ .filter((entry) => entry.isDirectory())
387
+ .map((entry) => entry.name)
388
+ .sort()
389
+ .reverse();
390
+ const submissions = [];
391
+ for (const id of entries) {
392
+ const metadata = await readJson(path.join(chapterPaths.submissions, id, 'metadata.json'), null);
393
+ if (metadata) submissions.push(metadata);
394
+ }
395
+ res.json(submissions.slice(0, Number(req.query.limit || 50)));
396
+ } catch (error) {
397
+ if (error.code === 'ENOENT') return res.json([]);
398
+ next(error);
399
+ }
400
+ });
401
+
402
+ app.get('/api/chapters/:chapterId/weak-points', async (req, res, next) => {
403
+ try {
404
+ const mistakes = await readChapterMistakes(req.params.chapterId);
405
+ res.json(weakPointsFromMistakes(mistakes, {
406
+ includeResolved: req.query.includeResolved === 'true'
407
+ }));
408
+ } catch (error) {
409
+ if (error.code === 'ENOENT') return res.json([]);
410
+ next(error);
411
+ }
412
+ });
413
+
414
+ app.post('/api/practices/:id/submissions', async (req, res, next) => {
415
+ try {
416
+ const practice = await readPractice(req.params.id);
417
+ const id = `submission-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}`;
418
+ const submission = {
419
+ id,
420
+ practiceId: practice.id,
421
+ chapterId: practice.chapterId,
422
+ status: 'created',
423
+ notes: req.body.notes || '',
424
+ photoPaths: [],
425
+ gradingResult: null,
426
+ parentConfirmed: false,
427
+ createdAt: new Date().toISOString()
428
+ };
429
+ await writeSubmissionMirrors(submission);
430
+ res.json(submission);
431
+ } catch (error) {
432
+ next(error);
433
+ }
434
+ });
435
+
436
+ app.post('/api/practices/:id/printed', async (req, res, next) => {
437
+ try {
438
+ res.json(await updatePracticeStatus(req.params.id, 'printed'));
439
+ } catch (error) {
440
+ next(error);
441
+ }
442
+ });
443
+
444
+ app.post('/api/practices/:id/previewed', async (req, res, next) => {
445
+ try {
446
+ res.json(await updatePracticeStatus(req.params.id, 'previewed'));
447
+ } catch (error) {
448
+ next(error);
449
+ }
450
+ });
451
+
452
+ app.post('/api/practices/:id/pdf-submissions', pdfUpload.single('pdf'), async (req, res, next) => {
453
+ try {
454
+ const practice = req.pdfPractice || await readPractice(req.params.id);
455
+ const submissionId = req.createdSubmissionId;
456
+ const job = createJob({
457
+ type: 'pdf-grade',
458
+ practiceId: practice.id,
459
+ chapterId: practice.chapterId,
460
+ submissionId
461
+ });
462
+ res.json({ ...job, submissionId });
463
+ queueMicrotask(async () => {
464
+ try {
465
+ startJob(job.id);
466
+ const result = await gradePdfSubmission({
467
+ practiceId: practice.id,
468
+ pdfPath: req.file.path,
469
+ notes: req.body.notes || '',
470
+ autoArchive: false,
471
+ submissionId,
472
+ useAgent: true,
473
+ maxRechecks: Math.max(0, Number(req.body.maxRechecks || 12)),
474
+ gradingBatchSize: Math.max(1, Number(req.body.gradingBatchSize || 5)),
475
+ onProgress: (event) => addJobEvent(job.id, event)
476
+ });
477
+ updateJob(job.id, { submissionId: result.submission.id });
478
+ completeJob(job.id, result);
479
+ } catch (error) {
480
+ try {
481
+ const submission = await readSubmission(submissionId);
482
+ await writeSubmissionMirrors({
483
+ ...submission,
484
+ status: 'failed_grade',
485
+ gradingResult: null,
486
+ error: {
487
+ message: error.message,
488
+ reason: error.reason || null,
489
+ detail: error.detail || null,
490
+ status: error.status || null
491
+ },
492
+ failedAt: new Date().toISOString()
493
+ });
494
+ updateJob(job.id, { submissionId });
495
+ } catch (submissionError) {
496
+ addJobEvent(job.id, {
497
+ stage: 'submission.fail_write_failed',
498
+ message: `批改失败状态写入失败:${submissionError.message}`
499
+ });
500
+ }
501
+ failJob(job.id, error);
502
+ }
503
+ });
504
+ } catch (error) {
505
+ next(error);
506
+ }
507
+ });
508
+
509
+ app.post('/api/submissions/:submissionId/photos', upload.array('photos', 8), async (req, res, next) => {
510
+ try {
511
+ const submission = await readSubmission(req.params.submissionId);
512
+ const practice = await readPractice(submission.practiceId);
513
+ const profile = await readJson(paths.profile);
514
+ const files = req.files || [];
515
+ const photoPaths = files.map((file) => file.path);
516
+ const gradingResult = await gradeSubmission({
517
+ practice,
518
+ profile,
519
+ files,
520
+ notes: req.body.notes || submission.notes || ''
521
+ });
522
+ const updated = {
523
+ ...submission,
524
+ notes: req.body.notes || submission.notes || '',
525
+ photoPaths,
526
+ gradingResult,
527
+ status: 'graded',
528
+ gradedAt: new Date().toISOString()
529
+ };
530
+ await writeSubmissionMirrors(updated);
531
+ console.log(`[submission.graded] ${submission.id} practice=${practice.id} photos=${files.length} source=${gradingResult.source}`);
532
+ res.json(updated);
533
+ } catch (error) {
534
+ next(error);
535
+ }
536
+ });
537
+
538
+ app.get('/api/submissions/:id', async (req, res, next) => {
539
+ try {
540
+ res.json(await readSubmission(req.params.id));
541
+ } catch (error) {
542
+ next(error);
543
+ }
544
+ });
545
+
546
+ app.delete('/api/submissions/:id', async (req, res, next) => {
547
+ try {
548
+ res.json(await deleteSubmission(req.params.id));
549
+ } catch (error) {
550
+ next(error);
551
+ }
552
+ });
553
+
554
+ app.get('/api/submissions/:id/artifacts', async (req, res, next) => {
555
+ try {
556
+ const submission = await readSubmission(req.params.id);
557
+ res.json(submission.artifactPaths || {});
558
+ } catch (error) {
559
+ next(error);
560
+ }
561
+ });
562
+
563
+ app.get('/api/submissions/:id/artifacts/:name', async (req, res, next) => {
564
+ try {
565
+ const submission = await readSubmission(req.params.id);
566
+ const filePath = submission.artifactPaths?.[req.params.name];
567
+ if (!filePath) return res.status(404).json({ error: 'artifact_not_found' });
568
+ res.sendFile(path.join(paths.rootDir, filePath));
569
+ } catch (error) {
570
+ next(error);
571
+ }
572
+ });
573
+
574
+ app.post('/api/submissions/:id/confirm', async (req, res, next) => {
575
+ try {
576
+ const updated = await confirmSubmission({
577
+ submissionId: req.params.id,
578
+ grading: req.body.grading,
579
+ parentSummary: req.body.parentSummary || ''
580
+ });
581
+ console.log(`[submission.archived] ${updated.id} accuracy=${updated.accuracy}`);
582
+ res.json(updated);
583
+ } catch (error) {
584
+ next(error);
585
+ }
586
+ });
587
+
588
+ app.get('/api/archive', async (req, res, next) => {
589
+ try {
590
+ const fs = await import('node:fs/promises');
591
+ const practices = await readJson(paths.chapters, []);
592
+ const submissionFiles = [];
593
+ try {
594
+ const chapterDirs = (await fs.readdir(paths.chapterData, { withFileTypes: true }))
595
+ .filter((entry) => entry.isDirectory())
596
+ .map((entry) => entry.name);
597
+ for (const chapterId of chapterDirs) {
598
+ const dir = chapterDataPaths(chapterId).submissions;
599
+ try {
600
+ const ids = (await fs.readdir(dir, { withFileTypes: true }))
601
+ .filter((entry) => entry.isDirectory())
602
+ .map((entry) => entry.name);
603
+ submissionFiles.push(...ids.map((id) => path.join(dir, id, 'metadata.json')));
604
+ } catch {
605
+ // Ignore chapters without submissions.
606
+ }
607
+ }
608
+ } catch {
609
+ // Ignore missing chapter data dir.
610
+ }
611
+ const submissions = [];
612
+ for (const file of [...new Set(submissionFiles)]) {
613
+ try {
614
+ submissions.push(await readJson(file));
615
+ } catch {
616
+ // Ignore incomplete upload folders.
617
+ }
618
+ }
619
+ const uniqueSubmissions = [...new Map(submissions.map((item) => [item.id, item])).values()];
620
+ res.json({
621
+ chapters: practices,
622
+ submissions: uniqueSubmissions.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))),
623
+ mistakes: await readJson(paths.mistakes, []),
624
+ mastery: (await getKnowledgeBundle()).mastery
625
+ });
626
+ } catch (error) {
627
+ next(error);
628
+ }
629
+ });
630
+
631
+ const distDir = path.join(paths.rootDir, 'dist');
632
+ app.use(express.static(distDir));
633
+ app.use((req, res, next) => {
634
+ const isAssetRoute = ['/api/', '/chapter-images/', '/chapter-data/', '/vendor/']
635
+ .some((prefix) => req.path.startsWith(prefix));
636
+ if (req.method !== 'GET' || isAssetRoute) {
637
+ next();
638
+ return;
639
+ }
640
+ res.sendFile(path.join(distDir, 'index.html'), (error) => {
641
+ if (error) next();
642
+ });
643
+ });
644
+
645
+ app.use((error, req, res, next) => {
646
+ console.error('[api.error]', error);
647
+ res.status(error.status || 500).json({
648
+ error: error.status ? error.message : 'internal_error',
649
+ message: error.message
650
+ });
651
+ });
652
+
653
+ app.listen(port, '127.0.0.1', () => {
654
+ console.log(`[server.ready] http://127.0.0.1:${port}`);
655
+ });