@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,726 @@
1
+ import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import {
5
+ flattenKnowledgePoints,
6
+ knowledgeToMarkdown
7
+ } from './knowledgeBase.js';
8
+
9
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const dataDir = path.resolve(process.env.MATH_AGENT_DATA_DIR || path.join(rootDir, 'data'));
11
+ const imageRoot = path.resolve(
12
+ process.env.MATH_AGENT_IMAGE_ROOT || path.join(rootDir, 'output', 'images', '初中数学_提分笔记_按章节')
13
+ );
14
+
15
+ export const paths = {
16
+ rootDir,
17
+ dataDir,
18
+ imageRoot,
19
+ global: path.join(dataDir, 'global'),
20
+ globalProfile: path.join(dataDir, 'global', 'student_profile.json'),
21
+ globalChapters: path.join(dataDir, 'global', 'chapters.json'),
22
+ globalMasteryIndex: path.join(dataDir, 'global', 'mastery_index.json'),
23
+ globalMistakesIndex: path.join(dataDir, 'global', 'mistakes_index.json'),
24
+ globalAbilityIndex: path.join(dataDir, 'global', 'ability_index.json'),
25
+ chapterData: path.join(dataDir, 'chapters'),
26
+ profile: path.join(dataDir, 'global', 'student_profile.json'),
27
+ chapters: path.join(dataDir, 'global', 'chapters.json'),
28
+ legacyProfile: path.join(dataDir, 'student_profile.json'),
29
+ legacyChapters: path.join(dataDir, 'chapters.json'),
30
+ mistakes: path.join(dataDir, 'mistakes.json'),
31
+ knowledge: path.join(dataDir, 'knowledge_points.json'),
32
+ knowledgeDocs: path.join(dataDir, 'knowledge_docs'),
33
+ knowledgeExtracts: path.join(dataDir, 'knowledge_extracts'),
34
+ mastery: path.join(dataDir, 'mastery.json')
35
+ };
36
+
37
+ export async function ensureDataDirs() {
38
+ await Promise.all([
39
+ mkdir(paths.knowledgeDocs, { recursive: true }),
40
+ mkdir(paths.knowledgeExtracts, { recursive: true }),
41
+ mkdir(paths.global, { recursive: true }),
42
+ mkdir(paths.chapterData, { recursive: true })
43
+ ]);
44
+ }
45
+
46
+ export async function readJson(filePath, fallback = undefined) {
47
+ try {
48
+ return JSON.parse(await readFile(filePath, 'utf8'));
49
+ } catch (error) {
50
+ if (error.code === 'ENOENT' && fallback !== undefined) return fallback;
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ export async function writeJson(filePath, data) {
56
+ await mkdir(path.dirname(filePath), { recursive: true });
57
+ await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
58
+ }
59
+
60
+ export function relativeDataPath(filePath) {
61
+ return path.relative(rootDir, filePath);
62
+ }
63
+
64
+ export function chapterDataPaths(chapterId) {
65
+ const root = path.join(paths.chapterData, chapterId);
66
+ return {
67
+ root,
68
+ chapter: path.join(root, 'chapter.json'),
69
+ sourcePages: path.join(root, 'source_pages'),
70
+ sourceManifest: path.join(root, 'source_pages', 'manifest.json'),
71
+ knowledge: path.join(root, 'knowledge'),
72
+ pageExtracts: path.join(root, 'knowledge', 'page_extracts'),
73
+ knowledgeJson: path.join(root, 'knowledge', 'knowledge.json'),
74
+ knowledgeMarkdown: path.join(root, 'knowledge', 'knowledge.md'),
75
+ knowledgeSummary: path.join(root, 'knowledge', 'summary.json'),
76
+ mastery: path.join(root, 'mastery.json'),
77
+ abilities: path.join(root, 'abilities.json'),
78
+ mistakes: path.join(root, 'mistakes.json'),
79
+ practices: path.join(root, 'practices'),
80
+ submissions: path.join(root, 'submissions'),
81
+ reports: path.join(root, 'reports'),
82
+ context: path.join(root, 'context'),
83
+ generationHistory: path.join(root, 'context', 'generation_history.json'),
84
+ generatedCoverage: path.join(root, 'context', 'generated_coverage.json'),
85
+ mistakeSummary: path.join(root, 'context', 'mistake_summary.json'),
86
+ nextPlan: path.join(root, 'context', 'next_plan.json')
87
+ };
88
+ }
89
+
90
+ export async function ensureChapterDataDirs(chapterId) {
91
+ const chapterPaths = chapterDataPaths(chapterId);
92
+ await Promise.all([
93
+ mkdir(chapterPaths.sourcePages, { recursive: true }),
94
+ mkdir(chapterPaths.pageExtracts, { recursive: true }),
95
+ mkdir(chapterPaths.practices, { recursive: true }),
96
+ mkdir(chapterPaths.submissions, { recursive: true }),
97
+ mkdir(chapterPaths.reports, { recursive: true }),
98
+ mkdir(chapterPaths.context, { recursive: true })
99
+ ]);
100
+ return chapterPaths;
101
+ }
102
+
103
+ export async function ensureChapterWorkspace(chapter) {
104
+ const chapterPaths = await ensureChapterDataDirs(chapter.id);
105
+ await writeJson(chapterPaths.chapter, chapter);
106
+ try {
107
+ const folderPath = path.join(paths.imageRoot, chapter.imageFolder);
108
+ const files = (await readdir(folderPath))
109
+ .filter((file) => /\.(png|jpe?g|webp)$/i.test(file))
110
+ .sort();
111
+ await writeJson(chapterPaths.sourceManifest, {
112
+ chapterId: chapter.id,
113
+ chapterTitle: chapter.fullTitle,
114
+ imageFolder: chapter.imageFolder,
115
+ pageCount: files.length,
116
+ pages: files.map((file) => ({
117
+ file,
118
+ sourcePath: relativeDataPath(path.join(folderPath, file)),
119
+ localPath: relativeDataPath(path.join(chapterPaths.sourcePages, file))
120
+ }))
121
+ });
122
+ } catch {
123
+ await writeJson(chapterPaths.sourceManifest, {
124
+ chapterId: chapter.id,
125
+ chapterTitle: chapter.fullTitle,
126
+ imageFolder: chapter.imageFolder,
127
+ pageCount: 0,
128
+ pages: []
129
+ });
130
+ }
131
+ return chapterPaths;
132
+ }
133
+
134
+ async function writeChapterMasteryFiles(mastery) {
135
+ const chapters = Object.entries(mastery?.chapters || {});
136
+ for (const [chapterId, chapterMastery] of chapters) {
137
+ const chapterPaths = chapterDataPaths(chapterId);
138
+ try {
139
+ await access(chapterPaths.root);
140
+ } catch {
141
+ continue;
142
+ }
143
+ await ensureChapterDataDirs(chapterId);
144
+ await writeJson(chapterPaths.mastery, chapterMastery);
145
+ }
146
+ }
147
+
148
+ async function chapterIdsWithData(chapters) {
149
+ const ids = new Set((chapters || []).map((chapter) => chapter.id));
150
+ try {
151
+ const entries = await readdir(paths.chapterData, { withFileTypes: true });
152
+ for (const entry of entries) {
153
+ if (entry.isDirectory()) ids.add(entry.name);
154
+ }
155
+ } catch (error) {
156
+ if (error.code !== 'ENOENT') throw error;
157
+ }
158
+ return [...ids].sort();
159
+ }
160
+
161
+ export async function readChapterMistakes(chapterId) {
162
+ const chapterPaths = chapterDataPaths(chapterId);
163
+ const chapterMistakes = await readJson(chapterPaths.mistakes, null);
164
+ if (Array.isArray(chapterMistakes)) return chapterMistakes.map(normalizeMistakeRecord);
165
+ return (await readJson(paths.mistakes, []))
166
+ .filter((item) => item.chapterId === chapterId)
167
+ .map(normalizeMistakeRecord);
168
+ }
169
+
170
+ export function normalizeMistakeRecord(item = {}) {
171
+ item ||= {};
172
+ const status = item.status || (item.resolved ? 'resolved' : 'open');
173
+ const primaryKnowledgePointId = item.knowledgePointId || item.knowledgePointIds?.[0] || null;
174
+ const sourceKnowledgePointIds = [
175
+ ...new Set([
176
+ ...(Array.isArray(item.sourceKnowledgePointIds) ? item.sourceKnowledgePointIds : []),
177
+ ...(Array.isArray(item.knowledgePointIds) ? item.knowledgePointIds : []),
178
+ primaryKnowledgePointId
179
+ ].filter(Boolean))
180
+ ];
181
+ return {
182
+ ...item,
183
+ status,
184
+ resolved: status === 'resolved',
185
+ knowledgePointId: primaryKnowledgePointId,
186
+ sourceKnowledgePointIds,
187
+ occurrences: Math.max(1, Number(item.occurrences || 1)),
188
+ reviewDates: Array.isArray(item.reviewDates) ? item.reviewDates : [],
189
+ repairEvidence: Array.isArray(item.repairEvidence) ? item.repairEvidence : []
190
+ };
191
+ }
192
+
193
+ export function isResolvedMistake(item = {}) {
194
+ return normalizeMistakeRecord(item).status === 'resolved';
195
+ }
196
+
197
+ export function isActionableMistake(item = {}) {
198
+ return ['open', 'regressed'].includes(normalizeMistakeRecord(item).status);
199
+ }
200
+
201
+ export function isUnresolvedMistake(item = {}) {
202
+ return !isResolvedMistake(item);
203
+ }
204
+
205
+ export async function writeChapterMistakes(chapterId, mistakes) {
206
+ const chapterPaths = await ensureChapterDataDirs(chapterId);
207
+ const normalizedMistakes = mistakes.map(normalizeMistakeRecord);
208
+ await writeJson(chapterPaths.mistakes, normalizedMistakes);
209
+ await writeJson(chapterPaths.mistakeSummary, {
210
+ chapterId,
211
+ updatedAt: new Date().toISOString(),
212
+ total: normalizedMistakes.length,
213
+ unresolved: normalizedMistakes.filter(isUnresolvedMistake).length,
214
+ actionable: normalizedMistakes.filter(isActionableMistake).length,
215
+ mistakes: normalizedMistakes.slice(-30)
216
+ });
217
+
218
+ const allMistakes = await readJson(paths.mistakes, []);
219
+ const next = [
220
+ ...allMistakes.filter((item) => item.chapterId !== chapterId),
221
+ ...normalizedMistakes
222
+ ].sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
223
+ await writeJson(paths.mistakes, next);
224
+ await syncGlobalIndexes();
225
+ return normalizedMistakes;
226
+ }
227
+
228
+ async function countDirectoryEntries(dirPath, { filesOnly = false } = {}) {
229
+ try {
230
+ const entries = await readdir(dirPath, { withFileTypes: true });
231
+ return entries.filter((entry) => !filesOnly || entry.isFile()).length;
232
+ } catch (error) {
233
+ if (error.code === 'ENOENT') return 0;
234
+ throw error;
235
+ }
236
+ }
237
+
238
+ export async function resetChapterLearningLoop(chapterId) {
239
+ const chapterPaths = await ensureChapterDataDirs(chapterId);
240
+ const cleared = {
241
+ practices: await countDirectoryEntries(chapterPaths.practices, { filesOnly: true }),
242
+ submissions: await countDirectoryEntries(chapterPaths.submissions),
243
+ reports: await countDirectoryEntries(chapterPaths.reports, { filesOnly: true })
244
+ };
245
+
246
+ await Promise.all([
247
+ rm(chapterPaths.practices, { recursive: true, force: true }),
248
+ rm(chapterPaths.submissions, { recursive: true, force: true }),
249
+ rm(chapterPaths.reports, { recursive: true, force: true }),
250
+ rm(chapterPaths.generatedCoverage, { force: true }),
251
+ rm(chapterPaths.generationHistory, { force: true }),
252
+ rm(chapterPaths.mistakeSummary, { force: true }),
253
+ rm(chapterPaths.nextPlan, { force: true }),
254
+ rm(chapterPaths.abilities, { force: true })
255
+ ]);
256
+ await ensureChapterDataDirs(chapterId);
257
+ await writeChapterMistakes(chapterId, []);
258
+
259
+ const chapters = await readJson(paths.chapters, []);
260
+ const chapter = chapters.find((item) => item.id === chapterId);
261
+ if (chapter) {
262
+ chapter.status = 'not_started';
263
+ chapter.mastery = 0;
264
+ chapter.latestAccuracy = null;
265
+ chapter.topErrorTypes = [];
266
+ chapter.resetAt = new Date().toISOString();
267
+ await writeJson(paths.chapters, chapters);
268
+ }
269
+ await syncGlobalIndexes();
270
+ return { chapterId, cleared };
271
+ }
272
+
273
+ export async function syncGlobalIndexes() {
274
+ await ensureDataDirs();
275
+ const chapters = await readJson(paths.chapters, []);
276
+ await writeJson(paths.globalProfile, await readJson(paths.profile, {}));
277
+ await writeJson(paths.globalChapters, chapters);
278
+
279
+ const legacyMastery = await readJson(paths.mastery, { chapters: {} });
280
+ const legacyMistakes = await readJson(paths.mistakes, []);
281
+ const ids = await chapterIdsWithData(chapters);
282
+ const masteryIndex = {
283
+ student: '周子烊',
284
+ version: 1,
285
+ updatedAt: new Date().toISOString(),
286
+ source: 'chapter-masteries',
287
+ chapters: {}
288
+ };
289
+ const mistakesIndex = {
290
+ student: '周子烊',
291
+ version: 1,
292
+ updatedAt: new Date().toISOString(),
293
+ source: 'chapter-mistakes',
294
+ chapters: {},
295
+ recent: []
296
+ };
297
+
298
+ for (const chapterId of ids) {
299
+ const chapterPaths = chapterDataPaths(chapterId);
300
+ const chapter = chapters.find((item) => item.id === chapterId);
301
+ const mastery = await readJson(chapterPaths.mastery, legacyMastery.chapters?.[chapterId] || null);
302
+ if (mastery) {
303
+ masteryIndex.chapters[chapterId] = {
304
+ chapterTitle: mastery.chapterTitle || chapter?.fullTitle || chapterId,
305
+ track: mastery.track || chapter?.track || '',
306
+ coverage: mastery.coverage || null,
307
+ updatedAt: mastery.updatedAt || legacyMastery.updatedAt || null
308
+ };
309
+ }
310
+
311
+ const mistakes = await readJson(
312
+ chapterPaths.mistakes,
313
+ legacyMistakes.filter((item) => item.chapterId === chapterId)
314
+ );
315
+ mistakesIndex.chapters[chapterId] = {
316
+ chapterTitle: chapter?.fullTitle || chapterId,
317
+ total: mistakes.length,
318
+ unresolved: mistakes.filter(isUnresolvedMistake).length,
319
+ actionable: mistakes.filter(isActionableMistake).length,
320
+ topErrorTypes: Object.entries(
321
+ mistakes.filter(isUnresolvedMistake).reduce((acc, item) => {
322
+ const key = item.errorType || '待确认';
323
+ acc[key] = (acc[key] || 0) + 1;
324
+ return acc;
325
+ }, {})
326
+ )
327
+ .sort((a, b) => b[1] - a[1])
328
+ .slice(0, 8)
329
+ .map(([errorType, count]) => ({ errorType, count }))
330
+ };
331
+ mistakesIndex.recent.push(...mistakes.slice(-5));
332
+ }
333
+
334
+ mistakesIndex.recent = mistakesIndex.recent
335
+ .sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')))
336
+ .slice(0, 30);
337
+ await writeJson(paths.globalMasteryIndex, masteryIndex);
338
+ await writeJson(paths.globalMistakesIndex, mistakesIndex);
339
+ return { masteryIndex, mistakesIndex };
340
+ }
341
+
342
+ export async function readText(filePath, fallback = '') {
343
+ try {
344
+ return await readFile(filePath, 'utf8');
345
+ } catch (error) {
346
+ if (error.code === 'ENOENT') return fallback;
347
+ throw error;
348
+ }
349
+ }
350
+
351
+ function parseCsvLine(line) {
352
+ const values = [];
353
+ let current = '';
354
+ let quoted = false;
355
+ for (const char of line) {
356
+ if (char === '"') {
357
+ quoted = !quoted;
358
+ } else if (char === ',' && !quoted) {
359
+ values.push(current);
360
+ current = '';
361
+ } else {
362
+ current += char;
363
+ }
364
+ }
365
+ values.push(current);
366
+ return values;
367
+ }
368
+
369
+ function trackForChapter(title) {
370
+ const algebra = [
371
+ '有理数',
372
+ '有理数的运算',
373
+ '代数式',
374
+ '整式的加减',
375
+ '一元一次方程',
376
+ '二元一次方程组',
377
+ '不等式与不等式组',
378
+ '整式的乘法',
379
+ '因式分解',
380
+ '分式',
381
+ '二次根式',
382
+ '一元二次方程'
383
+ ];
384
+ const functions = ['平面直角坐标系', '函数', '一次函数', '反比例函数', '二次函数'];
385
+ const statistics = ['数据的收集、整理与描述', '数据的分析', '随机事件的概率'];
386
+ if (algebra.some((item) => title.includes(item))) return '计算与代数地基';
387
+ if (functions.some((item) => title.includes(item))) return '函数主线';
388
+ if (statistics.some((item) => title.includes(item))) return '统计概率主线';
389
+ return '几何证明主线';
390
+ }
391
+
392
+ export async function seedChaptersFromManifest() {
393
+ await ensureDataDirs();
394
+ let existingChapters = null;
395
+ try {
396
+ existingChapters = JSON.parse(await readFile(paths.chapters, 'utf8'));
397
+ await seedKnowledgeAssets(existingChapters);
398
+ return;
399
+ } catch (error) {
400
+ if (error.code !== 'ENOENT') throw error;
401
+ }
402
+
403
+ const manifest = await readText(path.join(paths.imageRoot, 'manifest.csv'));
404
+ const rows = manifest.trim().split(/\r?\n/).slice(1).map(parseCsvLine);
405
+ const chapters = [];
406
+ for (const [chapter, bookStart, bookEnd, pdfStart, pdfEnd, folder] of rows) {
407
+ const folderPath = path.join(paths.imageRoot, folder);
408
+ const imageFiles = (await readdir(folderPath))
409
+ .filter((file) => file.endsWith('.png'))
410
+ .sort();
411
+ chapters.push({
412
+ id: `chapter-${String(chapters.length + 1).padStart(2, '0')}`,
413
+ title: chapter.replace(/^第.+?章/, '').trim() || chapter,
414
+ chapterLabel: chapter.match(/^第.+?章/)?.[0] ?? `第${chapters.length + 1}章`,
415
+ fullTitle: `${chapter.match(/^第.+?章/)?.[0] ?? ''} ${chapter.replace(/^第.+?章/, '').trim()}`.trim(),
416
+ track: trackForChapter(chapter),
417
+ bookStart: Number(bookStart),
418
+ bookEnd: Number(bookEnd),
419
+ pdfStart: Number(pdfStart),
420
+ pdfEnd: Number(pdfEnd),
421
+ imageFolder: folder,
422
+ imageCount: imageFiles.length,
423
+ coverImage: `/chapter-images/${encodeURIComponent(folder)}/${imageFiles[0]}`,
424
+ status: 'not_started',
425
+ mastery: 0,
426
+ latestAccuracy: null,
427
+ topErrorTypes: []
428
+ });
429
+ }
430
+ await writeJson(paths.chapters, chapters);
431
+ await seedKnowledgeAssets(chapters);
432
+ }
433
+
434
+ export async function updateChapterAfterArchive(chapterId, accuracy, errorTypes) {
435
+ const chapters = await readJson(paths.chapters, []);
436
+ const chapter = chapters.find((item) => item.id === chapterId);
437
+ if (!chapter) return;
438
+ chapter.latestAccuracy = accuracy;
439
+ chapter.mastery = Math.max(chapter.mastery ?? 0, Math.round(accuracy));
440
+ chapter.topErrorTypes = errorTypes;
441
+ chapter.status = accuracy >= 90 ? 'review_due' : 'in_progress';
442
+ await writeJson(paths.chapters, chapters);
443
+ }
444
+
445
+ function normalizeKnowledgeDoc(chapter, doc, source = 'seed') {
446
+ const sections = (doc.sections || []).map((section, sectionIndex) => ({
447
+ title: section.title || `知识组 ${sectionIndex + 1}`,
448
+ points: (section.points || []).map((point, pointIndex) => ({
449
+ id: point.id || `${chapter.id}-kp-${String(sectionIndex + 1).padStart(2, '0')}-${String(pointIndex + 1).padStart(2, '0')}`,
450
+ title: point.title || `知识点 ${pointIndex + 1}`,
451
+ summary: point.summary || '待补充。',
452
+ formulas: Array.isArray(point.formulas) ? point.formulas : [],
453
+ pitfalls: Array.isArray(point.pitfalls) ? point.pitfalls : [],
454
+ examples: Array.isArray(point.examples) ? point.examples : [],
455
+ questionTemplates: Array.isArray(point.questionTemplates) ? point.questionTemplates : []
456
+ }))
457
+ })).filter((section) => section.points.length);
458
+ return {
459
+ chapterId: chapter.id,
460
+ chapterTitle: chapter.fullTitle,
461
+ track: chapter.track,
462
+ source,
463
+ extractionStatus: source === 'agent' ? 'agent_extracted' : 'seeded',
464
+ updatedAt: new Date().toISOString(),
465
+ sections
466
+ };
467
+ }
468
+
469
+ function buildInitialMastery(chapters, knowledgeDocs, existing = null) {
470
+ const next = {
471
+ student: '周子烊',
472
+ version: 1,
473
+ updatedAt: new Date().toISOString(),
474
+ chapters: existing?.chapters || {}
475
+ };
476
+ for (const chapter of chapters) {
477
+ const knowledgeDoc = knowledgeDocs.find((item) => item.chapterId === chapter.id);
478
+ if (!knowledgeDoc) continue;
479
+ const current = next.chapters[chapter.id] || {
480
+ chapterTitle: chapter.fullTitle,
481
+ track: chapter.track,
482
+ coverage: { total: 0, covered: 0, mastered: 0, needsReview: 0 },
483
+ points: {}
484
+ };
485
+ current.chapterTitle = chapter.fullTitle;
486
+ current.track = chapter.track;
487
+ for (const point of flattenKnowledgePoints(knowledgeDoc)) {
488
+ current.points[point.id] ||= {
489
+ id: point.id,
490
+ title: point.title,
491
+ sectionTitle: point.sectionTitle,
492
+ status: 'not_started',
493
+ coveredCount: 0,
494
+ correctCount: 0,
495
+ wrongCount: 0,
496
+ lastPracticeId: null,
497
+ lastSubmissionId: null,
498
+ lastUpdatedAt: null,
499
+ errorTypes: []
500
+ };
501
+ current.points[point.id].title = point.title;
502
+ current.points[point.id].sectionTitle = point.sectionTitle;
503
+ }
504
+ current.coverage = masteryCoverage(current.points);
505
+ next.chapters[chapter.id] = current;
506
+ }
507
+ return next;
508
+ }
509
+
510
+ function masteryCoverage(points) {
511
+ const values = Object.values(points || {});
512
+ return {
513
+ total: values.length,
514
+ covered: values.filter((item) => item.coveredCount > 0).length,
515
+ mastered: values.filter((item) => item.status === 'mastered').length,
516
+ needsReview: values.filter((item) => item.status === 'needs_review').length
517
+ };
518
+ }
519
+
520
+ function mergeChapterMastery(template, existing) {
521
+ if (!existing) return template;
522
+ const next = {
523
+ ...template,
524
+ ...existing,
525
+ points: {}
526
+ };
527
+ const pointIds = new Set([
528
+ ...Object.keys(template.points || {}),
529
+ ...Object.keys(existing.points || {})
530
+ ]);
531
+ for (const pointId of pointIds) {
532
+ next.points[pointId] = {
533
+ ...(template.points?.[pointId] || {}),
534
+ ...(existing.points?.[pointId] || {})
535
+ };
536
+ }
537
+ next.coverage = masteryCoverage(next.points);
538
+ return next;
539
+ }
540
+
541
+ export async function seedKnowledgeAssets(chapters) {
542
+ await ensureDataDirs();
543
+ const existingDocs = await readJson(paths.knowledge, []);
544
+ const byChapter = new Map(existingDocs.map((doc) => [doc.chapterId, doc]));
545
+ const knowledgeDocs = [];
546
+ for (const chapter of chapters) {
547
+ const existing = byChapter.get(chapter.id);
548
+ if (!existing) {
549
+ const error = new Error(`knowledge_doc_missing:${chapter.id}`);
550
+ error.status = 422;
551
+ throw error;
552
+ }
553
+ const doc = existing;
554
+ const docPath = path.join(paths.knowledgeDocs, `${chapter.id}.md`);
555
+ doc.docPath = relativeDataPath(docPath);
556
+ knowledgeDocs.push(doc);
557
+ await writeFile(docPath, knowledgeToMarkdown(chapter, doc), 'utf8');
558
+ }
559
+ await writeJson(paths.knowledge, knowledgeDocs);
560
+ const existingMastery = await readJson(paths.mastery, null);
561
+ const mastery = buildInitialMastery(chapters, knowledgeDocs, existingMastery);
562
+ for (const chapter of chapters) {
563
+ const chapterPaths = chapterDataPaths(chapter.id);
564
+ const chapterMastery = await readJson(chapterPaths.mastery, null);
565
+ if (mastery.chapters[chapter.id]) {
566
+ mastery.chapters[chapter.id] = mergeChapterMastery(mastery.chapters[chapter.id], chapterMastery);
567
+ }
568
+ }
569
+ await writeJson(paths.mastery, mastery);
570
+ await writeChapterMasteryFiles(mastery);
571
+ await syncGlobalIndexes();
572
+ }
573
+
574
+ export async function getKnowledgeBundle(chapterId = null) {
575
+ const chapters = await readJson(paths.chapters, []);
576
+ await seedKnowledgeAssets(chapters);
577
+ const knowledge = await readJson(paths.knowledge, []);
578
+ const mastery = await readJson(paths.mastery, { chapters: {} });
579
+ if (!chapterId) return { knowledge, mastery };
580
+ const chapterPaths = chapterDataPaths(chapterId);
581
+ const chapter = chapters.find((item) => item.id === chapterId);
582
+ if (chapter) await ensureChapterWorkspace(chapter);
583
+ const doc = await readJson(chapterPaths.knowledgeJson, null);
584
+ const markdown = doc && chapter
585
+ ? await readText(chapterPaths.knowledgeMarkdown, doc.docPath ? await readText(path.join(rootDir, doc.docPath), '') : '')
586
+ : '';
587
+ const chapterMastery = await readJson(chapterPaths.mastery, mastery.chapters?.[chapterId] || null);
588
+ const sourceManifest = await readJson(chapterPaths.sourceManifest, null);
589
+ const summary = await readJson(chapterPaths.knowledgeSummary, null);
590
+ const pageExtracts = sourceManifest?.pages
591
+ ? await Promise.all(sourceManifest.pages.map(async (page, index) => {
592
+ const extractFile = `${path.basename(page.file, path.extname(page.file))}.json`;
593
+ const extractPath = path.join(chapterPaths.pageExtracts, extractFile);
594
+ const extract = await readJson(extractPath, null);
595
+ return extract
596
+ ? { ...extract, extractPath: relativeDataPath(extractPath) }
597
+ : {
598
+ chapterId,
599
+ imageFile: page.file,
600
+ pageIndex: index + 1,
601
+ rawOutline: [],
602
+ knowledgePoints: [],
603
+ extractPath: relativeDataPath(extractPath)
604
+ };
605
+ }))
606
+ : [];
607
+ const pointCount = (doc?.sections || []).reduce((sum, section) => sum + (section.points || []).length, 0);
608
+ const sourcePageCount = Number(sourceManifest?.pageCount || sourceManifest?.pages?.length || 0);
609
+ const extractProfile = doc?.extractProfile || summary?.extractProfile || {
610
+ detailLevel: 'exam_focus',
611
+ studentBaseline: 'strong',
612
+ focus: ['exam_points', 'error_prone'],
613
+ displayLayer: 'exam_point_wall'
614
+ };
615
+ const availability = {
616
+ status: pointCount > 0 ? 'available' : sourcePageCount > 0 ? 'missing' : 'missing_source',
617
+ reason: pointCount > 0
618
+ ? ''
619
+ : sourcePageCount > 0
620
+ ? 'knowledge_not_extracted'
621
+ : 'source_pages_missing',
622
+ knowledgePointCount: pointCount,
623
+ sourcePageCount,
624
+ extractedAt: doc?.extractedAt || doc?.updatedAt || summary?.updatedAt || null,
625
+ extractionStatus: doc?.extractionStatus || null,
626
+ source: doc?.source || null
627
+ };
628
+ return {
629
+ knowledge: doc || null,
630
+ markdown,
631
+ mastery: chapterMastery,
632
+ availability,
633
+ extractProfile,
634
+ extractionSummary: summary,
635
+ sourcePages: sourceManifest || null,
636
+ pageExtracts
637
+ };
638
+ }
639
+
640
+ export async function saveKnowledgeDoc(chapter, doc, source = 'agent', metadata = {}) {
641
+ await ensureDataDirs();
642
+ const chapters = await readJson(paths.chapters, []);
643
+ const knowledge = await readJson(paths.knowledge, []);
644
+ const normalized = normalizeKnowledgeDoc(chapter, doc, source);
645
+ if (metadata.extractProfile) normalized.extractProfile = metadata.extractProfile;
646
+ if (metadata.sourceManifest) normalized.sourceManifest = metadata.sourceManifest;
647
+ if (metadata.extractorVersion) normalized.extractorVersion = metadata.extractorVersion;
648
+ if (metadata.extractedAt) normalized.extractedAt = metadata.extractedAt;
649
+ const docPath = path.join(paths.knowledgeDocs, `${chapter.id}.md`);
650
+ normalized.docPath = relativeDataPath(docPath);
651
+ const next = knowledge.filter((item) => item.chapterId !== chapter.id);
652
+ next.push(normalized);
653
+ next.sort((a, b) => a.chapterId.localeCompare(b.chapterId));
654
+ await writeJson(paths.knowledge, next);
655
+ await writeFile(docPath, knowledgeToMarkdown(chapter, normalized), 'utf8');
656
+ const chapterPaths = await ensureChapterDataDirs(chapter.id);
657
+ const chapterDoc = {
658
+ ...normalized,
659
+ docPath: relativeDataPath(chapterPaths.knowledgeMarkdown)
660
+ };
661
+ await writeJson(chapterPaths.knowledgeJson, chapterDoc);
662
+ await writeFile(chapterPaths.knowledgeMarkdown, knowledgeToMarkdown(chapter, chapterDoc), 'utf8');
663
+ const existingMastery = await readJson(paths.mastery, null);
664
+ const scopedExistingMastery = metadata.resetLearningState && existingMastery?.chapters
665
+ ? {
666
+ ...existingMastery,
667
+ chapters: Object.fromEntries(
668
+ Object.entries(existingMastery.chapters).filter(([chapterId]) => chapterId !== chapter.id)
669
+ )
670
+ }
671
+ : existingMastery;
672
+ const mastery = buildInitialMastery(chapters, next, scopedExistingMastery);
673
+ const existingChapterMastery = await readJson(chapterPaths.mastery, null);
674
+ if (!metadata.resetLearningState && mastery.chapters[chapter.id]) {
675
+ mastery.chapters[chapter.id] = mergeChapterMastery(mastery.chapters[chapter.id], existingChapterMastery);
676
+ }
677
+ await writeJson(paths.mastery, mastery);
678
+ await writeJson(chapterPaths.mastery, mastery.chapters[chapter.id]);
679
+ await syncGlobalIndexes();
680
+ return chapterDoc;
681
+ }
682
+
683
+ export async function updateKnowledgeMasteryAfterArchive(practice, grading) {
684
+ const chapters = await readJson(paths.chapters, []);
685
+ const knowledge = await readJson(paths.knowledge, []);
686
+ const mastery = buildInitialMastery(chapters, knowledge, await readJson(paths.mastery, null));
687
+ const chapterPaths = await ensureChapterDataDirs(practice.chapterId);
688
+ const existingChapterMastery = await readJson(chapterPaths.mastery, null);
689
+ mastery.chapters[practice.chapterId] = mergeChapterMastery(mastery.chapters[practice.chapterId], existingChapterMastery);
690
+ const chapterMastery = mastery.chapters[practice.chapterId];
691
+ if (!chapterMastery) return mastery;
692
+
693
+ for (const item of grading) {
694
+ const question = practice.questions.find((candidate) => candidate.id === item.questionId);
695
+ const ids = question?.knowledgePointIds?.length ? question.knowledgePointIds : [];
696
+ const maxScore = Number(item.maxScore || question?.score || 0);
697
+ const score = Number(item.score || 0);
698
+ const ratio = maxScore > 0 ? score / maxScore : 0;
699
+ for (const pointId of ids) {
700
+ const point = chapterMastery.points[pointId];
701
+ if (!point) continue;
702
+ point.coveredCount += 1;
703
+ point.lastPracticeId = practice.id;
704
+ point.lastSubmissionId = item.submissionId || null;
705
+ point.lastUpdatedAt = new Date().toISOString();
706
+ point.errorTypes = [...new Set([...(point.errorTypes || []), ...(item.errorTypes || [])].filter(Boolean))].slice(0, 8);
707
+ if (item.status === 'correct' || ratio >= 0.85) {
708
+ point.correctCount += 1;
709
+ point.status = point.correctCount >= 2 || ratio >= 0.95 ? 'mastered' : 'covered';
710
+ } else if (ratio > 0 || item.status === 'partial') {
711
+ point.wrongCount += 1;
712
+ point.status = 'needs_review';
713
+ } else {
714
+ point.wrongCount += 1;
715
+ point.status = 'needs_review';
716
+ }
717
+ }
718
+ }
719
+ chapterMastery.coverage = masteryCoverage(chapterMastery.points);
720
+ chapterMastery.updatedAt = new Date().toISOString();
721
+ mastery.updatedAt = new Date().toISOString();
722
+ await writeJson(chapterPaths.mastery, chapterMastery);
723
+ await writeJson(paths.mastery, mastery);
724
+ await syncGlobalIndexes();
725
+ return mastery;
726
+ }