@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/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
|
+
}
|