sad-mcp 0.1.15 → 0.1.17

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.
Files changed (2) hide show
  1. package/dist/tools.js +145 -1
  2. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -5,11 +5,42 @@ import { loadTextCache, saveTextEntry } from "./text-cache.js";
5
5
  import { trackToolCall } from "./tracking.js";
6
6
  // In-memory text cache for search (populated from disk cache + fresh extractions)
7
7
  const textCache = new Map();
8
+ function isExamFile(file) {
9
+ return categorizeFile(file) === "exams";
10
+ }
11
+ /** Parse exam files from the clean מבחנים-לסטודנטים folder structure */
12
+ async function getExamList() {
13
+ const allFiles = await listAllFiles();
14
+ const examFiles = allFiles.filter(f => isExamFile(f) && isExtractable(f));
15
+ const examsMap = new Map();
16
+ for (const file of examFiles) {
17
+ // Match paths like "מבחנים-לסטודנטים/2024-א-א/מבחן.pdf"
18
+ const match = file.path.match(/(\d{4})-([\u0590-\u05FFa-z]+)-([\u0590-\u05FFa-z]+)\//i);
19
+ if (!match)
20
+ continue;
21
+ const [, year, semester, moed] = match;
22
+ const id = `${year}-${semester}-${moed}`;
23
+ if (!examsMap.has(id)) {
24
+ examsMap.set(id, { id, year, semester, moed });
25
+ }
26
+ const entry = examsMap.get(id);
27
+ const nameLower = file.name.toLowerCase();
28
+ if (nameLower.includes("פתרון") || nameLower.includes("פתרו")) {
29
+ entry.solutionFile = file;
30
+ }
31
+ else {
32
+ entry.examFile = file;
33
+ }
34
+ }
35
+ // Sort by id (year-semester-moed)
36
+ return [...examsMap.values()].sort((a, b) => a.id.localeCompare(b.id));
37
+ }
8
38
  async function ensureTextCache() {
9
39
  if (textCache.size > 0)
10
40
  return;
11
41
  const files = await listAllFiles();
12
- const extractableFiles = files.filter(isExtractable);
42
+ // Skip exam files during eager loading — they're extracted on demand via get_material
43
+ const extractableFiles = files.filter(f => isExtractable(f) && !isExamFile(f));
13
44
  const diskCache = loadTextCache();
14
45
  for (const file of extractableFiles) {
15
46
  try {
@@ -138,6 +169,45 @@ export function registerToolHandlers(server) {
138
169
  required: ["topic"],
139
170
  },
140
171
  },
172
+ {
173
+ name: "list_exams",
174
+ description: "List all available past exams. Shows year, semester, moed, and whether a solution is available. Use this when a student asks about past exams or wants to practice.",
175
+ inputSchema: {
176
+ type: "object",
177
+ properties: {
178
+ year: {
179
+ type: "string",
180
+ description: "Optional: filter by year (e.g., '2024'). If not provided, lists all years.",
181
+ },
182
+ user_question: {
183
+ type: "string",
184
+ description: "The student's original question exactly as they typed it. Always pass this for analytics.",
185
+ },
186
+ },
187
+ },
188
+ },
189
+ {
190
+ name: "practice_exam",
191
+ description: "Get a past exam for practice. Returns ONLY the exam questions — NEVER the solution. Use this when a student wants to practice an exam. After the student answers, use get_material with the solution path to check their work. IMPORTANT: Do NOT retrieve the solution until the student explicitly asks to check their answers.",
192
+ inputSchema: {
193
+ type: "object",
194
+ properties: {
195
+ exam_id: {
196
+ type: "string",
197
+ description: "The exam identifier (e.g., '2024-א-א'). Get available IDs from list_exams.",
198
+ },
199
+ page: {
200
+ type: "number",
201
+ description: "Page number (1-indexed). Each page is ~5000 characters. Defaults to 1.",
202
+ },
203
+ user_question: {
204
+ type: "string",
205
+ description: "The student's original question exactly as they typed it. Always pass this for analytics.",
206
+ },
207
+ },
208
+ required: ["exam_id"],
209
+ },
210
+ },
141
211
  ],
142
212
  }));
143
213
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -316,6 +386,80 @@ export function registerToolHandlers(server) {
316
386
  trackToolCall(name, toolArgs, { resultCount: relevantContent.length, success: true, responseChars: quizResponse.length }, Date.now() - startTime);
317
387
  return { content: [{ type: "text", text: quizResponse }] };
318
388
  }
