@zhouchangui/math-ati 0.1.1 → 0.1.3

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.
@@ -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
+ }
@@ -331,14 +331,14 @@ export async function syncGlobalIndexes() {
331
331
  const legacyMistakes = await readJson(paths.mistakes, []);
332
332
  const ids = await chapterIdsWithData(chapters);
333
333
  const masteryIndex = {
334
- student: '周子烊',
334
+ student: '学生',
335
335
  version: 1,
336
336
  updatedAt: new Date().toISOString(),
337
337
  source: 'chapter-masteries',
338
338
  chapters: {}
339
339
  };
340
340
  const mistakesIndex = {
341
- student: '周子烊',
341
+ student: '学生',
342
342
  version: 1,
343
343
  updatedAt: new Date().toISOString(),
344
344
  source: 'chapter-mistakes',
@@ -503,7 +503,8 @@ function normalizeKnowledgeDoc(chapter, doc, source = 'seed') {
503
503
  formulas: Array.isArray(point.formulas) ? point.formulas : [],
504
504
  pitfalls: Array.isArray(point.pitfalls) ? point.pitfalls : [],
505
505
  examples: Array.isArray(point.examples) ? point.examples : [],
506
- questionTemplates: Array.isArray(point.questionTemplates) ? point.questionTemplates : []
506
+ questionTemplates: Array.isArray(point.questionTemplates) ? point.questionTemplates : [],
507
+ sources: Array.isArray(point.sources) ? point.sources : []
507
508
  }))
508
509
  })).filter((section) => section.points.length);
509
510
  return {
@@ -517,9 +518,40 @@ function normalizeKnowledgeDoc(chapter, doc, source = 'seed') {
517
518
  };
518
519
  }
519
520
 
