@zhouchangui/math-ati 0.1.2 → 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.
- package/AGENTS.md +3 -1
- package/README.md +11 -0
- package/dist/assets/{index-CGZslJ0a.css → index--Um9OfFu.css} +1 -1
- package/dist/assets/index-CS-PgjYi.js +22 -0
- package/dist/index.html +3 -3
- package/package.json +3 -2
- package/prompts/geometry-practice-experience.md +44 -0
- package/prompts/knowledge-extract.system.md +35 -54
- package/prompts/knowledge-summarize.system.md +8 -6
- package/prompts/practice-generate.system.md +6 -4
- package/prompts/practice-review.system.md +4 -2
- package/prompts/practice-revise.system.md +5 -4
- package/prompts/svg-figure-review.system.md +13 -0
- package/prompts/svg-figure-revise.system.md +21 -0
- package/server/agentClient.js +179 -10
- package/server/coveragePlanner.js +174 -0
- package/server/fileStore.js +40 -7
- package/server/index.js +30 -1
- package/server/knowledgeExtractor.js +553 -115
- package/server/practiceGenerator.js +610 -83
- package/server/practicePaperHtml.js +105 -35
- package/server/practiceService.js +27 -2
- package/server/submissionService.js +1 -1
- package/server/svgFigureVerifier.js +315 -0
- 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
|
+
}
|
package/server/fileStore.js
CHANGED
|
@@ -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))}.
|
|
673
|
+
const extractFile = `${path.basename(page.file, path.extname(page.file))}.md`;
|
|
642
674
|
const extractPath = path.join(chapterPaths.pageExtracts, extractFile);
|
|
643
|
-
const
|
|
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} 题。` });
|