@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.
- package/.env.local.example +6 -0
- package/AGENTS.md +273 -0
- package/README.md +34 -0
- package/bin/math-ati.js +194 -0
- package/dist/assets/index-BYFoutza.js +22 -0
- package/dist/assets/index-Bk2WFPoL.css +1 -0
- package/dist/index.html +13 -0
- package/package.json +72 -0
- package/prompts/grading.system.md +129 -0
- package/prompts/knowledge-extract.system.md +123 -0
- package/prompts/knowledge-summarize.system.md +127 -0
- package/prompts/learning-summary.system.md +123 -0
- package/prompts/pdf-grading.system.md +80 -0
- package/prompts/pdf-page-extract.system.md +52 -0
- package/prompts/pdf-recheck.system.md +43 -0
- package/prompts/practice-generate.system.md +161 -0
- package/prompts/practice-review.system.md +65 -0
- package/prompts/practice-revise.system.md +56 -0
- package/server/abilityService.js +259 -0
- package/server/agentClient.js +202 -0
- package/server/env.js +4 -0
- package/server/fileStore.js +726 -0
- package/server/grading.js +116 -0
- package/server/index.js +655 -0
- package/server/jobStore.js +169 -0
- package/server/knowledgeBase.js +30 -0
- package/server/knowledgeExtractor.js +360 -0
- package/server/knowledgeFeedback.js +299 -0
- package/server/llmConfig.js +96 -0
- package/server/mistakeLifecycle.js +251 -0
- package/server/pdfSubmissionGrader.js +846 -0
- package/server/practiceGenerator.js +908 -0
- package/server/practicePaperHtml.js +313 -0
- package/server/practiceReviewer.js +307 -0
- package/server/practiceService.js +331 -0
- package/server/promptStore.js +16 -0
- package/server/submissionService.js +184 -0
- package/templates/workspace/.env.local.example +6 -0
- package/templates/workspace/data/global/ability_index.json +5 -0
- package/templates/workspace/data/global/chapters.json +621 -0
- package/templates/workspace/data/global/mastery_index.json +6 -0
- package/templates/workspace/data/global/mistakes_index.json +7 -0
- package/templates/workspace/data/global/student_profile.json +11 -0
- package/templates/workspace/data/knowledge_points.json +1264 -0
- package/templates/workspace/data/mistakes.json +1 -0
- package/vite.config.js +21 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import {
|
|
4
|
+
chapterDataPaths,
|
|
5
|
+
paths,
|
|
6
|
+
readJson,
|
|
7
|
+
syncGlobalIndexes,
|
|
8
|
+
writeJson
|
|
9
|
+
} from './fileStore.js';
|
|
10
|
+
|
|
11
|
+
function statusForScore(item) {
|
|
12
|
+
const maxScore = Number(item.maxScore || 0);
|
|
13
|
+
const score = Number(item.score || 0);
|
|
14
|
+
const ratio = maxScore > 0 ? score / maxScore : 0;
|
|
15
|
+
if (item.status === 'correct' || ratio >= 0.85) return 'mastered';
|
|
16
|
+
if (item.status === 'partial' || ratio > 0) return 'needs_review';
|
|
17
|
+
if (item.status === 'wrong' || item.status === 'unrecognized') return 'needs_review';
|
|
18
|
+
return 'needs_review';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function questionById(practice) {
|
|
22
|
+
return new Map((practice.questions || []).map((question) => [question.id, question]));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function gradingStats(grading = []) {
|
|
26
|
+
const totalScore = grading.reduce((sum, item) => sum + Number(item.maxScore || 0), 0);
|
|
27
|
+
const earnedScore = grading.reduce((sum, item) => sum + Number(item.score || 0), 0);
|
|
28
|
+
const accuracy = totalScore > 0 ? Math.round((earnedScore / totalScore) * 100) : 0;
|
|
29
|
+
const byStatus = grading.reduce((acc, item) => {
|
|
30
|
+
const key = item.status || 'needs_review';
|
|
31
|
+
acc[key] = (acc[key] || 0) + 1;
|
|
32
|
+
return acc;
|
|
33
|
+
}, {});
|
|
34
|
+
return {
|
|
35
|
+
totalQuestions: grading.length,
|
|
36
|
+
totalScore,
|
|
37
|
+
earnedScore,
|
|
38
|
+
accuracy,
|
|
39
|
+
correct: byStatus.correct || 0,
|
|
40
|
+
partial: byStatus.partial || 0,
|
|
41
|
+
wrong: byStatus.wrong || 0,
|
|
42
|
+
unrecognized: byStatus.unrecognized || 0,
|
|
43
|
+
needsReview: byStatus.needs_review || 0
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildKnowledgeEvidence({ practice, grading = [], stitchedAnswers = [] }) {
|
|
48
|
+
const questions = questionById(practice);
|
|
49
|
+
const stitchedByQuestion = new Map(stitchedAnswers.map((item) => [item.questionId, item]));
|
|
50
|
+
const evidence = [];
|
|
51
|
+
for (const item of grading) {
|
|
52
|
+
const question = questions.get(item.questionId);
|
|
53
|
+
if (!question) continue;
|
|
54
|
+
const result = statusForScore(item);
|
|
55
|
+
for (const knowledgePointId of question.knowledgePointIds || []) {
|
|
56
|
+
evidence.push({
|
|
57
|
+
knowledgePointId,
|
|
58
|
+
knowledgePointTitle: question.knowledgePoints?.[
|
|
59
|
+
(question.knowledgePointIds || []).indexOf(knowledgePointId)
|
|
60
|
+
] || knowledgePointId,
|
|
61
|
+
sourcePracticeId: practice.id,
|
|
62
|
+
sourceQuestionId: question.id,
|
|
63
|
+
sourcePages: stitchedByQuestion.get(question.id)?.sourcePages || item.sourcePages || [],
|
|
64
|
+
evidenceType: item.status || 'needs_review',
|
|
65
|
+
result,
|
|
66
|
+
score: Number(item.score || 0),
|
|
67
|
+
maxScore: Number(item.maxScore || question.score || 0),
|
|
68
|
+
confidence: item.confidence || stitchedByQuestion.get(question.id)?.stitchingConfidence || 'medium',
|
|
69
|
+
reason: item.comment || item.mistakeSummary || ''
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return evidence;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildWeakPoints({ practice, grading = [], knowledgeEvidence = [] }) {
|
|
77
|
+
const questions = questionById(practice);
|
|
78
|
+
const byPoint = new Map();
|
|
79
|
+
for (const evidence of knowledgeEvidence) {
|
|
80
|
+
if (evidence.result !== 'needs_review') continue;
|
|
81
|
+
const current = byPoint.get(evidence.knowledgePointId) || {
|
|
82
|
+
knowledgePointId: evidence.knowledgePointId,
|
|
83
|
+
title: evidence.knowledgePointTitle,
|
|
84
|
+
sourceQuestionIds: [],
|
|
85
|
+
issueTypes: [],
|
|
86
|
+
severity: 'low',
|
|
87
|
+
issue: '',
|
|
88
|
+
nextAction: ''
|
|
89
|
+
};
|
|
90
|
+
current.sourceQuestionIds.push(evidence.sourceQuestionId);
|
|
91
|
+
const gradingItem = grading.find((item) => item.questionId === evidence.sourceQuestionId);
|
|
92
|
+
current.issueTypes.push(...(gradingItem?.errorTypes || ['待巩固']));
|
|
93
|
+
byPoint.set(evidence.knowledgePointId, current);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const point of byPoint.values()) {
|
|
97
|
+
point.sourceQuestionIds = [...new Set(point.sourceQuestionIds)].sort((a, b) => {
|
|
98
|
+
const left = Number(a.replace(/^q/, ''));
|
|
99
|
+
const right = Number(b.replace(/^q/, ''));
|
|
100
|
+
return left - right;
|
|
101
|
+
});
|
|
102
|
+
point.issueTypes = [...new Set(point.issueTypes.filter(Boolean))].slice(0, 6);
|
|
103
|
+
const related = point.sourceQuestionIds
|
|
104
|
+
.map((questionId) => questions.get(questionId))
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
const total = related.reduce((sum, question) => sum + Number(question.score || 0), 0);
|
|
107
|
+
const earned = point.sourceQuestionIds.reduce((sum, questionId) => {
|
|
108
|
+
const item = grading.find((candidate) => candidate.questionId === questionId);
|
|
109
|
+
return sum + Number(item?.score || 0);
|
|
110
|
+
}, 0);
|
|
111
|
+
const ratio = total > 0 ? earned / total : 0;
|
|
112
|
+
point.severity = ratio <= 0.35 || point.sourceQuestionIds.length >= 3 ? 'high' : ratio < 0.75 ? 'medium' : 'low';
|
|
113
|
+
point.issue = point.issueTypes.length
|
|
114
|
+
? `${point.issueTypes.join('、')},相关题目:${point.sourceQuestionIds.join('、')}`
|
|
115
|
+
: `相关题目 ${point.sourceQuestionIds.join('、')} 暴露出掌握不稳。`;
|
|
116
|
+
point.nextAction = point.severity === 'high'
|
|
117
|
+
? '下次优先生成低坡度讲解后修复题,先拆步骤再综合。'
|
|
118
|
+
: '下次穿插 1-2 道同类巩固题,观察是否稳定。';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return [...byPoint.values()].sort((a, b) => {
|
|
122
|
+
const severityOrder = { high: 0, medium: 1, low: 2 };
|
|
123
|
+
return severityOrder[a.severity] - severityOrder[b.severity] ||
|
|
124
|
+
a.knowledgePointId.localeCompare(b.knowledgePointId);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function questionNumber(questionId) {
|
|
129
|
+
return Number(String(questionId || '').replace(/^q/, '')) || 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function buildCorrectionPackMarkdown({ practice, grading = [], weakPoints = [] }) {
|
|
133
|
+
const questions = questionById(practice);
|
|
134
|
+
const problemItems = grading
|
|
135
|
+
.filter((item) => ['partial', 'wrong', 'unrecognized', 'needs_review'].includes(item.status))
|
|
136
|
+
.sort((a, b) => questionNumber(a.questionId) - questionNumber(b.questionId));
|
|
137
|
+
|
|
138
|
+
const lines = [
|
|
139
|
+
`# ${practice.title} 错题讲解包`,
|
|
140
|
+
'',
|
|
141
|
+
`- 练习:${practice.id}`,
|
|
142
|
+
`- 章节:${practice.chapterTitle}`,
|
|
143
|
+
`- 需讲解题数:${problemItems.length}`,
|
|
144
|
+
''
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
if (weakPoints.length) {
|
|
148
|
+
lines.push('## 薄弱知识点', '');
|
|
149
|
+
for (const point of weakPoints) {
|
|
150
|
+
lines.push(`- ${point.title}:${point.issue}。${point.nextAction}`);
|
|
151
|
+
}
|
|
152
|
+
lines.push('');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
lines.push('## 错题与讲解方法', '');
|
|
156
|
+
if (!problemItems.length) {
|
|
157
|
+
lines.push('本次没有需要讲解的错题,保持这个节奏继续做间隔复查。', '');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const item of problemItems) {
|
|
161
|
+
const question = questions.get(item.questionId);
|
|
162
|
+
if (!question) continue;
|
|
163
|
+
lines.push(`### ${item.questionId}|${(question.knowledgePoints || []).join('、') || '知识点待确认'}`);
|
|
164
|
+
lines.push('');
|
|
165
|
+
lines.push(`- 状态:${item.status}`);
|
|
166
|
+
lines.push(`- 得分:${Number(item.score || 0)} / ${Number(item.maxScore || question.score || 0)}`);
|
|
167
|
+
lines.push(`- 学生作答:${item.recognizedAnswer || '未能稳定识别'}`);
|
|
168
|
+
lines.push(`- 错因摘要:${item.mistakeSummary || item.comment || '待结合原卷讲解'}`);
|
|
169
|
+
if (item.errorTypes?.length) lines.push(`- 错因标签:${item.errorTypes.join('、')}`);
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push('**标准答案:**');
|
|
172
|
+
lines.push('');
|
|
173
|
+
lines.push(item.referenceAnswer || '批改阶段未生成稳定参考答案,请结合原卷人工核对。');
|
|
174
|
+
lines.push('');
|
|
175
|
+
if (item.solutionMethod?.length) {
|
|
176
|
+
lines.push('**推荐讲解步骤:**');
|
|
177
|
+
for (const step of item.solutionMethod) lines.push(`- ${step}`);
|
|
178
|
+
lines.push('');
|
|
179
|
+
}
|
|
180
|
+
if (item.teachingHint) {
|
|
181
|
+
lines.push('**讲解提示:**');
|
|
182
|
+
lines.push('');
|
|
183
|
+
lines.push(item.teachingHint);
|
|
184
|
+
lines.push('');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return `${lines.join('\n').trim()}\n`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function buildPositiveReportMarkdown({
|
|
192
|
+
practice,
|
|
193
|
+
gradingResult,
|
|
194
|
+
knowledgeEvidence = [],
|
|
195
|
+
weakPoints = [],
|
|
196
|
+
selfCheck = null
|
|
197
|
+
}) {
|
|
198
|
+
const stats = gradingStats(gradingResult.grading || []);
|
|
199
|
+
const mastered = knowledgeEvidence.filter((item) => item.result === 'mastered');
|
|
200
|
+
const masteredTitles = [...new Set(mastered.map((item) => item.knowledgePointTitle).filter(Boolean))].slice(0, 12);
|
|
201
|
+
const weakTitles = weakPoints.slice(0, 8).map((item) => item.title);
|
|
202
|
+
|
|
203
|
+
const lines = [
|
|
204
|
+
`# ${practice.title} 批改反馈`,
|
|
205
|
+
'',
|
|
206
|
+
`- 练习:${practice.id}`,
|
|
207
|
+
`- 章节:${practice.chapterTitle}`,
|
|
208
|
+
`- 题目数:${stats.totalQuestions}`,
|
|
209
|
+
`- 得分:${stats.earnedScore} / ${stats.totalScore}`,
|
|
210
|
+
`- 正确率:${stats.accuracy}%`,
|
|
211
|
+
`- 正确:${stats.correct} 题`,
|
|
212
|
+
`- 部分正确:${stats.partial} 题`,
|
|
213
|
+
`- 错误:${stats.wrong} 题`,
|
|
214
|
+
`- 无法识别:${stats.unrecognized} 题`,
|
|
215
|
+
`- 需人工确认:${stats.needsReview} 题`,
|
|
216
|
+
'',
|
|
217
|
+
'## 正向反馈',
|
|
218
|
+
'',
|
|
219
|
+
gradingResult.summary || `本次完成了 ${stats.totalQuestions} 道题,其中 ${stats.correct} 道判断为正确。`,
|
|
220
|
+
''
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
if (masteredTitles.length) {
|
|
224
|
+
lines.push('## 已表现较好的知识点', '');
|
|
225
|
+
for (const title of masteredTitles) lines.push(`- ${title}`);
|
|
226
|
+
lines.push('');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (weakTitles.length) {
|
|
230
|
+
lines.push('## 下一步优先巩固', '');
|
|
231
|
+
for (const title of weakTitles) lines.push(`- ${title}`);
|
|
232
|
+
lines.push('');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (gradingResult.nextPracticeSuggestion) {
|
|
236
|
+
lines.push('## 下一次练习建议', '', gradingResult.nextPracticeSuggestion, '');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (selfCheck) {
|
|
240
|
+
lines.push('## 自动核对', '');
|
|
241
|
+
lines.push(`- 缺失题目:${selfCheck.missingQuestionIds.length ? selfCheck.missingQuestionIds.join('、') : '无'}`);
|
|
242
|
+
lines.push(`- 无效题号:${selfCheck.unknownQuestionIds.length ? selfCheck.unknownQuestionIds.join('、') : '无'}`);
|
|
243
|
+
lines.push(`- 分数异常:${selfCheck.scoreIssues.length}`);
|
|
244
|
+
lines.push(`- 二次复核题数:${selfCheck.recheckedQuestionIds.length}`);
|
|
245
|
+
lines.push('');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return `${lines.join('\n').trim()}\n`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function applyMasteryMarkersToDoc(doc, chapterMastery) {
|
|
252
|
+
return {
|
|
253
|
+
...doc,
|
|
254
|
+
sections: (doc.sections || []).map((section) => ({
|
|
255
|
+
...section,
|
|
256
|
+
points: (section.points || []).map((point) => {
|
|
257
|
+
const mastery = chapterMastery?.points?.[point.id] || null;
|
|
258
|
+
return {
|
|
259
|
+
...point,
|
|
260
|
+
masteryStatus: mastery?.status || point.masteryStatus || 'not_started',
|
|
261
|
+
masteryStats: mastery
|
|
262
|
+
? {
|
|
263
|
+
coveredCount: mastery.coveredCount || 0,
|
|
264
|
+
correctCount: mastery.correctCount || 0,
|
|
265
|
+
wrongCount: mastery.wrongCount || 0,
|
|
266
|
+
lastPracticeId: mastery.lastPracticeId || null,
|
|
267
|
+
lastSubmissionId: mastery.lastSubmissionId || null,
|
|
268
|
+
lastUpdatedAt: mastery.lastUpdatedAt || null
|
|
269
|
+
}
|
|
270
|
+
: point.masteryStats || null
|
|
271
|
+
};
|
|
272
|
+
})
|
|
273
|
+
}))
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function syncKnowledgeMasteryMarkers(chapterId) {
|
|
278
|
+
const chapterPaths = chapterDataPaths(chapterId);
|
|
279
|
+
const chapterMastery = await readJson(chapterPaths.mastery, null);
|
|
280
|
+
if (!chapterMastery) return null;
|
|
281
|
+
|
|
282
|
+
const chapterDoc = await readJson(chapterPaths.knowledgeJson, null);
|
|
283
|
+
if (chapterDoc) {
|
|
284
|
+
await writeJson(chapterPaths.knowledgeJson, applyMasteryMarkersToDoc(chapterDoc, chapterMastery));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const allKnowledge = await readJson(paths.knowledge, []);
|
|
288
|
+
const nextKnowledge = allKnowledge.map((doc) => (
|
|
289
|
+
doc.chapterId === chapterId ? applyMasteryMarkersToDoc(doc, chapterMastery) : doc
|
|
290
|
+
));
|
|
291
|
+
await writeJson(paths.knowledge, nextKnowledge);
|
|
292
|
+
|
|
293
|
+
const docPath = path.join(paths.knowledgeDocs, `${chapterId}.md`);
|
|
294
|
+
const currentMarkdown = await readFile(docPath, 'utf8').catch(() => '');
|
|
295
|
+
const marker = `\n\n<!-- mastery-updated-at: ${new Date().toISOString()} -->\n`;
|
|
296
|
+
await writeFile(docPath, `${currentMarkdown.replace(/\n\n<!-- mastery-updated-at: .*? -->\n?$/s, '').trim()}${marker}`, 'utf8');
|
|
297
|
+
await syncGlobalIndexes();
|
|
298
|
+
return chapterMastery;
|
|
299
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { paths } from './fileStore.js';
|
|
4
|
+
|
|
5
|
+
const ENV_KEYS = ['AI_BASE_URL', 'AI_API_KEY', 'AI_MODEL'];
|
|
6
|
+
|
|
7
|
+
function envPath() {
|
|
8
|
+
if (process.env.MATH_AGENT_ENV_FILE) return path.resolve(process.env.MATH_AGENT_ENV_FILE);
|
|
9
|
+
if (process.env.MATH_AGENT_DATA_DIR) return path.resolve(process.env.MATH_AGENT_DATA_DIR, '..', '.env.local');
|
|
10
|
+
return path.join(paths.rootDir, '.env.local');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseEnvText(text) {
|
|
14
|
+
const values = {};
|
|
15
|
+
for (const line of text.split(/\r?\n/)) {
|
|
16
|
+
const trimmed = line.trim();
|
|
17
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
18
|
+
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
19
|
+
if (!match) continue;
|
|
20
|
+
values[match[1]] = match[2].replace(/^"(.*)"$/, '$1');
|
|
21
|
+
}
|
|
22
|
+
return values;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readLocalEnvText() {
|
|
26
|
+
try {
|
|
27
|
+
return await readFile(envPath(), 'utf8');
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code === 'ENOENT') return '';
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function serializeValue(value) {
|
|
35
|
+
const text = String(value ?? '');
|
|
36
|
+
if (!/[\s#"'\\]/.test(text)) return text;
|
|
37
|
+
return JSON.stringify(text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mergeEnvText(text, updates) {
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const lines = text.split(/\r?\n/).filter((line, index, lines) => index < lines.length - 1 || line !== '');
|
|
43
|
+
const nextLines = lines.map((line) => {
|
|
44
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
|
|
45
|
+
if (!match || !(match[1] in updates)) return line;
|
|
46
|
+
seen.add(match[1]);
|
|
47
|
+
return `${match[1]}=${serializeValue(updates[match[1]])}`;
|
|
48
|
+
});
|
|
49
|
+
for (const key of ENV_KEYS) {
|
|
50
|
+
if (key in updates && !seen.has(key)) nextLines.push(`${key}=${serializeValue(updates[key])}`);
|
|
51
|
+
}
|
|
52
|
+
return `${nextLines.join('\n')}\n`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readLlmSettings() {
|
|
56
|
+
const fileValues = parseEnvText(await readLocalEnvText());
|
|
57
|
+
const baseUrl = process.env.AI_BASE_URL || fileValues.AI_BASE_URL || '';
|
|
58
|
+
const apiKey = process.env.AI_API_KEY || fileValues.AI_API_KEY || '';
|
|
59
|
+
const model = process.env.AI_MODEL || fileValues.AI_MODEL || 'gpt-5.5';
|
|
60
|
+
return {
|
|
61
|
+
baseUrl,
|
|
62
|
+
model,
|
|
63
|
+
hasApiKey: Boolean(apiKey),
|
|
64
|
+
envPath: envPath()
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function getLlmRuntimeConfig() {
|
|
69
|
+
const fileValues = parseEnvText(await readLocalEnvText());
|
|
70
|
+
return {
|
|
71
|
+
baseUrl: process.env.AI_BASE_URL || fileValues.AI_BASE_URL || '',
|
|
72
|
+
apiKey: process.env.AI_API_KEY || fileValues.AI_API_KEY || '',
|
|
73
|
+
model: process.env.AI_MODEL || fileValues.AI_MODEL || 'gpt-5.5'
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function writeLlmSettings(input = {}) {
|
|
78
|
+
const current = await readLlmSettings();
|
|
79
|
+
const baseUrl = String(input.baseUrl ?? current.baseUrl ?? '').trim();
|
|
80
|
+
const model = String(input.model ?? current.model ?? 'gpt-5.5').trim();
|
|
81
|
+
const apiKeyInput = String(input.apiKey ?? '').trim();
|
|
82
|
+
const clearApiKey = Boolean(input.clearApiKey);
|
|
83
|
+
const updates = {
|
|
84
|
+
AI_BASE_URL: baseUrl,
|
|
85
|
+
AI_MODEL: model || 'gpt-5.5'
|
|
86
|
+
};
|
|
87
|
+
if (clearApiKey) updates.AI_API_KEY = '';
|
|
88
|
+
if (apiKeyInput) updates.AI_API_KEY = apiKeyInput;
|
|
89
|
+
|
|
90
|
+
const nextText = mergeEnvText(await readLocalEnvText(), updates);
|
|
91
|
+
await writeFile(envPath(), nextText, 'utf8');
|
|
92
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
93
|
+
process.env[key] = value;
|
|
94
|
+
}
|
|
95
|
+
return readLlmSettings();
|
|
96
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
isActionableMistake,
|
|
4
|
+
isResolvedMistake,
|
|
5
|
+
normalizeMistakeRecord,
|
|
6
|
+
readChapterMistakes,
|
|
7
|
+
writeChapterMistakes
|
|
8
|
+
} from './fileStore.js';
|
|
9
|
+
|
|
10
|
+
function unique(values) {
|
|
11
|
+
return [...new Set(values.filter(Boolean))];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function questionKnowledgePointIds(question = {}) {
|
|
15
|
+
return unique(Array.isArray(question.knowledgePointIds) ? question.knowledgePointIds : []);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function mistakeKnowledgePointIds(mistake = {}) {
|
|
19
|
+
const normalized = normalizeMistakeRecord(mistake);
|
|
20
|
+
return unique([
|
|
21
|
+
...(Array.isArray(normalized.sourceKnowledgePointIds) ? normalized.sourceKnowledgePointIds : []),
|
|
22
|
+
normalized.knowledgePointId
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function overlaps(left = [], right = []) {
|
|
27
|
+
const rightSet = new Set(right);
|
|
28
|
+
return left.some((item) => rightSet.has(item));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function questionMatchesMistake(question, mistake) {
|
|
32
|
+
return overlaps(questionKnowledgePointIds(question), mistakeKnowledgePointIds(mistake));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isCorrectGrading(item = {}, question = {}) {
|
|
36
|
+
const maxScore = Number(item.maxScore || question.score || 0);
|
|
37
|
+
const score = Number(item.score || 0);
|
|
38
|
+
const ratio = maxScore > 0 ? score / maxScore : 0;
|
|
39
|
+
return item.status === 'correct' || ratio >= 0.85;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isWeakGrading(item = {}, question = {}) {
|
|
43
|
+
return !isCorrectGrading(item, question)
|
|
44
|
+
&& ['wrong', 'partial', 'needs_review', 'unrecognized'].includes(item.status);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mistakeEvent(type, details = {}) {
|
|
48
|
+
return {
|
|
49
|
+
type,
|
|
50
|
+
at: new Date().toISOString(),
|
|
51
|
+
...details
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function severityForMistake(mistake) {
|
|
56
|
+
if (mistake.status === 'regressed') return 'high';
|
|
57
|
+
if (Number(mistake.occurrences || 1) >= 3) return 'high';
|
|
58
|
+
if (Number(mistake.occurrences || 1) >= 2) return 'medium';
|
|
59
|
+
return 'low';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function weakPointsFromMistakes(mistakes, { includeResolved = false } = {}) {
|
|
63
|
+
const candidates = mistakes
|
|
64
|
+
.map(normalizeMistakeRecord)
|
|
65
|
+
.filter((mistake) => includeResolved || isActionableMistake(mistake));
|
|
66
|
+
const byPoint = new Map();
|
|
67
|
+
for (const mistake of candidates) {
|
|
68
|
+
const pointId = mistake.knowledgePointId || mistakeKnowledgePointIds(mistake)[0] || '';
|
|
69
|
+
if (!pointId) continue;
|
|
70
|
+
const current = byPoint.get(pointId) || {
|
|
71
|
+
knowledgePointId: pointId,
|
|
72
|
+
knowledgePoint: mistake.knowledgePoint || '待确认',
|
|
73
|
+
issue: mistake.summary || '待修复错因',
|
|
74
|
+
nextAction: '生成易错题练习进行修复。',
|
|
75
|
+
severity: severityForMistake(mistake),
|
|
76
|
+
status: mistake.status,
|
|
77
|
+
occurrences: 0,
|
|
78
|
+
latestSubmissionId: mistake.submissionId || null,
|
|
79
|
+
latestPracticeId: mistake.practiceId || null,
|
|
80
|
+
mistakeIds: [],
|
|
81
|
+
sourceQuestionIds: [],
|
|
82
|
+
sourceKnowledgePointIds: []
|
|
83
|
+
};
|
|
84
|
+
current.occurrences += Number(mistake.occurrences || 1);
|
|
85
|
+
current.severity = severityForMistake({ ...mistake, occurrences: current.occurrences });
|
|
86
|
+
current.status = current.status === 'regressed' || mistake.status === 'regressed'
|
|
87
|
+
? 'regressed'
|
|
88
|
+
: mistake.status;
|
|
89
|
+
current.mistakeIds = unique([...current.mistakeIds, mistake.id]);
|
|
90
|
+
current.sourceQuestionIds = unique([...current.sourceQuestionIds, mistake.questionId]);
|
|
91
|
+
current.sourceKnowledgePointIds = unique([
|
|
92
|
+
...current.sourceKnowledgePointIds,
|
|
93
|
+
...mistakeKnowledgePointIds(mistake)
|
|
94
|
+
]);
|
|
95
|
+
if (mistake.createdAt && (!current.createdAt || mistake.createdAt > current.createdAt)) {
|
|
96
|
+
current.createdAt = mistake.createdAt;
|
|
97
|
+
current.latestSubmissionId = mistake.submissionId || current.latestSubmissionId;
|
|
98
|
+
current.latestPracticeId = mistake.practiceId || current.latestPracticeId;
|
|
99
|
+
current.issue = mistake.summary || current.issue;
|
|
100
|
+
}
|
|
101
|
+
byPoint.set(pointId, current);
|
|
102
|
+
}
|
|
103
|
+
const severityOrder = { high: 0, medium: 1, low: 2 };
|
|
104
|
+
return [...byPoint.values()].sort((a, b) => (
|
|
105
|
+
severityOrder[a.severity] - severityOrder[b.severity] ||
|
|
106
|
+
b.occurrences - a.occurrences ||
|
|
107
|
+
a.knowledgePointId.localeCompare(b.knowledgePointId)
|
|
108
|
+
));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function markRepairPracticeStarted(practice) {
|
|
112
|
+
if (practice.type !== 'mistake_repair') return [];
|
|
113
|
+
const questionPointIds = unique((practice.questions || []).flatMap(questionKnowledgePointIds));
|
|
114
|
+
if (!questionPointIds.length) return [];
|
|
115
|
+
const mistakes = await readChapterMistakes(practice.chapterId);
|
|
116
|
+
let changed = false;
|
|
117
|
+
const next = mistakes.map((mistake) => {
|
|
118
|
+
const normalized = normalizeMistakeRecord(mistake);
|
|
119
|
+
if (!isActionableMistake(normalized) || !overlaps(mistakeKnowledgePointIds(normalized), questionPointIds)) {
|
|
120
|
+
return normalized;
|
|
121
|
+
}
|
|
122
|
+
changed = true;
|
|
123
|
+
return {
|
|
124
|
+
...normalized,
|
|
125
|
+
status: 'practicing',
|
|
126
|
+
resolved: false,
|
|
127
|
+
lastRepairPracticeId: practice.id,
|
|
128
|
+
practicingAt: new Date().toISOString(),
|
|
129
|
+
repairEvidence: [
|
|
130
|
+
...(normalized.repairEvidence || []),
|
|
131
|
+
mistakeEvent('repair_practice_generated', { practiceId: practice.id })
|
|
132
|
+
]
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
return changed ? writeChapterMistakes(practice.chapterId, next) : next;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function findExistingMistake(mistakes, { question, errorType, preferRepairPracticeId = null }) {
|
|
139
|
+
const matching = mistakes.filter((mistake) => questionMatchesMistake(question, mistake));
|
|
140
|
+
return matching.find((mistake) => (
|
|
141
|
+
preferRepairPracticeId && mistake.lastRepairPracticeId === preferRepairPracticeId
|
|
142
|
+
)) || matching.find((mistake) => (
|
|
143
|
+
!isResolvedMistake(mistake) && (!errorType || mistake.errorType === errorType)
|
|
144
|
+
)) || matching.find((mistake) => !isResolvedMistake(mistake)) || matching[0] || null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function upsertWrongMistake({ mistakes, practice, submissionId, gradingItem, question }) {
|
|
148
|
+
const errorType = gradingItem.errorTypes?.[0] || '待确认';
|
|
149
|
+
const existing = findExistingMistake(mistakes, {
|
|
150
|
+
question,
|
|
151
|
+
errorType,
|
|
152
|
+
preferRepairPracticeId: practice.type === 'mistake_repair' ? practice.id : null
|
|
153
|
+
});
|
|
154
|
+
const pointIds = questionKnowledgePointIds(question);
|
|
155
|
+
const primaryPointId = pointIds[0] || null;
|
|
156
|
+
const event = mistakeEvent(isResolvedMistake(existing) ? 'mistake_regressed' : 'mistake_observed', {
|
|
157
|
+
practiceId: practice.id,
|
|
158
|
+
submissionId,
|
|
159
|
+
questionId: gradingItem.questionId,
|
|
160
|
+
status: gradingItem.status
|
|
161
|
+
});
|
|
162
|
+
if (existing) {
|
|
163
|
+
existing.status = isResolvedMistake(existing) ? 'regressed' : 'open';
|
|
164
|
+
existing.resolved = false;
|
|
165
|
+
existing.resolvedAt = null;
|
|
166
|
+
existing.practiceId = practice.id;
|
|
167
|
+
existing.submissionId = submissionId;
|
|
168
|
+
existing.questionId = gradingItem.questionId;
|
|
169
|
+
existing.knowledgePointId = existing.knowledgePointId || primaryPointId;
|
|
170
|
+
existing.sourceKnowledgePointIds = unique([
|
|
171
|
+
...mistakeKnowledgePointIds(existing),
|
|
172
|
+
...pointIds
|
|
173
|
+
]);
|
|
174
|
+
existing.knowledgePoint = question.knowledgePoints?.[0] || existing.knowledgePoint || '待确认';
|
|
175
|
+
existing.errorType = errorType;
|
|
176
|
+
existing.summary = gradingItem.comment || gradingItem.teachingHint || existing.summary || '归档错题';
|
|
177
|
+
existing.occurrences = Number(existing.occurrences || 1) + 1;
|
|
178
|
+
existing.lastObservedAt = new Date().toISOString();
|
|
179
|
+
existing.repairEvidence = [...(existing.repairEvidence || []), event];
|
|
180
|
+
return existing;
|
|
181
|
+
}
|
|
182
|
+
const created = {
|
|
183
|
+
id: `mistake-${randomUUID().slice(0, 8)}`,
|
|
184
|
+
chapterId: practice.chapterId,
|
|
185
|
+
practiceId: practice.id,
|
|
186
|
+
submissionId,
|
|
187
|
+
questionId: gradingItem.questionId,
|
|
188
|
+
knowledgePointId: primaryPointId,
|
|
189
|
+
sourceKnowledgePointIds: pointIds,
|
|
190
|
+
knowledgePoint: question.knowledgePoints?.[0] || '待确认',
|
|
191
|
+
errorType,
|
|
192
|
+
summary: gradingItem.comment || gradingItem.teachingHint || '归档错题',
|
|
193
|
+
status: 'open',
|
|
194
|
+
occurrences: 1,
|
|
195
|
+
reviewDates: [],
|
|
196
|
+
resolved: false,
|
|
197
|
+
repairEvidence: [event],
|
|
198
|
+
createdAt: new Date().toISOString()
|
|
199
|
+
};
|
|
200
|
+
mistakes.push(created);
|
|
201
|
+
return created;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveRepairMistakes({ mistakes, practice, grading, questionById, submissionId }) {
|
|
205
|
+
if (practice.type !== 'mistake_repair') return;
|
|
206
|
+
const outcomes = new Map();
|
|
207
|
+
for (const item of grading) {
|
|
208
|
+
const question = questionById.get(item.questionId);
|
|
209
|
+
if (!question) continue;
|
|
210
|
+
for (const pointId of questionKnowledgePointIds(question)) {
|
|
211
|
+
const current = outcomes.get(pointId) || { correct: 0, weak: 0 };
|
|
212
|
+
if (isCorrectGrading(item, question)) current.correct += 1;
|
|
213
|
+
if (isWeakGrading(item, question)) current.weak += 1;
|
|
214
|
+
outcomes.set(pointId, current);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const mistake of mistakes) {
|
|
218
|
+
const normalized = normalizeMistakeRecord(mistake);
|
|
219
|
+
if (normalized.status !== 'practicing' && normalized.lastRepairPracticeId !== practice.id) continue;
|
|
220
|
+
const relatedOutcomes = mistakeKnowledgePointIds(normalized)
|
|
221
|
+
.map((pointId) => outcomes.get(pointId))
|
|
222
|
+
.filter(Boolean);
|
|
223
|
+
if (!relatedOutcomes.length) continue;
|
|
224
|
+
const hasCorrect = relatedOutcomes.some((item) => item.correct > 0);
|
|
225
|
+
const hasWeak = relatedOutcomes.some((item) => item.weak > 0);
|
|
226
|
+
if (hasCorrect && !hasWeak) {
|
|
227
|
+
mistake.status = 'resolved';
|
|
228
|
+
mistake.resolved = true;
|
|
229
|
+
mistake.resolvedAt = new Date().toISOString();
|
|
230
|
+
mistake.repairEvidence = [
|
|
231
|
+
...(mistake.repairEvidence || []),
|
|
232
|
+
mistakeEvent('repair_confirmed_resolved', {
|
|
233
|
+
practiceId: practice.id,
|
|
234
|
+
submissionId
|
|
235
|
+
})
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function updateMistakesAfterArchive({ practice, grading, submissionId }) {
|
|
242
|
+
const mistakes = (await readChapterMistakes(practice.chapterId)).map(normalizeMistakeRecord);
|
|
243
|
+
const questionById = new Map((practice.questions || []).map((question) => [question.id, question]));
|
|
244
|
+
for (const item of grading) {
|
|
245
|
+
const question = questionById.get(item.questionId);
|
|
246
|
+
if (!question || !isWeakGrading(item, question)) continue;
|
|
247
|
+
upsertWrongMistake({ mistakes, practice, submissionId, gradingItem: item, question });
|
|
248
|
+
}
|
|
249
|
+
resolveRepairMistakes({ mistakes, practice, grading, questionById, submissionId });
|
|
250
|
+
return writeChapterMistakes(practice.chapterId, mistakes);
|
|
251
|
+
}
|