521
+ function markdownSection(markdown, title) {
522
+ const lines = String(markdown || '').split(/\r?\n/);
523
+ const start = lines.findIndex((line) => new RegExp(`^##\\s+${title}\\s*$`).test(line.trim()));
524
+ if (start < 0) return '';
525
+ const end = lines.findIndex((line, index) => index > start && /^##\s+/.test(line.trim()));
526
+ return lines.slice(start + 1, end < 0 ? undefined : end).join('\n').trim();
527
+ }
528
+
529
+ function markdownHeadingTitles(sectionText) {
530
+ return String(sectionText || '')
531
+ .split(/\r?\n/)
532
+ .map((line) => line.match(/^###\s+(.+?)\s*$/)?.[1]?.trim())
533
+ .filter(Boolean);
534
+ }
535
+
536
+ function parsePageExtractMarkdown(markdown, fallback = {}) {
537
+ const pageTitle = markdownSection(markdown, '页面标题').split(/\r?\n/).find(Boolean) || fallback.imageFile || '';
538
+ const knowledgeTitles = markdownHeadingTitles(markdownSection(markdown, '知识点'));
539
+ const mistakeTitles = markdownHeadingTitles(markdownSection(markdown, '易错点'));
540
+ return {
541
+ pageTitle: pageTitle.replace(/^#+\s*/, '').trim(),
542
+ rawOutline: markdownSection(markdown, '原文结构')
543
+ .split(/\r?\n/)
544
+ .map((line) => line.replace(/^\s*-\s*/, '').trim())
545
+ .filter(Boolean),
546
+ knowledgePoints: knowledgeTitles.map((title) => ({ title })),
547
+ easyMistakes: mistakeTitles.map((title) => ({ title, errorType: title })),
548
+ markdown
549
+ };
550
+ }
551
+
520
552
  function buildInitialMastery(chapters, knowledgeDocs, existing = null) {
521
553
  const next = {
522
- student: '周子烊',
554
+ student: '学生',
523
555
  version: 1,
524
556
  updatedAt: new Date().toISOString(),
525
557
  chapters: existing?.chapters || {}
@@ -638,11 +670,12 @@ export async function getKnowledgeBundle(chapterId = null) {
638
670
  const summary = await readJson(chapterPaths.knowledgeSummary, null);
639
671
  const pageExtracts = sourceManifest?.pages
640
672
  ? await Promise.all(sourceManifest.pages.map(async (page, index) => {
641
- const extractFile = `${path.basename(page.file, path.extname(page.file))}.json`;
673
+ const extractFile = `${path.basename(page.file, path.extname(page.file))}.md`;
642
674
  const extractPath = path.join(chapterPaths.pageExtracts, extractFile);
643
- const extract = await readJson(extractPath, null);
675
+ const markdown = await readText(extractPath, '');
676
+ const extract = markdown ? parsePageExtractMarkdown(markdown, { imageFile: page.file }) : null;
644
677
  return extract
645
- ? { ...extract, extractPath: relativeDataPath(extractPath) }
678
+ ? { chapterId, imageFile: page.file, pageIndex: index + 1, ...extract, extractPath: relativeDataPath(extractPath) }
646
679
  : {
647
680
  chapterId,
648
681
  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));
@@ -282,7 +294,10 @@ app.post('/api/practices/generate', async (req, res, next) => {
282
294
  knowledgePointId: req.body.knowledgePointId || '',
283
295
  knowledgePointIds: Array.isArray(req.body.knowledgePointIds) ? req.body.knowledgePointIds : [],
284
296
  abilityIds: Array.isArray(req.body.abilityIds) ? req.body.abilityIds : [],
285
- questionKind: req.body.questionKind || 'auto'
297
+ questionKind: req.body.questionKind || 'auto',
298
+ verifySvgFigures: Boolean(req.body.verifySvgFigures),
299
+ requireSvg: Boolean(req.body.requireSvg),
300
+ minSvgQuestions: req.body.minSvgQuestions
286
301
  });
287
302
  console.log(
288
303
  `[practice.generated] ${practice.id} chapter=${practice.chapterId} questions=${practice.questions.length} source=${practice.source} review=${practice.review?.passed ? 'pass' : 'fail'}` +
@@ -302,6 +317,15 @@ app.post('/api/jobs/practice-generate', async (req, res, next) => {
302
317
  try {
303
318
  startJob(job.id);
304
319
  addJobEvent(job.id, { step: 'practice_generate.start', message: '正在生成练习卷。' });
320
+ const generationCacheKey = safeGenerationCacheKey(req.body.generationCacheKey, `ui-${job.id}`);
321
+ const generationCacheDir = await practiceGenerationCacheDir({
322
+ chapterId: req.body.chapterId,
323
+ cacheKey: generationCacheKey
324
+ });
325
+ addJobEvent(job.id, {
326
+ step: 'practice_generate.cache_ready',
327
+ message: `阶段缓存已启用:${path.basename(generationCacheDir)}。失败后重试会复用已完成阶段。`
328
+ });
305
329
  const practice = await createPractice({
306
330
  chapterId: req.body.chapterId,
307
331
  type: req.body.type || 'knowledge_coverage',
@@ -312,6 +336,11 @@ app.post('/api/jobs/practice-generate', async (req, res, next) => {
312
336
  knowledgePointIds: Array.isArray(req.body.knowledgePointIds) ? req.body.knowledgePointIds : [],
313
337
  abilityIds: Array.isArray(req.body.abilityIds) ? req.body.abilityIds : [],
314
338
  questionKind: req.body.questionKind || 'auto',
339
+ generationCacheDir,
340
+ fastReview: Boolean(req.body.fastReview),
341
+ verifySvgFigures: Boolean(req.body.verifySvgFigures),
342
+ requireSvg: Boolean(req.body.requireSvg),
343
+ minSvgQuestions: req.body.minSvgQuestions,
315
344
  onProgress: (event) => addJobEvent(job.id, event)
316
345
  });
317
346
  addJobEvent(job.id, { step: 'practice_generate.done', message: `练习卷生成完成,共 ${practice.questions.length} 题。` });