@zhouchangui/math-ati 0.1.2 → 0.1.4

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 (35) hide show
  1. package/AGENTS.md +4 -1
  2. package/README.md +11 -0
  3. package/bin/math-ati.js +136 -5
  4. package/dist/assets/{index-CGZslJ0a.css → index-DOg8CQsE.css} +1 -1
  5. package/dist/assets/index-DyfeTKmg.js +22 -0
  6. package/dist/index.html +3 -3
  7. package/package.json +9 -5
  8. package/prompts/geometry-practice-experience.md +44 -0
  9. package/prompts/grading.system.md +3 -1
  10. package/prompts/knowledge-extract.system.md +35 -54
  11. package/prompts/knowledge-structure.system.md +75 -0
  12. package/prompts/knowledge-summarize.system.md +21 -7
  13. package/prompts/pdf-grading.system.md +4 -1
  14. package/prompts/pdf-recheck.system.md +2 -0
  15. package/prompts/practice-answers.system.md +154 -0
  16. package/prompts/practice-coverage-repair.system.md +112 -0
  17. package/prompts/practice-generate.system.md +51 -9
  18. package/prompts/practice-review.system.md +4 -2
  19. package/prompts/practice-revise.system.md +5 -4
  20. package/prompts/practice-rules.md +61 -0
  21. package/prompts/svg-figure-review.system.md +13 -0
  22. package/prompts/svg-figure-revise.system.md +21 -0
  23. package/server/agentClient.js +179 -10
  24. package/server/coveragePlanner.js +174 -0
  25. package/server/fileStore.js +49 -9
  26. package/server/index.js +78 -1
  27. package/server/knowledgeExtractor.js +717 -120
  28. package/server/knowledgeFeedback.js +69 -0
  29. package/server/practiceGenerator.js +637 -116
  30. package/server/practicePaperHtml.js +105 -35
  31. package/server/practiceService.js +27 -2
  32. package/server/promptStore.js +14 -0
  33. package/server/submissionService.js +1 -1
  34. package/server/svgFigureVerifier.js +315 -0
  35. package/dist/assets/index-CGfjl7nO.js +0 -22
