@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/src/tools.ts ADDED
@@ -0,0 +1,557 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import {
3
+ insertCard,
4
+ insertSession,
5
+ updateSession,
6
+ getSession,
7
+ getCard,
8
+ updateCard,
9
+ getDueCards,
10
+ getNewCards,
11
+ getAllCardsForExam,
12
+ getWeakTopics,
13
+ upsertExamConfig,
14
+ getExamConfig,
15
+ upsertTopicStats,
16
+ getTopicStats,
17
+ countCardsByQuestion,
18
+ Card,
19
+ } from './db.js';
20
+ import { scheduleReview } from './fsrs.js';
21
+ import { QUESTION_BANK, QuestionTemplate } from './questions.js';
22
+ import { generateStudyPlan, MOCK_EXAM_QUESTIONS, EXAM_TIME_LIMITS } from './study-plan.js';
23
+ import { calculateReadiness } from './readiness.js';
24
+
25
+ // ─── Tool Input Types ─────────────────────────────────────────────────────────
26
+
27
+ export interface SetExamInput {
28
+ profileId: string;
29
+ examType: string;
30
+ examDate: string;
31
+ currentLevel: 'beginner' | 'intermediate' | 'advanced';
32
+ }
33
+
34
+ export interface GetDailySessionInput {
35
+ profileId: string;
36
+ }
37
+
38
+ export interface AnswerQuestionInput {
39
+ profileId: string;
40
+ cardId: string;
41
+ answer: string;
42
+ rating?: 1 | 2 | 3 | 4;
43
+ }
44
+
45
+ export interface StartMockExamInput {
46
+ profileId: string;
47
+ }
48
+
49
+ export interface SubmitMockExamInput {
50
+ sessionId: string;
51
+ answers: Array<{ cardId: string; answer: string }>;
52
+ }
53
+
54
+ export interface GetReadinessInput {
55
+ profileId: string;
56
+ }
57
+
58
+ export interface GetWeakAreasInput {
59
+ profileId: string;
60
+ }
61
+
62
+ // ─── Helper Functions ─────────────────────────────────────────────────────────
63
+
64
+ function questionTemplateToCard(template: QuestionTemplate, profileId: string): Card {
65
+ return {
66
+ id: uuidv4(),
67
+ profile_id: profileId,
68
+ subject: template.subject,
69
+ topic: template.topic,
70
+ question: formatQuestion(template),
71
+ answer: template.answer,
72
+ difficulty: template.difficulty === 'easy' ? 0.2 : template.difficulty === 'medium' ? 0.4 : 0.7,
73
+ stability: 1.0,
74
+ retrievability: 1.0,
75
+ next_review: new Date().toISOString(),
76
+ review_count: 0,
77
+ last_rating: null,
78
+ created_at: new Date().toISOString(),
79
+ };
80
+ }
81
+
82
+ function formatQuestion(template: QuestionTemplate): string {
83
+ if (template.type === 'multiple_choice' && template.choices) {
84
+ return `${template.question}\n\n${template.choices.join('\n')}`;
85
+ }
86
+ return template.question;
87
+ }
88
+
89
+ function evaluateAnswer(
90
+ card: Card,
91
+ studentAnswer: string,
92
+ template?: QuestionTemplate
93
+ ): { correct: boolean; explanation: string } {
94
+ const correctAnswer = card.answer.trim().toLowerCase();
95
+ const studentAnswerNorm = studentAnswer.trim().toLowerCase();
96
+
97
+ // Multiple choice — exact match (single letter)
98
+ if (template?.type === 'multiple_choice') {
99
+ const correct = correctAnswer === studentAnswerNorm ||
100
+ correctAnswer.startsWith(studentAnswerNorm) ||
101
+ studentAnswerNorm === correctAnswer[0];
102
+ return {
103
+ correct,
104
+ explanation: template.explanation
105
+ };
106
+ }
107
+
108
+ // Problem solving — check numeric answer
109
+ if (template?.type === 'problem_solving') {
110
+ // Extract numbers from both answers
111
+ const correctNum = extractNumber(correctAnswer);
112
+ const studentNum = extractNumber(studentAnswerNorm);
113
+ if (correctNum !== null && studentNum !== null) {
114
+ const tolerance = Math.abs(correctNum) * 0.05 || 0.01;
115
+ const correct = Math.abs(correctNum - studentNum) <= tolerance;
116
+ return { correct, explanation: template?.explanation || '' };
117
+ }
118
+ // Fallback to keyword matching
119
+ return keywordMatch(correctAnswer, studentAnswerNorm, template?.explanation || '');
120
+ }
121
+
122
+ // Short answer / free response — keyword matching
123
+ return keywordMatch(correctAnswer, studentAnswerNorm, template?.explanation || '');
124
+ }
125
+
126
+ function extractNumber(text: string): number | null {
127
+ const match = text.match(/-?\d+\.?\d*/);
128
+ if (match) return parseFloat(match[0]);
129
+ return null;
130
+ }
131
+
132
+ function keywordMatch(correctAnswer: string, studentAnswer: string, explanation: string): { correct: boolean; explanation: string } {
133
+ // Check if student answer contains key terms from correct answer
134
+ const keyTerms = correctAnswer
135
+ .split(/[\s,;.]+/)
136
+ .filter(w => w.length > 3)
137
+ .map(w => w.toLowerCase());
138
+
139
+ if (keyTerms.length === 0) {
140
+ const correct = correctAnswer === studentAnswer;
141
+ return { correct, explanation };
142
+ }
143
+
144
+ let matched = 0;
145
+ for (const term of keyTerms) {
146
+ if (studentAnswer.includes(term)) matched++;
147
+ }
148
+
149
+ const matchRatio = matched / keyTerms.length;
150
+ const correct = matchRatio >= 0.5;
151
+ return { correct, explanation };
152
+ }
153
+
154
+ function findTemplate(question: string): QuestionTemplate | undefined {
155
+ // Strip choices if present
156
+ const questionText = question.split('\n\n')[0];
157
+ return QUESTION_BANK.find(t => t.question === questionText);
158
+ }
159
+
160
+ // ─── Tool Implementations ─────────────────────────────────────────────────────
161
+
162
+ export async function setExam(input: SetExamInput) {
163
+ const { profileId, examType, examDate, currentLevel } = input;
164
+
165
+ // Store exam config
166
+ upsertExamConfig({
167
+ profile_id: profileId,
168
+ exam_type: examType,
169
+ exam_date: examDate,
170
+ current_level: currentLevel,
171
+ created_at: new Date().toISOString(),
172
+ });
173
+
174
+ // Seed cards from question bank
175
+ const questions = QUESTION_BANK.filter(q => q.subject === examType);
176
+ let seeded = 0;
177
+
178
+ for (const template of questions) {
179
+ const formattedQuestion = formatQuestion(template);
180
+ const existing = countCardsByQuestion(profileId, formattedQuestion);
181
+ if (existing === 0) {
182
+ const card = questionTemplateToCard(template, profileId);
183
+ card.question = formattedQuestion;
184
+ insertCard(card);
185
+ seeded++;
186
+ }
187
+ }
188
+
189
+ // Generate study plan
190
+ const studyPlan = generateStudyPlan(examType, examDate, currentLevel);
191
+ const now = new Date();
192
+ const examDateObj = new Date(examDate);
193
+ const daysRemaining = Math.ceil((examDateObj.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
194
+
195
+ return {
196
+ success: true,
197
+ message: `Exam configured: ${examType} on ${examDate}`,
198
+ seededCards: seeded,
199
+ daysRemaining,
200
+ topicsIdentified: studyPlan.topicsIdentified,
201
+ studyPlan: {
202
+ examType: studyPlan.examType,
203
+ examDate: studyPlan.examDate,
204
+ daysRemaining: studyPlan.daysRemaining,
205
+ weeklyGoals: studyPlan.weeklyGoals,
206
+ first7Days: studyPlan.dailySchedule.slice(0, 7),
207
+ }
208
+ };
209
+ }
210
+
211
+ export async function getDailySession(input: GetDailySessionInput) {
212
+ const { profileId } = input;
213
+
214
+ const config = getExamConfig(profileId);
215
+ if (!config) {
216
+ throw new Error('No exam configured. Use set_exam first.');
217
+ }
218
+
219
+ const now = new Date().toISOString();
220
+ const TARGET_QUESTIONS = 12;
221
+
222
+ // Get due cards
223
+ let dueCards = getDueCards(profileId, now, TARGET_QUESTIONS);
224
+
225
+ // Top up with new cards if needed
226
+ if (dueCards.length < 5) {
227
+ const newCards = getNewCards(profileId, TARGET_QUESTIONS - dueCards.length);
228
+ const existingIds = new Set(dueCards.map(c => c.id));
229
+ const uniqueNew = newCards.filter(c => !existingIds.has(c.id));
230
+ dueCards = [...dueCards, ...uniqueNew].slice(0, TARGET_QUESTIONS);
231
+ }
232
+
233
+ // Identify focus areas
234
+ const topicCounts: Record<string, number> = {};
235
+ for (const card of dueCards) {
236
+ topicCounts[card.topic] = (topicCounts[card.topic] || 0) + 1;
237
+ }
238
+
239
+ const focusAreas = Object.entries(topicCounts)
240
+ .sort((a, b) => b[1] - a[1])
241
+ .slice(0, 3)
242
+ .map(([topic]) => topic);
243
+
244
+ const questions = dueCards.map(card => ({
245
+ cardId: card.id,
246
+ subject: card.subject,
247
+ topic: card.topic,
248
+ question: card.question,
249
+ type: findTemplate(card.question)?.type || 'short_answer',
250
+ choices: findTemplate(card.question)?.choices,
251
+ difficulty: card.difficulty < 0.35 ? 'easy' : card.difficulty < 0.55 ? 'medium' : 'hard',
252
+ reviewCount: card.review_count,
253
+ }));
254
+
255
+ return {
256
+ questions,
257
+ estimatedMinutes: Math.ceil(dueCards.length * 2.5),
258
+ focusAreas,
259
+ totalDue: dueCards.length,
260
+ examType: config.exam_type,
261
+ examDate: config.exam_date,
262
+ };
263
+ }
264
+
265
+ export async function answerQuestion(input: AnswerQuestionInput) {
266
+ const { profileId, cardId, answer, rating } = input;
267
+
268
+ const card = getCard(cardId);
269
+ if (!card) {
270
+ throw new Error(`Card ${cardId} not found`);
271
+ }
272
+
273
+ if (card.profile_id !== profileId) {
274
+ throw new Error('Card does not belong to this profile');
275
+ }
276
+
277
+ const template = findTemplate(card.question);
278
+ const { correct, explanation } = evaluateAnswer(card, answer, template);
279
+
280
+ // Determine FSRS rating
281
+ let fsrsRating: 1 | 2 | 3 | 4 = rating || (correct ? 3 : 1);
282
+ if (!rating) {
283
+ fsrsRating = correct ? 3 : 1;
284
+ }
285
+
286
+ // Apply FSRS scheduling
287
+ const lastReview = card.next_review ? new Date(card.next_review) : undefined;
288
+ const fsrsResult = scheduleReview(
289
+ fsrsRating,
290
+ card.stability,
291
+ card.difficulty,
292
+ card.review_count,
293
+ lastReview
294
+ );
295
+
296
+ // Update card
297
+ updateCard({
298
+ id: card.id,
299
+ difficulty: fsrsResult.difficulty,
300
+ stability: fsrsResult.stability,
301
+ retrievability: fsrsResult.retrievability,
302
+ next_review: fsrsResult.nextReview.toISOString(),
303
+ review_count: card.review_count + 1,
304
+ last_rating: fsrsRating,
305
+ });
306
+
307
+ // Update topic stats
308
+ upsertTopicStats({
309
+ profile_id: profileId,
310
+ topic: card.topic,
311
+ subject: card.subject,
312
+ total_attempts: 1,
313
+ correct_attempts: correct ? 1 : 0,
314
+ last_attempt: new Date().toISOString(),
315
+ });
316
+
317
+ // Check for weak area
318
+ let weakAreaAlert: string | undefined;
319
+ const topicStats = getTopicStats(profileId).find(s => s.topic === card.topic);
320
+ if (topicStats && topicStats.total_attempts >= 5) {
321
+ const accuracy = topicStats.correct_attempts / topicStats.total_attempts;
322
+ if (accuracy < 0.6) {
323
+ weakAreaAlert = `Weak area detected: ${card.topic} (${Math.round(accuracy * 100)}% accuracy over ${topicStats.total_attempts} attempts). Consider extra focus on this topic.`;
324
+ }
325
+ }
326
+
327
+ return {
328
+ correct,
329
+ correctAnswer: card.answer,
330
+ explanation,
331
+ nextReview: fsrsResult.nextReview.toISOString(),
332
+ intervalDays: fsrsResult.intervalDays,
333
+ rating: fsrsRating,
334
+ weakAreaAlert,
335
+ topic: card.topic,
336
+ subject: card.subject,
337
+ };
338
+ }
339
+
340
+ export async function startMockExam(input: StartMockExamInput) {
341
+ const { profileId } = input;
342
+
343
+ const config = getExamConfig(profileId);
344
+ if (!config) {
345
+ throw new Error('No exam configured. Use set_exam first.');
346
+ }
347
+
348
+ const examType = config.exam_type;
349
+ const questionCount = MOCK_EXAM_QUESTIONS[examType] || 60;
350
+ const timeLimitMins = EXAM_TIME_LIMITS[examType] || 120;
351
+
352
+ // Select balanced questions across topics
353
+ const cards = getAllCardsForExam(profileId, examType, questionCount * 2);
354
+ const shuffled = [...cards].sort(() => Math.random() - 0.5);
355
+ const selected = shuffled.slice(0, questionCount);
356
+
357
+ // Create session
358
+ const sessionId = uuidv4();
359
+ insertSession({
360
+ id: sessionId,
361
+ profile_id: profileId,
362
+ exam_type: examType,
363
+ started_at: new Date().toISOString(),
364
+ ended_at: null,
365
+ questions_answered: 0,
366
+ correct: 0,
367
+ readiness_score: null,
368
+ });
369
+
370
+ const questions = selected.map(card => ({
371
+ cardId: card.id,
372
+ subject: card.subject,
373
+ topic: card.topic,
374
+ question: card.question,
375
+ type: findTemplate(card.question)?.type || 'short_answer',
376
+ choices: findTemplate(card.question)?.choices,
377
+ }));
378
+
379
+ return {
380
+ sessionId,
381
+ questions,
382
+ timeLimitMins,
383
+ totalQuestions: questions.length,
384
+ examType,
385
+ instructions: `You have ${timeLimitMins} minutes to complete ${questions.length} questions. This simulates the actual ${examType} exam. Answer each question and submit when done.`,
386
+ };
387
+ }
388
+
389
+ export async function submitMockExam(input: SubmitMockExamInput) {
390
+ const { sessionId, answers } = input;
391
+
392
+ const session = getSession(sessionId);
393
+ if (!session) {
394
+ throw new Error(`Session ${sessionId} not found`);
395
+ }
396
+
397
+ const profileId = session.profile_id;
398
+ const config = getExamConfig(profileId);
399
+
400
+ // Grade all answers
401
+ let correct = 0;
402
+ const topicBreakdown: Record<string, { correct: number; total: number }> = {};
403
+ const gradedAnswers: Array<{
404
+ cardId: string;
405
+ correct: boolean;
406
+ topic: string;
407
+ }> = [];
408
+
409
+ for (const submission of answers) {
410
+ const card = getCard(submission.cardId);
411
+ if (!card) continue;
412
+
413
+ const template = findTemplate(card.question);
414
+ const result = evaluateAnswer(card, submission.answer, template);
415
+
416
+ if (result.correct) correct++;
417
+
418
+ // Update topic stats
419
+ upsertTopicStats({
420
+ profile_id: profileId,
421
+ topic: card.topic,
422
+ subject: card.subject,
423
+ total_attempts: 1,
424
+ correct_attempts: result.correct ? 1 : 0,
425
+ last_attempt: new Date().toISOString(),
426
+ });
427
+
428
+ // Topic breakdown
429
+ if (!topicBreakdown[card.topic]) {
430
+ topicBreakdown[card.topic] = { correct: 0, total: 0 };
431
+ }
432
+ topicBreakdown[card.topic].total++;
433
+ if (result.correct) topicBreakdown[card.topic].correct++;
434
+
435
+ gradedAnswers.push({
436
+ cardId: submission.cardId,
437
+ correct: result.correct,
438
+ topic: card.topic,
439
+ });
440
+ }
441
+
442
+ const total = answers.length;
443
+ const score = total > 0 ? Math.round((correct / total) * 100) : 0;
444
+
445
+ // Calculate readiness
446
+ let readinessReport = null;
447
+ if (config) {
448
+ readinessReport = calculateReadiness(profileId, config.exam_type, config.exam_date);
449
+ }
450
+
451
+ // Update session
452
+ updateSession({
453
+ id: sessionId,
454
+ ended_at: new Date().toISOString(),
455
+ questions_answered: total,
456
+ correct,
457
+ readiness_score: readinessReport?.score ?? null,
458
+ });
459
+
460
+ // Format topic breakdown
461
+ const topicResults = Object.entries(topicBreakdown).map(([topic, stats]) => ({
462
+ topic,
463
+ correct: stats.correct,
464
+ total: stats.total,
465
+ accuracy: Math.round((stats.correct / stats.total) * 100),
466
+ })).sort((a, b) => a.accuracy - b.accuracy);
467
+
468
+ // Determine passing threshold
469
+ const examType = session.exam_type;
470
+ let passingThreshold = 60;
471
+ let estimatedGrade = `${score}%`;
472
+
473
+ if (examType.startsWith('AP_')) {
474
+ passingThreshold = 55;
475
+ if (score >= 85) estimatedGrade = 'AP Score: 5/5';
476
+ else if (score >= 70) estimatedGrade = 'AP Score: 4/5';
477
+ else if (score >= 55) estimatedGrade = 'AP Score: 3/5';
478
+ else if (score >= 40) estimatedGrade = 'AP Score: 2/5';
479
+ else estimatedGrade = 'AP Score: 1/5';
480
+ } else if (examType === 'SAT_Math') {
481
+ passingThreshold = 60;
482
+ if (score >= 90) estimatedGrade = 'SAT Math: ~760-800';
483
+ else if (score >= 80) estimatedGrade = 'SAT Math: ~700-750';
484
+ else if (score >= 70) estimatedGrade = 'SAT Math: ~630-690';
485
+ else estimatedGrade = 'SAT Math: below 630';
486
+ }
487
+
488
+ return {
489
+ score,
490
+ correct,
491
+ total,
492
+ passingThreshold,
493
+ passed: score >= passingThreshold,
494
+ estimatedGrade,
495
+ readinessScore: readinessReport?.score,
496
+ topicBreakdown: topicResults,
497
+ strongestTopics: topicResults.filter(t => t.accuracy >= 80).map(t => t.topic),
498
+ weakestTopics: topicResults.filter(t => t.accuracy < 60).map(t => t.topic),
499
+ recommendation: readinessReport?.recommendation,
500
+ };
501
+ }
502
+
503
+ export async function getReadiness(input: GetReadinessInput) {
504
+ const { profileId } = input;
505
+
506
+ const config = getExamConfig(profileId);
507
+ if (!config) {
508
+ throw new Error('No exam configured. Use set_exam first.');
509
+ }
510
+
511
+ const report = calculateReadiness(profileId, config.exam_type, config.exam_date);
512
+
513
+ return {
514
+ profileId,
515
+ examType: config.exam_type,
516
+ examDate: config.exam_date,
517
+ ...report,
518
+ };
519
+ }
520
+
521
+ export interface WeakArea {
522
+ topic: string;
523
+ subject: string;
524
+ accuracy: number;
525
+ attempts: number;
526
+ correctAttempts: number;
527
+ }
528
+
529
+ export async function getWeakAreas(input: GetWeakAreasInput) {
530
+ const { profileId } = input;
531
+
532
+ const config = getExamConfig(profileId);
533
+ const weakTopicStats = getWeakTopics(profileId, 0.6);
534
+
535
+ const topics: WeakArea[] = weakTopicStats.map(stats => ({
536
+ topic: stats.topic,
537
+ subject: stats.subject,
538
+ accuracy: Math.round((stats.correct_attempts / stats.total_attempts) * 100),
539
+ attempts: stats.total_attempts,
540
+ correctAttempts: stats.correct_attempts,
541
+ }));
542
+
543
+ // Suggest focus
544
+ let suggestedFocus = 'No weak areas identified yet — keep practicing!';
545
+ if (topics.length > 0) {
546
+ const top3 = topics.slice(0, 3).map(t => t.topic).join(', ');
547
+ suggestedFocus = `Focus intensively on: ${top3}. Aim for at least 10 practice questions per topic before your exam.`;
548
+ }
549
+
550
+ return {
551
+ profileId,
552
+ examType: config?.exam_type,
553
+ topics,
554
+ suggestedFocus,
555
+ totalWeakTopics: topics.length,
556
+ };
557
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "resolveJsonModule": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }