@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.
- package/AGENTS.md +4 -1
- package/README.md +11 -0
- package/bin/math-ati.js +136 -5
- package/dist/assets/{index-CGZslJ0a.css → index-DOg8CQsE.css} +1 -1
- package/dist/assets/index-DyfeTKmg.js +22 -0
- package/dist/index.html +3 -3
- package/package.json +9 -5
- package/prompts/geometry-practice-experience.md +44 -0
- package/prompts/grading.system.md +3 -1
- package/prompts/knowledge-extract.system.md +35 -54
- package/prompts/knowledge-structure.system.md +75 -0
- package/prompts/knowledge-summarize.system.md +21 -7
- package/prompts/pdf-grading.system.md +4 -1
- package/prompts/pdf-recheck.system.md +2 -0
- package/prompts/practice-answers.system.md +154 -0
- package/prompts/practice-coverage-repair.system.md +112 -0
- package/prompts/practice-generate.system.md +51 -9
- package/prompts/practice-review.system.md +4 -2
- package/prompts/practice-revise.system.md +5 -4
- package/prompts/practice-rules.md +61 -0
- 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 +49 -9
- package/server/index.js +78 -1
- package/server/knowledgeExtractor.js +717 -120
- package/server/knowledgeFeedback.js +69 -0
- package/server/practiceGenerator.js +637 -116
- package/server/practicePaperHtml.js +105 -35
- package/server/practiceService.js +27 -2
- package/server/promptStore.js +14 -0
- 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
|
@@ -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,
|
|
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))}.
|
|
680
|
+
const extractFile = `${path.basename(page.file, path.extname(page.file))}.md`;
|
|
642
681
|
const extractPath = path.join(chapterPaths.pageExtracts, extractFile);
|
|
643
|
-
const
|
|
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} 题。` });
|