@@ -0,0 +1,174 @@
1
+ export const COVERAGE_DURATION_CONFIGS = {
2
+ 15: {
3
+ targetMinutes: 15,
4
+ questionCount: 6,
5
+ questionRange: [5, 6],
6
+ newPointCapacity: 7,
7
+ reviewRatio: 0.3
8
+ },
9
+ 25: {
10
+ targetMinutes: 25,
11
+ questionCount: 9,
12
+ questionRange: [8, 10],
13
+ newPointCapacity: 11,
14
+ reviewRatio: 0.25
15
+ },
16
+ 35: {
17
+ targetMinutes: 35,
18
+ questionCount: 13,
19
+ questionRange: [11, 14],
20
+ newPointCapacity: 16,
21
+ reviewRatio: 0.2
22
+ }
23
+ };
24
+
25
+ function normalizeDuration(durationMinutes = 25) {
26
+ const value = Number(durationMinutes);
27
+ return COVERAGE_DURATION_CONFIGS[value] ? value : 25;
28
+ }
29
+
30
+ function chunkBalanced(items, chunkCount) {
31
+ if (chunkCount <= 1) return [items];
32
+ const chunks = [];
33
+ let cursor = 0;
34
+ for (let index = 0; index < chunkCount; index += 1) {
35
+ const remainingItems = items.length - cursor;
36
+ const remainingChunks = chunkCount - index;
37
+ const size = Math.ceil(remainingItems / remainingChunks);
38
+ chunks.push(items.slice(cursor, cursor + size));
39
+ cursor += size;
40
+ }
41
+ return chunks.filter((chunk) => chunk.length);
42
+ }
43
+
44
+ function uniqueById(points) {
45
+ const seen = new Set();
46
+ const result = [];
47
+ for (const point of points) {
48
+ if (!point?.id || seen.has(point.id)) continue;
49
+ seen.add(point.id);
50
+ result.push(point);
51
+ }
52
+ return result;
53
+ }
54
+
55
+ function rotatePick(points, count, offset = 0) {
56
+ const unique = uniqueById(points);
57
+ if (!unique.length || count <= 0) return [];
58
+ const picked = [];
59
+ for (let index = 0; index < unique.length && picked.length < count; index += 1) {
60
+ picked.push(unique[(offset + index) % unique.length]);
61
+ }
62
+ return picked;
63
+ }
64
+
65
+ function reviewPick({ completedPoints, recentPoints, mistakePoints, count, paperIndex }) {
66
+ if (count <= 0) return [];
67
+ const prioritized = uniqueById([
68
+ ...mistakePoints.filter((point) => completedPoints.some((done) => done.id === point.id)),
69
+ ...recentPoints,
70
+ ...completedPoints
71
+ ]);
72
+ return rotatePick(prioritized, count, paperIndex);
73
+ }
74
+
75
+ function pointIdList(points) {
76
+ return uniqueById(points).map((point) => point.id);
77
+ }
78
+
79
+ export function buildCoverageSchedule({
80
+ chapterId,
81
+ durationMinutes = 25,
82
+ targetPoints = [],
83
+ includeMistakes = false,
84
+ now = new Date()
85
+ } = {}) {
86
+ const duration = normalizeDuration(durationMinutes);
87
+ const config = COVERAGE_DURATION_CONFIGS[duration];
88
+ const points = uniqueById(targetPoints).filter((point) => point.id);
89
+ const totalPoints = points.length;
90
+ if (!totalPoints) {
91
+ const error = new Error('coverage_plan_no_points');
92
+ error.status = 422;
93
+ throw error;
94
+ }
95
+
96
+ const basePaperCount = Math.max(1, Math.ceil(totalPoints / config.newPointCapacity));
97
+ const newChunks = chunkBalanced(points, basePaperCount);
98
+ const mistakePoints = points.filter((point) => /易错|错题|错误|mistake/i.test(point.section || ''));
99
+ const papers = [];
100
+ const completedPoints = [];
101
+ for (let index = 0; index < newChunks.length; index += 1) {
102
+ const newPoints = newChunks[index];
103
+ const reviewCount = index === 0 ? 0 : Math.max(1, Math.round(newPoints.length * config.reviewRatio));
104
+ const recentPoints = completedPoints.slice(-config.newPointCapacity);
105
+ const reviewPoints = reviewPick({
106
+ completedPoints,
107
+ recentPoints,
108
+ mistakePoints,
109
+ count: reviewCount,
110
+ paperIndex: index
111
+ });
112
+ papers.push({
113
+ paperNo: papers.length + 1,
114
+ mode: 'new_coverage',
115
+ targetMinutes: config.targetMinutes,
116
+ questionCount: config.questionCount,
117
+ questionRange: config.questionRange,
118
+ newPointIds: pointIdList(newPoints),
119
+ reviewPointIds: pointIdList(reviewPoints),
120
+ targetPointIds: pointIdList([...newPoints, ...reviewPoints]),
121
+ expectedNewCoverageCount: newPoints.length,
122
+ expectedTotalCoverageCount: uniqueById([...newPoints, ...reviewPoints]).length,
123
+ repeatPolicy: index === 0
124
+ ? '首张卷优先建立新覆盖,不安排复习点。'
125
+ : '复习点来自最近覆盖点、易错专项或高关联前置点;允许少量重复巩固。'
126
+ });
127
+ completedPoints.push(...newPoints);
128
+ }
129
+
130
+ if (basePaperCount > 1) {
131
+ const reviewCount = Math.min(
132
+ config.newPointCapacity,
133
+ Math.max(Math.ceil(config.newPointCapacity * 0.75), mistakePoints.length)
134
+ );
135
+ const reviewPoints = uniqueById([
136
+ ...mistakePoints,
137
+ ...rotatePick(points, reviewCount, papers.length)
138
+ ]).slice(0, reviewCount);
139
+ papers.push({
140
+ paperNo: papers.length + 1,
141
+ mode: 'review_gap',
142
+ targetMinutes: config.targetMinutes,
143
+ questionCount: config.questionCount,
144
+ questionRange: config.questionRange,
145
+ newPointIds: [],
146
+ reviewPointIds: pointIdList(reviewPoints),
147
+ targetPointIds: pointIdList(reviewPoints),
148
+ expectedNewCoverageCount: 0,
149
+ expectedTotalCoverageCount: reviewPoints.length,
150
+ repeatPolicy: '查漏巩固卷只安排复习点,优先易错专项、近期覆盖点和跨组关联点。'
151
+ });
152
+ }
153
+
154
+ return {
155
+ planId: `coverage-plan-${now.toISOString().replace(/[:.]/g, '-')}`,
156
+ chapterId,
157
+ strategy: 'first_round_coverage_with_review',
158
+ durationMinutes: config.targetMinutes,
159
+ durationConfig: config,
160
+ includeMistakes: Boolean(includeMistakes),
161
+ totalKnowledgePointCount: totalPoints,
162
+ basePaperCount,
163
+ reviewPaperCount: papers.filter((paper) => paper.mode === 'review_gap').length,
164
+ totalPaperCount: papers.length,
165
+ createdAt: now.toISOString(),
166
+ coveragePolicy: {
167
+ newPointCapacity: config.newPointCapacity,
168
+ reviewRatio: config.reviewRatio,
169
+ questionCount: config.questionCount,
170
+ rule: '按章节知识点数和单次练习时长排程;前几张卷做首轮覆盖,末张卷查漏巩固;允许有意识重复,但每张卷控制在目标分钟数内。'
171
+ },
172
+ papers
173
+ };
174
+ }
@@ -1,4 +1,4 @@
1
- import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
1
+ import { access, copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import {
@@ -175,9 +175,16 @@ export async function ensureChapterWorkspace(chapter) {
175
175
 
176
176
  const folderPath = path.join(paths.imageRoot, chapter.imageFolder);
177
177
  const sourceFiles = await listSourcePageImages(folderPath);
178
+ // source_pages/ holds the chapter's local source-page copies (see AGENTS.md).
179
+ // When the manifest is stale and no local copies exist, copy the images from
180
+ // the shared imageRoot into source_pages/ so chapterImages can resolve them.
181
+ await Promise.all(sourceFiles.map((file) => copyFile(
182
+ path.join(folderPath, file),
183
+ path.join(chapterPaths.sourcePages, file)
184
+ )));
178
185
  await writeJson(
179
186
  chapterPaths.sourceManifest,
180
- buildSourceManifest(chapter, chapterPaths, sourceFiles, folderPath)
187
+ buildSourceManifest(chapter, chapterPaths, sourceFiles, chapterPaths.sourcePages)
181
188
  );
182
189
  return chapterPaths;
183
190
  }
@@ -331,14 +338,14 @@ export async function syncGlobalIndexes() {
331
338
  const legacyMistakes = await readJson(paths.mistakes, []);
332
339
  const ids = await chapterIdsWithData(chapters);
333
340
  const masteryIndex = {
334
- student: '周子烊',
341
+ student: '学生',
335
342
  version: 1,
336
343
  updatedAt: new Date().toISOString(),
337
344
  source: 'chapter-masteries',
338
345
  chapters: {}
339
346
  };
340
347
  const mistakesIndex = {
341
- student: '周子烊',
348
+ student: '学生',
342
349
  version: 1,
343
350
  updatedAt: new Date().toISOString(),
344
351
  source: 'chapter-mistakes',
@@ -503,7 +510,8 @@ function normalizeKnowledgeDoc(chapter, doc, source = 'seed') {
503
510
  formulas: Array.isArray(point.formulas) ? point.formulas : [],
504
511
  pitfalls: Array.isArray(point.pitfalls) ? point.pitfalls : [],
505
512
  examples: Array.isArray(point.examples) ? point.examples : [],
506
- questionTemplates: Array.isArray(point.questionTemplates) ? point.questionTemplates : []
513
+ questionTemplates: Array.isArray(point.questionTemplates) ? point.questionTemplates : [],
514
+ sources: Array.isArray(point.sources) ? point.sources : []
507
515
  }))
508
516
  })).filter((section) => section.points.length);