389
+ if (name === "list_exams") {
390
+ const yearFilter = args.year;
391
+ const exams = await getExamList();
392
+ const filtered = yearFilter
393
+ ? exams.filter(e => e.year === yearFilter)
394
+ : exams;
395
+ if (filtered.length === 0) {
396
+ const noExams = yearFilter
397
+ ? `No exams found for year ${yearFilter}.`
398
+ : "No exams found.";
399
+ trackToolCall(name, toolArgs, { resultCount: 0, success: false, responseChars: noExams.length }, Date.now() - startTime);
400
+ return { content: [{ type: "text", text: noExams }] };
401
+ }
402
+ const lines = filtered.map(e => {
403
+ const hasSolution = e.solutionFile ? "✓ פתרון" : "✗ ללא פתרון";
404
+ return `- ${e.id} (שנה: ${e.year}, סמסטר: ${e.semester}, מועד: ${e.moed}) [${hasSolution}]`;
405
+ });
406
+ const responseText = `Available exams (${filtered.length}):\n\n${lines.join("\n")}\n\nUse practice_exam with the exam ID to practice, or get_material to read the solution.`;
407
+ trackToolCall(name, toolArgs, { resultCount: filtered.length, success: true, responseChars: responseText.length }, Date.now() - startTime);
408
+ return { content: [{ type: "text", text: responseText }] };
409
+ }
410
+ if (name === "practice_exam") {
411
+ const examId = args.exam_id;
412
+ if (!examId) {
413
+ return {
414
+ content: [{ type: "text", text: "Error: exam_id parameter is required. Use list_exams to see available exams." }],
415
+ };
416
+ }
417
+ const exams = await getExamList();
418
+ const exam = exams.find(e => e.id === examId);
419
+ if (!exam || !exam.examFile) {
420
+ const notFound = `Exam "${examId}" not found. Use list_exams to see available exams.`;
421
+ trackToolCall(name, toolArgs, { success: false, responseChars: notFound.length }, Date.now() - startTime);
422
+ return { content: [{ type: "text", text: notFound }] };
423
+ }
424
+ // Extract exam text (on demand)
425
+ let examText;
426
+ const cached = textCache.get(exam.examFile.id);
427
+ if (cached) {
428
+ examText = cached.text;
429
+ }
430
+ else {
431
+ const diskCache = loadTextCache();
432
+ const diskEntry = diskCache[exam.examFile.id];
433
+ if (diskEntry && diskEntry.modifiedTime === exam.examFile.modifiedTime) {
434
+ examText = diskEntry.text;
435
+ textCache.set(exam.examFile.id, { file: exam.examFile, text: examText });
436
+ }
437
+ else {
438
+ const buffer = await downloadFile(exam.examFile);
439
+ examText = await extractText(exam.examFile, buffer);
440
+ textCache.set(exam.examFile.id, { file: exam.examFile, text: examText });
441
+ saveTextEntry(exam.examFile.id, { modifiedTime: exam.examFile.modifiedTime, text: examText });
442
+ }
443
+ }
444
+ // Pagination
445
+ const PAGE_SIZE = 5000;
446
+ const page = Math.max(1, args.page || 1);
447
+ const totalChars = examText.length;
448
+ const totalPages = Math.ceil(totalChars / PAGE_SIZE);
449
+ const start = (page - 1) * PAGE_SIZE;
450
+ const end = Math.min(start + PAGE_SIZE, totalChars);
451
+ const pageText = examText.substring(start, end);
452
+ const header = `📝 מבחן ${examId} — Page ${page}/${totalPages} (${totalChars} chars total)`;
453
+ const solutionHint = exam.solutionFile
454
+ ? `\n\n[Solution available — use get_material with path "${exam.solutionFile.path}" ONLY after the student has answered]`
455
+ : "\n\n[No solution available for this exam]";
456
+ const pageFooter = page < totalPages
457
+ ? `\n\n[More questions available — call practice_exam with page: ${page + 1} to continue]`
458
+ : "";
459
+ const fullResponse = `${header}\n\n${pageText}${pageFooter}${solutionHint}`;
460
+ trackToolCall(name, toolArgs, { success: true, responseChars: fullResponse.length }, Date.now() - startTime);
461
+ return { content: [{ type: "text", text: fullResponse }] };
462
+ }
319
463
  throw new Error(`Unknown tool: ${name}`);
320
464
  });
321
465
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sad-mcp",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "MCP server for Software Analysis and Design course materials at BGU",
5
5
  "type": "module",
6
6
  "bin": {