@gonzih/exam-prep-mcp 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/LICENSE +21 -0
- package/README.md +287 -0
- package/SKILL.md +79 -0
- package/dist/db.d.ts +66 -0
- package/dist/db.js +188 -0
- package/dist/fsrs.d.ts +10 -0
- package/dist/fsrs.js +101 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +226 -0
- package/dist/questions.d.ts +17 -0
- package/dist/questions.js +906 -0
- package/dist/readiness.d.ts +15 -0
- package/dist/readiness.js +272 -0
- package/dist/study-plan.d.ts +25 -0
- package/dist/study-plan.js +313 -0
- package/dist/tools.d.ts +132 -0
- package/dist/tools.js +424 -0
- package/llms.txt +83 -0
- package/package.json +30 -0
- package/src/db.ts +257 -0
- package/src/fsrs.ts +118 -0
- package/src/index.ts +262 -0
- package/src/questions.ts +962 -0
- package/src/readiness.ts +268 -0
- package/src/study-plan.ts +364 -0
- package/src/tools.ts +557 -0
- package/tsconfig.json +16 -0
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export interface SetExamInput {
|
|
2
|
+
profileId: string;
|
|
3
|
+
examType: string;
|
|
4
|
+
examDate: string;
|
|
5
|
+
currentLevel: 'beginner' | 'intermediate' | 'advanced';
|
|
6
|
+
}
|
|
7
|
+
export interface GetDailySessionInput {
|
|
8
|
+
profileId: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AnswerQuestionInput {
|
|
11
|
+
profileId: string;
|
|
12
|
+
cardId: string;
|
|
13
|
+
answer: string;
|
|
14
|
+
rating?: 1 | 2 | 3 | 4;
|
|
15
|
+
}
|
|
16
|
+
export interface StartMockExamInput {
|
|
17
|
+
profileId: string;
|
|
18
|
+
}
|
|
19
|
+
export interface SubmitMockExamInput {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
answers: Array<{
|
|
22
|
+
cardId: string;
|
|
23
|
+
answer: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export interface GetReadinessInput {
|
|
27
|
+
profileId: string;
|
|
28
|
+
}
|
|
29
|
+
export interface GetWeakAreasInput {
|
|
30
|
+
profileId: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function setExam(input: SetExamInput): Promise<{
|
|
33
|
+
success: boolean;
|
|
34
|
+
message: string;
|
|
35
|
+
seededCards: number;
|
|
36
|
+
daysRemaining: number;
|
|
37
|
+
topicsIdentified: string[];
|
|
38
|
+
studyPlan: {
|
|
39
|
+
examType: string;
|
|
40
|
+
examDate: string;
|
|
41
|
+
daysRemaining: number;
|
|
42
|
+
weeklyGoals: string[];
|
|
43
|
+
first7Days: import("./study-plan.js").DayPlan[];
|
|
44
|
+
};
|
|
45
|
+
}>;
|
|
46
|
+
export declare function getDailySession(input: GetDailySessionInput): Promise<{
|
|
47
|
+
questions: {
|
|
48
|
+
cardId: string;
|
|
49
|
+
subject: string;
|
|
50
|
+
topic: string;
|
|
51
|
+
question: string;
|
|
52
|
+
type: "multiple_choice" | "short_answer" | "free_response" | "problem_solving";
|
|
53
|
+
choices: string[] | undefined;
|
|
54
|
+
difficulty: string;
|
|
55
|
+
reviewCount: number;
|
|
56
|
+
}[];
|
|
57
|
+
estimatedMinutes: number;
|
|
58
|
+
focusAreas: string[];
|
|
59
|
+
totalDue: number;
|
|
60
|
+
examType: string;
|
|
61
|
+
examDate: string;
|
|
62
|
+
}>;
|
|
63
|
+
export declare function answerQuestion(input: AnswerQuestionInput): Promise<{
|
|
64
|
+
correct: boolean;
|
|
65
|
+
correctAnswer: string;
|
|
66
|
+
explanation: string;
|
|
67
|
+
nextReview: string;
|
|
68
|
+
intervalDays: number;
|
|
69
|
+
rating: 1 | 2 | 3 | 4;
|
|
70
|
+
weakAreaAlert: string | undefined;
|
|
71
|
+
topic: string;
|
|
72
|
+
subject: string;
|
|
73
|
+
}>;
|
|
74
|
+
export declare function startMockExam(input: StartMockExamInput): Promise<{
|
|
75
|
+
sessionId: string;
|
|
76
|
+
questions: {
|
|
77
|
+
cardId: string;
|
|
78
|
+
subject: string;
|
|
79
|
+
topic: string;
|
|
80
|
+
question: string;
|
|
81
|
+
type: "multiple_choice" | "short_answer" | "free_response" | "problem_solving";
|
|
82
|
+
choices: string[] | undefined;
|
|
83
|
+
}[];
|
|
84
|
+
timeLimitMins: number;
|
|
85
|
+
totalQuestions: number;
|
|
86
|
+
examType: string;
|
|
87
|
+
instructions: string;
|
|
88
|
+
}>;
|
|
89
|
+
export declare function submitMockExam(input: SubmitMockExamInput): Promise<{
|
|
90
|
+
score: number;
|
|
91
|
+
correct: number;
|
|
92
|
+
total: number;
|
|
93
|
+
passingThreshold: number;
|
|
94
|
+
passed: boolean;
|
|
95
|
+
estimatedGrade: string;
|
|
96
|
+
readinessScore: number | undefined;
|
|
97
|
+
topicBreakdown: {
|
|
98
|
+
topic: string;
|
|
99
|
+
correct: number;
|
|
100
|
+
total: number;
|
|
101
|
+
accuracy: number;
|
|
102
|
+
}[];
|
|
103
|
+
strongestTopics: string[];
|
|
104
|
+
weakestTopics: string[];
|
|
105
|
+
recommendation: string | undefined;
|
|
106
|
+
}>;
|
|
107
|
+
export declare function getReadiness(input: GetReadinessInput): Promise<{
|
|
108
|
+
score: number;
|
|
109
|
+
strongTopics: import("./readiness.js").TopicScore[];
|
|
110
|
+
weakTopics: import("./readiness.js").TopicScore[];
|
|
111
|
+
coveragePercent: number;
|
|
112
|
+
trend: "improving" | "declining" | "stable";
|
|
113
|
+
recommendation: string;
|
|
114
|
+
estimatedGrade: string;
|
|
115
|
+
profileId: string;
|
|
116
|
+
examType: string;
|
|
117
|
+
examDate: string;
|
|
118
|
+
}>;
|
|
119
|
+
export interface WeakArea {
|
|
120
|
+
topic: string;
|
|
121
|
+
subject: string;
|
|
122
|
+
accuracy: number;
|
|
123
|
+
attempts: number;
|
|
124
|
+
correctAttempts: number;
|
|
125
|
+
}
|
|
126
|
+
export declare function getWeakAreas(input: GetWeakAreasInput): Promise<{
|
|
127
|
+
profileId: string;
|
|
128
|
+
examType: string | undefined;
|
|
129
|
+
topics: WeakArea[];
|
|
130
|
+
suggestedFocus: string;
|
|
131
|
+
totalWeakTopics: number;
|
|
132
|
+
}>;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { insertCard, insertSession, updateSession, getSession, getCard, updateCard, getDueCards, getNewCards, getAllCardsForExam, getWeakTopics, upsertExamConfig, getExamConfig, upsertTopicStats, getTopicStats, countCardsByQuestion, } from './db.js';
|
|
3
|
+
import { scheduleReview } from './fsrs.js';
|
|
4
|
+
import { QUESTION_BANK } from './questions.js';
|
|
5
|
+
import { generateStudyPlan, MOCK_EXAM_QUESTIONS, EXAM_TIME_LIMITS } from './study-plan.js';
|
|
6
|
+
import { calculateReadiness } from './readiness.js';
|
|
7
|
+
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
|
8
|
+
function questionTemplateToCard(template, profileId) {
|
|
9
|
+
return {
|
|
10
|
+
id: uuidv4(),
|
|
11
|
+
profile_id: profileId,
|
|
12
|
+
subject: template.subject,
|
|
13
|
+
topic: template.topic,
|
|
14
|
+
question: formatQuestion(template),
|
|
15
|
+
answer: template.answer,
|
|
16
|
+
difficulty: template.difficulty === 'easy' ? 0.2 : template.difficulty === 'medium' ? 0.4 : 0.7,
|
|
17
|
+
stability: 1.0,
|
|
18
|
+
retrievability: 1.0,
|
|
19
|
+
next_review: new Date().toISOString(),
|
|
20
|
+
review_count: 0,
|
|
21
|
+
last_rating: null,
|
|
22
|
+
created_at: new Date().toISOString(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function formatQuestion(template) {
|
|
26
|
+
if (template.type === 'multiple_choice' && template.choices) {
|
|
27
|
+
return `${template.question}\n\n${template.choices.join('\n')}`;
|
|
28
|
+
}
|
|
29
|
+
return template.question;
|
|
30
|
+
}
|
|
31
|
+
function evaluateAnswer(card, studentAnswer, template) {
|
|
32
|
+
const correctAnswer = card.answer.trim().toLowerCase();
|
|
33
|
+
const studentAnswerNorm = studentAnswer.trim().toLowerCase();
|
|
34
|
+
// Multiple choice — exact match (single letter)
|
|
35
|
+
if (template?.type === 'multiple_choice') {
|
|
36
|
+
const correct = correctAnswer === studentAnswerNorm ||
|
|
37
|
+
correctAnswer.startsWith(studentAnswerNorm) ||
|
|
38
|
+
studentAnswerNorm === correctAnswer[0];
|
|
39
|
+
return {
|
|
40
|
+
correct,
|
|
41
|
+
explanation: template.explanation
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// Problem solving — check numeric answer
|
|
45
|
+
if (template?.type === 'problem_solving') {
|
|
46
|
+
// Extract numbers from both answers
|
|
47
|
+
const correctNum = extractNumber(correctAnswer);
|
|
48
|
+
const studentNum = extractNumber(studentAnswerNorm);
|
|
49
|
+
if (correctNum !== null && studentNum !== null) {
|
|
50
|
+
const tolerance = Math.abs(correctNum) * 0.05 || 0.01;
|
|
51
|
+
const correct = Math.abs(correctNum - studentNum) <= tolerance;
|
|
52
|
+
return { correct, explanation: template?.explanation || '' };
|
|
53
|
+
}
|
|
54
|
+
// Fallback to keyword matching
|
|
55
|
+
return keywordMatch(correctAnswer, studentAnswerNorm, template?.explanation || '');
|
|
56
|
+
}
|
|
57
|
+
// Short answer / free response — keyword matching
|
|
58
|
+
return keywordMatch(correctAnswer, studentAnswerNorm, template?.explanation || '');
|
|
59
|
+
}
|
|
60
|
+
function extractNumber(text) {
|
|
61
|
+
const match = text.match(/-?\d+\.?\d*/);
|
|
62
|
+
if (match)
|
|
63
|
+
return parseFloat(match[0]);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function keywordMatch(correctAnswer, studentAnswer, explanation) {
|
|
67
|
+
// Check if student answer contains key terms from correct answer
|
|
68
|
+
const keyTerms = correctAnswer
|
|
69
|
+
.split(/[\s,;.]+/)
|
|
70
|
+
.filter(w => w.length > 3)
|
|
71
|
+
.map(w => w.toLowerCase());
|
|
72
|
+
if (keyTerms.length === 0) {
|
|
73
|
+
const correct = correctAnswer === studentAnswer;
|
|
74
|
+
return { correct, explanation };
|
|
75
|
+
}
|
|
76
|
+
let matched = 0;
|
|
77
|
+
for (const term of keyTerms) {
|
|
78
|
+
if (studentAnswer.includes(term))
|
|
79
|
+
matched++;
|
|
80
|
+
}
|
|
81
|
+
const matchRatio = matched / keyTerms.length;
|
|
82
|
+
const correct = matchRatio >= 0.5;
|
|
83
|
+
return { correct, explanation };
|
|
84
|
+
}
|
|
85
|
+
function findTemplate(question) {
|
|
86
|
+
// Strip choices if present
|
|
87
|
+
const questionText = question.split('\n\n')[0];
|
|
88
|
+
return QUESTION_BANK.find(t => t.question === questionText);
|
|
89
|
+
}
|
|
90
|
+
// ─── Tool Implementations ─────────────────────────────────────────────────────
|
|
91
|
+
export async function setExam(input) {
|
|
92
|
+
const { profileId, examType, examDate, currentLevel } = input;
|
|
93
|
+
// Store exam config
|
|
94
|
+
upsertExamConfig({
|
|
95
|
+
profile_id: profileId,
|
|
96
|
+
exam_type: examType,
|
|
97
|
+
exam_date: examDate,
|
|
98
|
+
current_level: currentLevel,
|
|
99
|
+
created_at: new Date().toISOString(),
|
|
100
|
+
});
|
|
101
|
+
// Seed cards from question bank
|
|
102
|
+
const questions = QUESTION_BANK.filter(q => q.subject === examType);
|
|
103
|
+
let seeded = 0;
|
|
104
|
+
for (const template of questions) {
|
|
105
|
+
const formattedQuestion = formatQuestion(template);
|
|
106
|
+
const existing = countCardsByQuestion(profileId, formattedQuestion);
|
|
107
|
+
if (existing === 0) {
|
|
108
|
+
const card = questionTemplateToCard(template, profileId);
|
|
109
|
+
card.question = formattedQuestion;
|
|
110
|
+
insertCard(card);
|
|
111
|
+
seeded++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Generate study plan
|
|
115
|
+
const studyPlan = generateStudyPlan(examType, examDate, currentLevel);
|
|
116
|
+
const now = new Date();
|
|
117
|
+
const examDateObj = new Date(examDate);
|
|
118
|
+
const daysRemaining = Math.ceil((examDateObj.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|
119
|
+
return {
|
|
120
|
+
success: true,
|
|
121
|
+
message: `Exam configured: ${examType} on ${examDate}`,
|
|
122
|
+
seededCards: seeded,
|
|
123
|
+
daysRemaining,
|
|
124
|
+
topicsIdentified: studyPlan.topicsIdentified,
|
|
125
|
+
studyPlan: {
|
|
126
|
+
examType: studyPlan.examType,
|
|
127
|
+
examDate: studyPlan.examDate,
|
|
128
|
+
daysRemaining: studyPlan.daysRemaining,
|
|
129
|
+
weeklyGoals: studyPlan.weeklyGoals,
|
|
130
|
+
first7Days: studyPlan.dailySchedule.slice(0, 7),
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export async function getDailySession(input) {
|
|
135
|
+
const { profileId } = input;
|
|
136
|
+
const config = getExamConfig(profileId);
|
|
137
|
+
if (!config) {
|
|
138
|
+
throw new Error('No exam configured. Use set_exam first.');
|
|
139
|
+
}
|
|
140
|
+
const now = new Date().toISOString();
|
|
141
|
+
const TARGET_QUESTIONS = 12;
|
|
142
|
+
// Get due cards
|
|
143
|
+
let dueCards = getDueCards(profileId, now, TARGET_QUESTIONS);
|
|
144
|
+
// Top up with new cards if needed
|
|
145
|
+
if (dueCards.length < 5) {
|
|
146
|
+
const newCards = getNewCards(profileId, TARGET_QUESTIONS - dueCards.length);
|
|
147
|
+
const existingIds = new Set(dueCards.map(c => c.id));
|
|
148
|
+
const uniqueNew = newCards.filter(c => !existingIds.has(c.id));
|
|
149
|
+
dueCards = [...dueCards, ...uniqueNew].slice(0, TARGET_QUESTIONS);
|
|
150
|
+
}
|
|
151
|
+
// Identify focus areas
|
|
152
|
+
const topicCounts = {};
|
|
153
|
+
for (const card of dueCards) {
|
|
154
|
+
topicCounts[card.topic] = (topicCounts[card.topic] || 0) + 1;
|
|
155
|
+
}
|
|
156
|
+
const focusAreas = Object.entries(topicCounts)
|
|
157
|
+
.sort((a, b) => b[1] - a[1])
|
|
158
|
+
.slice(0, 3)
|
|
159
|
+
.map(([topic]) => topic);
|
|
160
|
+
const questions = dueCards.map(card => ({
|
|
161
|
+
cardId: card.id,
|
|
162
|
+
subject: card.subject,
|
|
163
|
+
topic: card.topic,
|
|
164
|
+
question: card.question,
|
|
165
|
+
type: findTemplate(card.question)?.type || 'short_answer',
|
|
166
|
+
choices: findTemplate(card.question)?.choices,
|
|
167
|
+
difficulty: card.difficulty < 0.35 ? 'easy' : card.difficulty < 0.55 ? 'medium' : 'hard',
|
|
168
|
+
reviewCount: card.review_count,
|
|
169
|
+
}));
|
|
170
|
+
return {
|
|
171
|
+
questions,
|
|
172
|
+
estimatedMinutes: Math.ceil(dueCards.length * 2.5),
|
|
173
|
+
focusAreas,
|
|
174
|
+
totalDue: dueCards.length,
|
|
175
|
+
examType: config.exam_type,
|
|
176
|
+
examDate: config.exam_date,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export async function answerQuestion(input) {
|
|
180
|
+
const { profileId, cardId, answer, rating } = input;
|
|
181
|
+
const card = getCard(cardId);
|
|
182
|
+
if (!card) {
|
|
183
|
+
throw new Error(`Card ${cardId} not found`);
|
|
184
|
+
}
|
|
185
|
+
if (card.profile_id !== profileId) {
|
|
186
|
+
throw new Error('Card does not belong to this profile');
|
|
187
|
+
}
|
|
188
|
+
const template = findTemplate(card.question);
|
|
189
|
+
const { correct, explanation } = evaluateAnswer(card, answer, template);
|
|
190
|
+
// Determine FSRS rating
|
|
191
|
+
let fsrsRating = rating || (correct ? 3 : 1);
|
|
192
|
+
if (!rating) {
|
|
193
|
+
fsrsRating = correct ? 3 : 1;
|
|
194
|
+
}
|
|
195
|
+
// Apply FSRS scheduling
|
|
196
|
+
const lastReview = card.next_review ? new Date(card.next_review) : undefined;
|
|
197
|
+
const fsrsResult = scheduleReview(fsrsRating, card.stability, card.difficulty, card.review_count, lastReview);
|
|
198
|
+
// Update card
|
|
199
|
+
updateCard({
|
|
200
|
+
id: card.id,
|
|
201
|
+
difficulty: fsrsResult.difficulty,
|
|
202
|
+
stability: fsrsResult.stability,
|
|
203
|
+
retrievability: fsrsResult.retrievability,
|
|
204
|
+
next_review: fsrsResult.nextReview.toISOString(),
|
|
205
|
+
review_count: card.review_count + 1,
|
|
206
|
+
last_rating: fsrsRating,
|
|
207
|
+
});
|
|
208
|
+
// Update topic stats
|
|
209
|
+
upsertTopicStats({
|
|
210
|
+
profile_id: profileId,
|
|
211
|
+
topic: card.topic,
|
|
212
|
+
subject: card.subject,
|
|
213
|
+
total_attempts: 1,
|
|
214
|
+
correct_attempts: correct ? 1 : 0,
|
|
215
|
+
last_attempt: new Date().toISOString(),
|
|
216
|
+
});
|
|
217
|
+
// Check for weak area
|
|
218
|
+
let weakAreaAlert;
|
|
219
|
+
const topicStats = getTopicStats(profileId).find(s => s.topic === card.topic);
|
|
220
|
+
if (topicStats && topicStats.total_attempts >= 5) {
|
|
221
|
+
const accuracy = topicStats.correct_attempts / topicStats.total_attempts;
|
|
222
|
+
if (accuracy < 0.6) {
|
|
223
|
+
weakAreaAlert = `Weak area detected: ${card.topic} (${Math.round(accuracy * 100)}% accuracy over ${topicStats.total_attempts} attempts). Consider extra focus on this topic.`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
correct,
|
|
228
|
+
correctAnswer: card.answer,
|
|
229
|
+
explanation,
|
|
230
|
+
nextReview: fsrsResult.nextReview.toISOString(),
|
|
231
|
+
intervalDays: fsrsResult.intervalDays,
|
|
232
|
+
rating: fsrsRating,
|
|
233
|
+
weakAreaAlert,
|
|
234
|
+
topic: card.topic,
|
|
235
|
+
subject: card.subject,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
export async function startMockExam(input) {
|
|
239
|
+
const { profileId } = input;
|
|
240
|
+
const config = getExamConfig(profileId);
|
|
241
|
+
if (!config) {
|
|
242
|
+
throw new Error('No exam configured. Use set_exam first.');
|
|
243
|
+
}
|
|
244
|
+
const examType = config.exam_type;
|
|
245
|
+
const questionCount = MOCK_EXAM_QUESTIONS[examType] || 60;
|
|
246
|
+
const timeLimitMins = EXAM_TIME_LIMITS[examType] || 120;
|
|
247
|
+
// Select balanced questions across topics
|
|
248
|
+
const cards = getAllCardsForExam(profileId, examType, questionCount * 2);
|
|
249
|
+
const shuffled = [...cards].sort(() => Math.random() - 0.5);
|
|
250
|
+
const selected = shuffled.slice(0, questionCount);
|
|
251
|
+
// Create session
|
|
252
|
+
const sessionId = uuidv4();
|
|
253
|
+
insertSession({
|
|
254
|
+
id: sessionId,
|
|
255
|
+
profile_id: profileId,
|
|
256
|
+
exam_type: examType,
|
|
257
|
+
started_at: new Date().toISOString(),
|
|
258
|
+
ended_at: null,
|
|
259
|
+
questions_answered: 0,
|
|
260
|
+
correct: 0,
|
|
261
|
+
readiness_score: null,
|
|
262
|
+
});
|
|
263
|
+
const questions = selected.map(card => ({
|
|
264
|
+
cardId: card.id,
|
|
265
|
+
subject: card.subject,
|
|
266
|
+
topic: card.topic,
|
|
267
|
+
question: card.question,
|
|
268
|
+
type: findTemplate(card.question)?.type || 'short_answer',
|
|
269
|
+
choices: findTemplate(card.question)?.choices,
|
|
270
|
+
}));
|
|
271
|
+
return {
|
|
272
|
+
sessionId,
|
|
273
|
+
questions,
|
|
274
|
+
timeLimitMins,
|
|
275
|
+
totalQuestions: questions.length,
|
|
276
|
+
examType,
|
|
277
|
+
instructions: `You have ${timeLimitMins} minutes to complete ${questions.length} questions. This simulates the actual ${examType} exam. Answer each question and submit when done.`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
export async function submitMockExam(input) {
|
|
281
|
+
const { sessionId, answers } = input;
|
|
282
|
+
const session = getSession(sessionId);
|
|
283
|
+
if (!session) {
|
|
284
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
285
|
+
}
|
|
286
|
+
const profileId = session.profile_id;
|
|
287
|
+
const config = getExamConfig(profileId);
|
|
288
|
+
// Grade all answers
|
|
289
|
+
let correct = 0;
|
|
290
|
+
const topicBreakdown = {};
|
|
291
|
+
const gradedAnswers = [];
|
|
292
|
+
for (const submission of answers) {
|
|
293
|
+
const card = getCard(submission.cardId);
|
|
294
|
+
if (!card)
|
|
295
|
+
continue;
|
|
296
|
+
const template = findTemplate(card.question);
|
|
297
|
+
const result = evaluateAnswer(card, submission.answer, template);
|
|
298
|
+
if (result.correct)
|
|
299
|
+
correct++;
|
|
300
|
+
// Update topic stats
|
|
301
|
+
upsertTopicStats({
|
|
302
|
+
profile_id: profileId,
|
|
303
|
+
topic: card.topic,
|
|
304
|
+
subject: card.subject,
|
|
305
|
+
total_attempts: 1,
|
|
306
|
+
correct_attempts: result.correct ? 1 : 0,
|
|
307
|
+
last_attempt: new Date().toISOString(),
|
|
308
|
+
});
|
|
309
|
+
// Topic breakdown
|
|
310
|
+
if (!topicBreakdown[card.topic]) {
|
|
311
|
+
topicBreakdown[card.topic] = { correct: 0, total: 0 };
|
|
312
|
+
}
|
|
313
|
+
topicBreakdown[card.topic].total++;
|
|
314
|
+
if (result.correct)
|
|
315
|
+
topicBreakdown[card.topic].correct++;
|
|
316
|
+
gradedAnswers.push({
|
|
317
|
+
cardId: submission.cardId,
|
|
318
|
+
correct: result.correct,
|
|
319
|
+
topic: card.topic,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const total = answers.length;
|
|
323
|
+
const score = total > 0 ? Math.round((correct / total) * 100) : 0;
|
|
324
|
+
// Calculate readiness
|
|
325
|
+
let readinessReport = null;
|
|
326
|
+
if (config) {
|
|
327
|
+
readinessReport = calculateReadiness(profileId, config.exam_type, config.exam_date);
|
|
328
|
+
}
|
|
329
|
+
// Update session
|
|
330
|
+
updateSession({
|
|
331
|
+
id: sessionId,
|
|
332
|
+
ended_at: new Date().toISOString(),
|
|
333
|
+
questions_answered: total,
|
|
334
|
+
correct,
|
|
335
|
+
readiness_score: readinessReport?.score ?? null,
|
|
336
|
+
});
|
|
337
|
+
// Format topic breakdown
|
|
338
|
+
const topicResults = Object.entries(topicBreakdown).map(([topic, stats]) => ({
|
|
339
|
+
topic,
|
|
340
|
+
correct: stats.correct,
|
|
341
|
+
total: stats.total,
|
|
342
|
+
accuracy: Math.round((stats.correct / stats.total) * 100),
|
|
343
|
+
})).sort((a, b) => a.accuracy - b.accuracy);
|
|
344
|
+
// Determine passing threshold
|
|
345
|
+
const examType = session.exam_type;
|
|
346
|
+
let passingThreshold = 60;
|
|
347
|
+
let estimatedGrade = `${score}%`;
|
|
348
|
+
if (examType.startsWith('AP_')) {
|
|
349
|
+
passingThreshold = 55;
|
|
350
|
+
if (score >= 85)
|
|
351
|
+
estimatedGrade = 'AP Score: 5/5';
|
|
352
|
+
else if (score >= 70)
|
|
353
|
+
estimatedGrade = 'AP Score: 4/5';
|
|
354
|
+
else if (score >= 55)
|
|
355
|
+
estimatedGrade = 'AP Score: 3/5';
|
|
356
|
+
else if (score >= 40)
|
|
357
|
+
estimatedGrade = 'AP Score: 2/5';
|
|
358
|
+
else
|
|
359
|
+
estimatedGrade = 'AP Score: 1/5';
|
|
360
|
+
}
|
|
361
|
+
else if (examType === 'SAT_Math') {
|
|
362
|
+
passingThreshold = 60;
|
|
363
|
+
if (score >= 90)
|
|
364
|
+
estimatedGrade = 'SAT Math: ~760-800';
|
|
365
|
+
else if (score >= 80)
|
|
366
|
+
estimatedGrade = 'SAT Math: ~700-750';
|
|
367
|
+
else if (score >= 70)
|
|
368
|
+
estimatedGrade = 'SAT Math: ~630-690';
|
|
369
|
+
else
|
|
370
|
+
estimatedGrade = 'SAT Math: below 630';
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
score,
|
|
374
|
+
correct,
|
|
375
|
+
total,
|
|
376
|
+
passingThreshold,
|
|
377
|
+
passed: score >= passingThreshold,
|
|
378
|
+
estimatedGrade,
|
|
379
|
+
readinessScore: readinessReport?.score,
|
|
380
|
+
topicBreakdown: topicResults,
|
|
381
|
+
strongestTopics: topicResults.filter(t => t.accuracy >= 80).map(t => t.topic),
|
|
382
|
+
weakestTopics: topicResults.filter(t => t.accuracy < 60).map(t => t.topic),
|
|
383
|
+
recommendation: readinessReport?.recommendation,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
export async function getReadiness(input) {
|
|
387
|
+
const { profileId } = input;
|
|
388
|
+
const config = getExamConfig(profileId);
|
|
389
|
+
if (!config) {
|
|
390
|
+
throw new Error('No exam configured. Use set_exam first.');
|
|
391
|
+
}
|
|
392
|
+
const report = calculateReadiness(profileId, config.exam_type, config.exam_date);
|
|
393
|
+
return {
|
|
394
|
+
profileId,
|
|
395
|
+
examType: config.exam_type,
|
|
396
|
+
examDate: config.exam_date,
|
|
397
|
+
...report,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
export async function getWeakAreas(input) {
|
|
401
|
+
const { profileId } = input;
|
|
402
|
+
const config = getExamConfig(profileId);
|
|
403
|
+
const weakTopicStats = getWeakTopics(profileId, 0.6);
|
|
404
|
+
const topics = weakTopicStats.map(stats => ({
|
|
405
|
+
topic: stats.topic,
|
|
406
|
+
subject: stats.subject,
|
|
407
|
+
accuracy: Math.round((stats.correct_attempts / stats.total_attempts) * 100),
|
|
408
|
+
attempts: stats.total_attempts,
|
|
409
|
+
correctAttempts: stats.correct_attempts,
|
|
410
|
+
}));
|
|
411
|
+
// Suggest focus
|
|
412
|
+
let suggestedFocus = 'No weak areas identified yet — keep practicing!';
|
|
413
|
+
if (topics.length > 0) {
|
|
414
|
+
const top3 = topics.slice(0, 3).map(t => t.topic).join(', ');
|
|
415
|
+
suggestedFocus = `Focus intensively on: ${top3}. Aim for at least 10 practice questions per topic before your exam.`;
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
profileId,
|
|
419
|
+
examType: config?.exam_type,
|
|
420
|
+
topics,
|
|
421
|
+
suggestedFocus,
|
|
422
|
+
totalWeakTopics: topics.length,
|
|
423
|
+
};
|
|
424
|
+
}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# exam-prep-mcp
|
|
2
|
+
|
|
3
|
+
> AI-powered exam preparation with FSRS-5 spaced repetition
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
exam-prep-mcp is a Model Context Protocol server for exam preparation. It implements the FSRS-5 (Free Spaced Repetition Scheduler) algorithm to optimally schedule practice card reviews, tracks student performance in a SQLite database, generates personalized study plans, runs mock exams, and produces readiness reports with grade estimates.
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
### set_exam
|
|
12
|
+
Configure exam preparation for a student profile.
|
|
13
|
+
- profileId: string (required) - unique student identifier
|
|
14
|
+
- examType: string (required) - one of the supported exam types
|
|
15
|
+
- examDate: string (required) - YYYY-MM-DD format
|
|
16
|
+
- currentLevel: "beginner" | "intermediate" | "advanced" (required)
|
|
17
|
+
- Returns: studyPlan, topicsIdentified, daysRemaining, seededCards
|
|
18
|
+
|
|
19
|
+
### get_daily_session
|
|
20
|
+
Get today's practice questions via FSRS-scheduled card selection.
|
|
21
|
+
- profileId: string (required)
|
|
22
|
+
- Returns: questions array (cardId, subject, topic, question, type, choices), estimatedMinutes, focusAreas
|
|
23
|
+
|
|
24
|
+
### answer_question
|
|
25
|
+
Submit student answer, evaluate correctness, apply FSRS scheduling.
|
|
26
|
+
- profileId: string (required)
|
|
27
|
+
- cardId: string (required)
|
|
28
|
+
- answer: string (required) - "A"/"B"/"C"/"D" for multiple choice, text for others
|
|
29
|
+
- rating: 1|2|3|4 (optional) - 1=Again, 2=Hard, 3=Good, 4=Easy
|
|
30
|
+
- Returns: correct (bool), correctAnswer, explanation, nextReview, intervalDays, weakAreaAlert
|
|
31
|
+
|
|
32
|
+
### start_mock_exam
|
|
33
|
+
Begin a full-length timed practice exam.
|
|
34
|
+
- profileId: string (required)
|
|
35
|
+
- Returns: sessionId, questions array, timeLimitMins, totalQuestions, instructions
|
|
36
|
+
|
|
37
|
+
### submit_mock_exam
|
|
38
|
+
Grade a completed mock exam and update performance data.
|
|
39
|
+
- sessionId: string (required)
|
|
40
|
+
- answers: Array<{cardId: string, answer: string}> (required)
|
|
41
|
+
- Returns: score (0-100), correct, total, estimatedGrade, readinessScore, topicBreakdown, weakestTopics, recommendation
|
|
42
|
+
|
|
43
|
+
### get_readiness
|
|
44
|
+
Get comprehensive readiness report with score and grade estimate.
|
|
45
|
+
- profileId: string (required)
|
|
46
|
+
- Returns: score (0-100), strongTopics, weakTopics, coveragePercent, trend, recommendation, estimatedGrade
|
|
47
|
+
|
|
48
|
+
### get_weak_areas
|
|
49
|
+
Identify topics with accuracy below 60% needing focus.
|
|
50
|
+
- profileId: string (required)
|
|
51
|
+
- Returns: topics array (topic, subject, accuracy, attempts), suggestedFocus
|
|
52
|
+
|
|
53
|
+
## Supported Exam Types
|
|
54
|
+
|
|
55
|
+
AP_Biology, AP_Chemistry, AP_US_History, AP_World_History, AP_Physics, AP_Calculus, AP_Statistics, AP_English, AP_Economics, SAT_Math, SAT_Reading, SAT_Writing, MCAT, LSAT, GRE
|
|
56
|
+
|
|
57
|
+
## Question Coverage
|
|
58
|
+
|
|
59
|
+
- AP Biology: Cell Structure, DNA Replication, Transcription/Translation, Cell Division, Genetics, Evolution, Ecology, Photosynthesis, Cellular Respiration
|
|
60
|
+
- AP Chemistry: Atomic Structure, Chemical Bonding, Stoichiometry, Gas Laws, Thermodynamics, Equilibrium, Acids/Bases, Electrochemistry
|
|
61
|
+
- AP US History: Colonial Period, Revolution, Constitutional Convention, Civil War, Reconstruction, Progressive Era, WWI, WWII, Cold War, Civil Rights
|
|
62
|
+
- SAT Math: Linear Equations, Systems, Quadratics, Percentages, Statistics, Geometry, Word Problems
|
|
63
|
+
|
|
64
|
+
## Grade Estimates
|
|
65
|
+
|
|
66
|
+
- AP exams: 1-5 scale (5=Extremely Well Qualified, 3=Qualified)
|
|
67
|
+
- SAT Math: score ranges 400-800
|
|
68
|
+
- MCAT: 472-528 scale
|
|
69
|
+
- LSAT: 120-180 scale
|
|
70
|
+
- GRE: 130-170 per section
|
|
71
|
+
|
|
72
|
+
## Algorithm
|
|
73
|
+
|
|
74
|
+
FSRS-5 parameters model memory with stability (S), difficulty (D), and retrievability R(t) = e^(-0.9t/S). Cards are scheduled at the interval where predicted retrievability equals 90%. New cards use initial stability based on rating (Again=0.41d, Hard=1.18d, Good=3.13d, Easy=15.47d).
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
Database: EXAM_PREP_DB environment variable, default ~/.exam-prep/study.sqlite
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
npm install -g @gonzih/exam-prep-mcp
|
|
83
|
+
or: npx @gonzih/exam-prep-mcp
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gonzih/exam-prep-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-powered exam preparation MCP server with FSRS spaced repetition",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Gonzih",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"exam-prep-mcp": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsx src/index.ts",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
20
|
+
"better-sqlite3": "^9.4.3",
|
|
21
|
+
"uuid": "^9.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/better-sqlite3": "^7.6.8",
|
|
25
|
+
"@types/node": "^20.11.5",
|
|
26
|
+
"@types/uuid": "^9.0.7",
|
|
27
|
+
"tsx": "^4.7.0",
|
|
28
|
+
"typescript": "^5.3.3"
|
|
29
|
+
}
|
|
30
|
+
}
|