509
517
  return {
@@ -517,9 +525,40 @@ function normalizeKnowledgeDoc(chapter, doc, source = 'seed') {
517
525
  };
518
526
  }
519
527
 
528
+ function markdownSection(markdown, title) {
529
+ const lines = String(markdown || '').split(/\r?\n/);
530
+ const start = lines.findIndex((line) => new RegExp(`^##\\s+${title}\\s*$`).test(line.trim()));
531
+ if (start < 0) return '';
532
+ const end = lines.findIndex((line, index) => index > start && /^##\s+/.test(line.trim()));
533
+ return lines.slice(start + 1, end < 0 ? undefined : end).join('\n').trim();
534
+ }
535
+
536
+ function markdownHeadingTitles(sectionText) {
537
+ return String(sectionText || '')
538
+ .split(/\r?\n/)
539
+ .map((line) => line.match(/^###\s+(.+?)\s*$/)?.[1]?.trim())
540
+ .filter(Boolean);
541
+ }
542
+
543
+ function parsePageExtractMarkdown(markdown, fallback = {}) {
544
+ const pageTitle = markdownSection(markdown, '页面标题').split(/\r?\n/).find(Boolean) || fallback.imageFile || '';
545
+ const knowledgeTitles = markdownHeadingTitles(markdownSection(markdown, '知识点'));
546
+ const mistakeTitles = markdownHeadingTitles(markdownSection(markdown, '易错点'));
547
+ return {
548
+ pageTitle: pageTitle.replace(/^#+\s*/, '').trim(),
549
+ rawOutline: markdownSection(markdown, '原文结构')
550
+ .split(/\r?\n/)
551
+ .map((line) => line.replace(/^\s*-\s*/, '').trim())
552
+ .filter(Boolean),
553
+ knowledgePoints: knowledgeTitles.map((title) => ({ title })),
554
+ easyMistakes: mistakeTitles.map((title) => ({ title, errorType: title })),
555
+ markdown
556
+ };
557
+ }
558
+
520
559
  function buildInitialMastery(chapters, knowledgeDocs, existing = null) {
521
560
  const next = {
522
- student: '周子烊',
561
+ student: '学生',
523
562
  version: 1,
524
563
  updatedAt: new Date().toISOString(),
525
564
  chapters: existing?.chapters || {}
@@ -638,11 +677,12 @@ export async function getKnowledgeBundle(chapterId = null) {
638
677
  const summary = await readJson(chapterPaths.knowledgeSummary, null);
639
678
  const pageExtracts = sourceManifest?.pages
640
679
  ? await Promise.all(sourceManifest.pages.map(async (page, index) => {
641
- const extractFile = `${path.basename(page.file, path.extname(page.file))}.json`;
680
+ const extractFile = `${path.basename(page.file, path.extname(page.file))}.md`;
642
681
  const extractPath = path.join(chapterPaths.pageExtracts, extractFile);
643
- const extract = await readJson(extractPath, null);
682
+ const markdown = await readText(extractPath, '');
683
+ const extract = markdown ? parsePageExtractMarkdown(markdown, { imageFile: page.file }) : null;
644
684
  return extract
645
- ? { ...extract, extractPath: relativeDataPath(extractPath) }
685
+ ? { chapterId, imageFile: page.file, pageIndex: index + 1, ...extract, extractPath: relativeDataPath(extractPath) }
646
686
  : {
647
687
  chapterId,
648
688
  imageFile: page.file,
package/server/index.js CHANGED
@@ -53,6 +53,18 @@ const port = Number(process.env.PORT || 4173);
53
53
 
54
54
  await seedChaptersFromManifest();
55
55
 
56
+ function safeGenerationCacheKey(value, fallback) {
57
+ const key = String(value || '').trim();
58
+ if (/^ui-[a-zA-Z0-9_.-]{8,120}$/.test(key)) return key;
59
+ return fallback;
60
+ }
61
+
62
+ async function practiceGenerationCacheDir({ chapterId, cacheKey }) {
63
+ const dir = path.join(chapterDataPaths(chapterId).context, 'generation-cache', cacheKey);
64
+ await mkdir(dir, { recursive: true });
65
+ return dir;
66
+ }
67
+
56
68
  app.use(express.json({ limit: '2mb' }));
57
69
  app.use('/chapter-images', express.static(paths.imageRoot));
58
70
  app.use('/chapter-data', express.static(paths.chapterData));
@@ -140,6 +152,54 @@ app.put('/api/profile', async (req, res, next) => {
140
152
  }
141
153
  });
142
154
 
155
+ // ── First-time setup ────────────────────────────────────────────────
156
+
157
+ app.get('/api/setup/status', async (req, res, next) => {
158
+ try {
159
+ const profile = await readJson(paths.profile, null).catch(() => null);
160
+ const needsSetup = !profile?.setupCompletedAt;
161
+ res.json({ needsSetup });
162
+ } catch (error) {
163
+ next(error);
164
+ }
165
+ });
166
+
167
+ app.post('/api/setup', async (req, res, next) => {
168
+ try {
169
+ const body = req.body || {};
170
+ const profileFields = body.profile || {};
171
+ const llm = body.llm || {};
172
+
173
+ // Save profile with setup completion marker
174
+ const current = await readJson(paths.profile, {}).catch(() => ({}));
175
+ const profile = {
176
+ ...current,
177
+ name: String(profileFields.name || current.name || '学生').trim(),
178
+ gender: String(profileFields.gender || current.gender || '').trim(),
179
+ age: Number.isFinite(Number(profileFields.age)) ? Number(profileFields.age) : current.age || 14,
180
+ stage: String(profileFields.stage || current.stage || '').trim(),
181
+ primaryGoal: String(profileFields.primaryGoal || current.primaryGoal || '').trim(),
182
+ priorityTrack: String(profileFields.priorityTrack || current.priorityTrack || '').trim(),
183
+ preferences: String(profileFields.preferences || current.preferences || '').trim(),
184
+ profileNotes: String(profileFields.profileNotes || current.profileNotes || '').trim(),
185
+ setupCompletedAt: new Date().toISOString(),
186
+ updatedAt: new Date().toISOString()
187
+ };
188
+ await writeJson(paths.profile, profile);
189
+
190
+ // Save LLM settings
191
+ await writeLlmSettings({
192
+ baseUrl: llm.baseUrl,
193
+ model: llm.model,
194
+ apiKey: llm.apiKey
195
+ });
196
+
197
+ res.json({ ok: true });
198
+ } catch (error) {
199
+ next(error);
200
+ }
201
+ });
202
+
143
203
  app.get('/api/settings/llm', async (req, res, next) => {
144
204
  try {
145
205
  res.json(await readLlmSettings());
@@ -282,7 +342,10 @@ app.post('/api/practices/generate', async (req, res, next) => {
282
342
  knowledgePointId: req.body.knowledgePointId || '',
283
343
  knowledgePointIds: Array.isArray(req.body.knowledgePointIds) ? req.body.knowledgePointIds : [],
284
344
  abilityIds: Array.isArray(req.body.abilityIds) ? req.body.abilityIds : [],
285
- questionKind: req.body.questionKind || 'auto'
345
+ questionKind: req.body.questionKind || 'auto',
346
+ verifySvgFigures: Boolean(req.body.verifySvgFigures),
347
+ requireSvg: Boolean(req.body.requireSvg),
348
+ minSvgQuestions: req.body.minSvgQuestions
286
349
  });
287
350
  console.log(
288
351
  `[practice.generated] ${practice.id} chapter=${practice.chapterId} questions=${practice.questions.length} source=${practice.source} review=${practice.review?.passed ? 'pass' : 'fail'}` +
@@ -302,6 +365,15 @@ app.post('/api/jobs/practice-generate', async (req, res, next) => {
302
365
  try {
303
366
  startJob(job.id);
304
367
  addJobEvent(job.id, { step: 'practice_generate.start', message: '正在生成练习卷。' });
368
+ const generationCacheKey = safeGenerationCacheKey(req.body.generationCacheKey, `ui-${job.id}`);
369
+ const generationCacheDir = await practiceGenerationCacheDir({
370
+ chapterId: req.body.chapterId,
371
+ cacheKey: generationCacheKey
372
+ });
373
+ addJobEvent(job.id, {
374
+ step: 'practice_generate.cache_ready',
375
+ message: `阶段缓存已启用:${path.basename(generationCacheDir)}。失败后重试会复用已完成阶段。`
376
+ });
305
377
  const practice = await createPractice({
306
378
  chapterId: req.body.chapterId,
307
379
  type: req.body.type || 'knowledge_coverage',
@@ -312,6 +384,11 @@ app.post('/api/jobs/practice-generate', async (req, res, next) => {
312
384
  knowledgePointIds: Array.isArray(req.body.knowledgePointIds) ? req.body.knowledgePointIds : [],
313
385
  abilityIds: Array.isArray(req.body.abilityIds) ? req.body.abilityIds : [],
314
386
  questionKind: req.body.questionKind || 'auto',
387
+ generationCacheDir,
388
+ fastReview: Boolean(req.body.fastReview),
389
+ verifySvgFigures: Boolean(req.body.verifySvgFigures),
390
+ requireSvg: Boolean(req.body.requireSvg),
391
+ minSvgQuestions: req.body.minSvgQuestions,
315
392
  onProgress: (event) => addJobEvent(job.id, event)
316
393
  });
317
394
  addJobEvent(job.id, { step: 'practice_generate.done', message: `练习卷生成完成,共 ${practice.questions.length} 题。` });