Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
371 lines (314 loc) · 13.8 KB
/
Copy pathserver.js
File metadata and controls
371 lines (314 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
require('dotenv').config();
const express = require('express');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const pdfParse = require('pdf-parse');
const app = express();
const PORT = process.env.PORT || 3000;
const API_KEY = process.env.ANTHROPIC_API_KEY;
const MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514';
const SYNC_KEY = process.env.SYNC_KEY || null;
if (!API_KEY) {
throw new Error('Missing ANTHROPIC_API_KEY in .env');
}
// Multer config - store PDFs in memory
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed'));
}
}
});
// ============================================
// MIDDLEWARE
// ============================================
app.use(express.json({ limit: '5mb' }));
// ============================================
// CLAUDE API - PARSE QUESTIONS
// ============================================
const PARSE_SYSTEM_PROMPT = `You are a question extractor. Your job is to parse text that contains exam/review questions and extract multiple choice AND short answer/fill-in-the-blank questions as structured JSON.
## ABSOLUTE RULES
1. Extract multiple choice questions (questions with lettered options like A, B, C, D) AND short answer/fill-in-the-blank questions.
2. IGNORE essay questions, free response, and true/false.
3. Preserve the EXACT question text and option text as written. Do not rephrase, summarize, or modify.
4. Preserve the original question numbering (e.g., if questions start at 24, keep that numbering).
5. If a correct answer is indicated in the text (answer key, bold text, asterisk, **bold markers**, etc.), include it. If no correct answer is indicated, set correctAnswer to null.
6. If an explanation is provided for an answer, include it. Otherwise omit the explanation field.
## QUESTION TYPES
### Multiple choice: type "mc"
Questions with lettered options (A, B, C, D). Include options array.
### Short answer / fill-in-the-blank: type "short_answer"
Questions with blanks (_____ or similar) or that expect a typed answer. Also includes definition/explanation questions where only one answer sub-item (e.g. "a. **answer**") is listed rather than multiple choices.
- Do NOT include an options array for these.
- correctAnswer must be an ARRAY of all acceptable answers (strings).
- If the answer has alternatives separated by "or" (e.g. "**mitosis** or **cell division**"), each alternative is a separate entry in the array.
- Strip any ** markers from answers.
## OUTPUT FORMAT
Return ONLY valid JSON, no markdown code fences, no explanation text. The format must be:
[
{
"id": "q1",
"number": 1,
"type": "mc",
"question": "The exact question text as written",
"options": [
{ "letter": "A", "text": "First option text" },
{ "letter": "B", "text": "Second option text" }
],
"correctAnswer": "B",
"explanation": "Optional explanation if provided in source"
},
{
"id": "q2",
"number": 2,
"type": "short_answer",
"question": "The _____ is the powerhouse of the cell.",
"correctAnswer": ["mitochondria", "mitochondrion"],
"explanation": "Optional explanation if provided in source"
}
]
## EDGE CASES
- If options use lowercase (a, b, c, d), normalize to uppercase (A, B, C, D).
- If a question has more or fewer than 4 options, include all of them.
- If you find zero questions, return an empty array: []
- Do NOT invent or guess correct answers. Only include them if explicitly present in the source text.
- Some questions may span multiple lines or have complex formatting - capture the full question text.
- Text wrapped in **double asterisks** indicates the correct answer - extract the letter of that option as correctAnswer for MC, or the text for short answer.
- Remove any ** markers from the option/answer text in the output.
- For short answer questions with no indicated answer, set correctAnswer to null (not an empty array).
- If a question has blanks (___) but ALSO has multiple lettered options (A, B, C, D), treat it as type "mc".
- IMPORTANT: A common academic format lists the answer on a single sub-item like "a. **Answer**" under each question. If a question only has ONE lettered sub-item (just "a.") containing the answer, this is NOT multiple choice - it is a short answer question. The "a." line is the answer, not a choice. Extract these as type "short_answer" with the answer text in the correctAnswer array.`;
const GENERATE_SYSTEM_PROMPT = `You are a study question generator. Given raw study notes, textbook content, or lecture material, create a mix of multiple choice and short answer questions that test understanding of the key concepts.
## RULES
1. Generate questions that cover the most important concepts, facts, and definitions in the material.
2. Create a mix of multiple choice (type "mc") and short answer / fill-in-the-blank (type "short_answer") questions.
3. For multiple choice questions, create 4 options (A, B, C, D) where only one is correct. Make distractors plausible.
4. For short answer questions, include all acceptable answers in the correctAnswer array.
5. Always set the correctAnswer for generated questions.
6. Generate roughly 1 question per major concept or key point (aim for 5-20 questions depending on material length).
## OUTPUT FORMAT
Return ONLY valid JSON, no markdown code fences. The format must be:
[
{
"id": "q1",
"number": 1,
"type": "mc",
"question": "Generated question text",
"options": [
{ "letter": "A", "text": "Option text" },
{ "letter": "B", "text": "Option text" },
{ "letter": "C", "text": "Option text" },
{ "letter": "D", "text": "Option text" }
],
"correctAnswer": "B",
"explanation": "Brief explanation of why this is correct"
},
{
"id": "q2",
"number": 2,
"type": "short_answer",
"question": "The _____ is the powerhouse of the cell.",
"correctAnswer": ["mitochondria", "mitochondrion"],
"explanation": "Brief explanation"
}
]`;
app.post('/api/generate-questions', async (req, res) => {
try {
const { text } = req.body;
if (!text || !text.trim()) {
return res.status(400).json({ error: 'No text provided' });
}
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: MODEL,
max_tokens: 16000,
system: GENERATE_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Generate study questions (multiple choice and short answer) from the following notes/material:\n\n${text}`
}]
})
});
const data = await apiRes.json();
if (data.error) {
console.error('Anthropic API error:', JSON.stringify(data.error));
return res.status(500).json({
error: `API error: ${data.error.message || 'Unknown error'}`
});
}
if (data.content && data.content[0]?.text) {
let responseText = data.content[0].text.trim();
if (responseText.startsWith('```')) {
responseText = responseText
.replace(/^```(?:json)?\n?/, '')
.replace(/\n?```$/, '');
}
try {
const questions = JSON.parse(responseText);
res.json({ questions });
} catch {
console.error('Failed to parse generated questions:', responseText.substring(0, 200));
res.status(500).json({
error: 'Failed to parse generated questions',
raw: data.content[0].text
});
}
} else {
res.status(500).json({ error: 'Unexpected API response format' });
}
} catch (err) {
console.error('Generate request error:', err.message);
res.status(500).json({ error: `Request failed: ${err.message}` });
}
});
app.post('/api/parse-questions', async (req, res) => {
try {
const { text } = req.body;
if (!text || !text.trim()) {
return res.status(400).json({ error: 'No text provided to parse' });
}
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: MODEL,
max_tokens: 16000,
system: PARSE_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Extract all multiple choice and short answer/fill-in-the-blank questions from the following text:\n\n${text}`
}]
})
});
const data = await apiRes.json();
// Handle API-level errors
if (data.error) {
console.error('Anthropic API error:', JSON.stringify(data.error));
return res.status(500).json({
error: `API error: ${data.error.message || 'Unknown error'}`
});
}
if (data.content && data.content[0]?.text) {
let responseText = data.content[0].text.trim();
// Strip markdown code fences if present
if (responseText.startsWith('```')) {
responseText = responseText
.replace(/^```(?:json)?\n?/, '')
.replace(/\n?```$/, '');
}
try {
const questions = JSON.parse(responseText);
res.json({ questions });
} catch {
console.error('Failed to parse Claude response as JSON:', responseText.substring(0, 200));
res.status(500).json({
error: 'Failed to parse questions into structured format',
raw: data.content[0].text
});
}
} else {
console.error('Unexpected API response:', JSON.stringify(data).substring(0, 500));
res.status(500).json({ error: 'Unexpected API response format' });
}
} catch (err) {
console.error('Parse request error:', err.message);
res.status(500).json({ error: `Request failed: ${err.message}` });
}
});
// ============================================
// PDF UPLOAD
// ============================================
app.post('/api/upload-pdf', upload.single('pdf'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No PDF file uploaded' });
}
const data = await pdfParse(req.file.buffer);
res.json({ text: data.text, pages: data.numpages });
} catch (err) {
console.error('PDF parse error:', err.message);
res.status(500).json({ error: 'Failed to parse PDF file' });
}
});
// ============================================
// DATA SYNC (optional - requires SYNC_KEY in .env)
// ============================================
const SYNC_DIR = path.join(__dirname, '.data');
const SYNC_FILE = path.join(SYNC_DIR, 'sync.json');
// Expose whether sync is available so the client can check
app.get('/api/sync/status', (req, res) => {
res.json({ enabled: !!SYNC_KEY });
});
app.get('/api/sync', (req, res) => {
if (!SYNC_KEY) return res.status(404).json({ error: 'Sync not configured' });
const key = req.headers['x-sync-key'];
if (key !== SYNC_KEY) return res.status(401).json({ error: 'Invalid sync key' });
try {
if (fs.existsSync(SYNC_FILE)) {
const data = JSON.parse(fs.readFileSync(SYNC_FILE, 'utf8'));
res.json(data);
} else {
res.json(null);
}
} catch (err) {
console.error('Sync read error:', err.message);
res.status(500).json({ error: 'Failed to read sync data' });
}
});
app.post('/api/sync', (req, res) => {
if (!SYNC_KEY) return res.status(404).json({ error: 'Sync not configured' });
const key = req.headers['x-sync-key'];
if (key !== SYNC_KEY) return res.status(401).json({ error: 'Invalid sync key' });
try {
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
fs.writeFileSync(SYNC_FILE, JSON.stringify(req.body, null, 2));
res.json({ ok: true });
} catch (err) {
console.error('Sync write error:', err.message);
res.status(500).json({ error: 'Failed to write sync data' });
}
});
// ============================================
// DOCS - serve markdown files
// ============================================
app.get('/api/docs/:file', (req, res) => {
const allowed = { 'readme': 'README.md', 'changelog': 'CHANGELOG.md' };
const file = allowed[req.params.file];
if (!file) return res.status(404).json({ error: 'Not found' });
const filePath = path.join(__dirname, file);
if (fs.existsSync(filePath)) {
res.type('text/plain').send(fs.readFileSync(filePath, 'utf8'));
} else {
res.status(404).json({ error: 'File not found' });
}
});
// ============================================
// STATIC FILES & START
// ============================================
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
app.use(express.static(__dirname, {
extensions: ['html'],
dotfiles: 'deny'
}));
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
console.log(`Model: ${MODEL}`);
console.log(`API key loaded: ${!!API_KEY}`);
});