const express = require('express'); const db = require('../db'); const { streamCompletion } = require('../lib/llm'); const router = express.Router(); // GET /api/exams — list all exams, optionally filter by topic router.get('/', (req, res) => { try { const topic = req.query.topic; let rows; if (topic) { rows = db.prepare('SELECT * FROM exams WHERE topics LIKE ? ORDER BY taken_at DESC').all(`%${topic}%`); } else { rows = db.prepare('SELECT * FROM exams ORDER BY taken_at DESC').all(); } const result = rows.map((r) => ({ ...r, topics: JSON.parse(r.topics || '[]'), })); res.json(result); } catch (err) { console.error('[exams] list error:', err.message); res.status(500).json({ error: err.message }); } }); // POST /api/exams — create a new exam router.post('/', (req, res) => { const { title, score, topics, taken_at } = req.body; if (!title || score === undefined) { return res.status(400).json({ error: 'title and score are required' }); } try { const topicsJson = JSON.stringify(topics || []); const takenAt = taken_at || new Date().toISOString(); const info = db.prepare( 'INSERT INTO exams (title, score, topics, taken_at) VALUES (?, ?, ?, ?)' ).run(title, score, topicsJson, takenAt); const row = db.prepare('SELECT * FROM exams WHERE id = ?').get(info.lastInsertRowid); res.status(201).json({ ...row, topics: JSON.parse(row.topics || '[]') }); } catch (err) { console.error('[exams] create error:', err.message); res.status(500).json({ error: err.message }); } }); // PUT /api/exams/:id — update an exam router.put('/:id', (req, res) => { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) { return res.status(400).json({ error: 'Invalid id' }); } const { title, score, topics, taken_at } = req.body; try { const existing = db.prepare('SELECT * FROM exams WHERE id = ?').get(id); if (!existing) { return res.status(404).json({ error: 'Exam not found' }); } const newTitle = title !== undefined ? title : existing.title; const newScore = score !== undefined ? score : existing.score; const newTopics = topics !== undefined ? JSON.stringify(topics) : existing.topics; const newTakenAt = taken_at !== undefined ? taken_at : existing.taken_at; db.prepare( 'UPDATE exams SET title = ?, score = ?, topics = ?, taken_at = ? WHERE id = ?' ).run(newTitle, newScore, newTopics, newTakenAt, id); const row = db.prepare('SELECT * FROM exams WHERE id = ?').get(id); res.json({ ...row, topics: JSON.parse(row.topics || '[]') }); } catch (err) { console.error('[exams] update error:', err.message); res.status(500).json({ error: err.message }); } }); // DELETE /api/exams/:id — delete an exam router.delete('/:id', (req, res) => { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) { return res.status(400).json({ error: 'Invalid id' }); } try { const info = db.prepare('DELETE FROM exams WHERE id = ?').run(id); if (info.changes === 0) { return res.status(404).json({ error: 'Exam not found' }); } res.json({ deleted: true }); } catch (err) { console.error('[exams] delete error:', err.message); res.status(500).json({ error: err.message }); } }); // GET /api/exams/:id — get a single exam router.get('/:id', (req, res) => { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) { return res.status(400).json({ error: 'Invalid id' }); } try { const row = db.prepare('SELECT * FROM exams WHERE id = ?').get(id); if (!row) { return res.status(404).json({ error: 'Exam not found' }); } res.json({ ...row, topics: JSON.parse(row.topics || '[]'), questions: row.questions ? JSON.parse(row.questions) : [], answers: row.answers ? JSON.parse(row.answers) : [], }); } catch (err) { console.error('[exams] get error:', err.message); res.status(500).json({ error: err.message }); } }); // POST /api/exams/:id/start — start an exam (set started_at and status) router.post('/:id/start', (req, res) => { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) { return res.status(400).json({ error: 'Invalid id' }); } try { const exam = db.prepare('SELECT * FROM exams WHERE id = ?').get(id); if (!exam) { return res.status(404).json({ error: 'Exam not found' }); } db.prepare("UPDATE exams SET started_at = datetime('now'), status = ? WHERE id = ?") .run('in_progress', id); const row = db.prepare('SELECT * FROM exams WHERE id = ?').get(id); res.json({ ...row, topics: JSON.parse(row.topics || '[]'), questions: row.questions ? JSON.parse(row.questions) : [], }); } catch (err) { console.error('[exams] start error:', err.message); res.status(500).json({ error: err.message }); } }); // POST /api/exams/generate — generate an exam via LLM router.post('/generate', async (req, res) => { const { conversation_id, topic, pdf_ids, num_questions = 5, duration_seconds = 600 } = req.body; if (!topic) { return res.status(400).json({ error: 'topic is required' }); } try { // Get model let model = null; if (conversation_id) { const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(conversation_id); if (conv && conv.model_id) { model = db.prepare('SELECT * FROM models WHERE id = ?').get(conv.model_id); } } if (!model) { model = db.prepare('SELECT * FROM models WHERE is_default_exam = 1 LIMIT 1').get(); } if (!model) { model = db.prepare('SELECT * FROM models WHERE is_default_main = 1 LIMIT 1').get(); } if (!model) { return res.status(500).json({ error: 'No model available for exam generation' }); } // Build prompt for exam generation const prompt = `Generá un examen simulado sobre "${topic}" con exactamente ${num_questions} preguntas. Respondé ÚNICAMENTE con un JSON array donde cada elemento tiene: { "q": "pregunta", "options": ["opción A", "opción B", "opción C", "opción D"], "answer": 0 } (answer es el índice correcto, 0-based). No incluyas texto adicional fuera del JSON.`; let jsonText = ''; for await (const chunk of streamCompletion(model, [{ role: 'user', content: prompt }], undefined)) { if (chunk.error) { return res.status(502).json({ error: chunk.error }); } if (chunk.done) { jsonText = chunk.fullText; } } // Extract JSON from response let questions = []; try { const fenceMatch = jsonText.match(/```json\s*([\s\S]*?)\s*```/); if (fenceMatch) { questions = JSON.parse(fenceMatch[1]); } else { questions = JSON.parse(jsonText); } } catch (parseErr) { console.error('[exams] JSON parse error:', parseErr.message, 'raw:', jsonText.slice(0, 500)); return res.status(502).json({ error: 'Failed to parse exam questions from model response' }); } if (!Array.isArray(questions) || questions.length === 0) { return res.status(502).json({ error: 'Model returned empty or invalid questions' }); } const startedAt = new Date().toISOString(); const info = db.prepare( 'INSERT INTO exams (title, score, topics, taken_at, questions, duration_seconds, started_at, status, conversation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run( `Examen: ${topic}`, 0, JSON.stringify([topic]), startedAt, JSON.stringify(questions), duration_seconds, startedAt, 'in_progress', conversation_id || null ); const row = db.prepare('SELECT * FROM exams WHERE id = ?').get(info.lastInsertRowid); res.status(201).json({ id: row.id, questions, started_at: startedAt, duration_seconds, status: 'in_progress', }); } catch (err) { console.error('[exams] generate error:', err.message); res.status(500).json({ error: err.message }); } }); // POST /api/exams/:id/submit — submit answers with grace period router.post('/:id/submit', (req, res) => { const id = parseInt(req.params.id, 10); if (Number.isNaN(id)) { return res.status(400).json({ error: 'Invalid id' }); } const { answers } = req.body; if (!Array.isArray(answers)) { return res.status(400).json({ error: 'answers array is required' }); } try { const exam = db.prepare('SELECT * FROM exams WHERE id = ?').get(id); if (!exam) { return res.status(404).json({ error: 'Exam not found' }); } if (exam.status === 'completed') { return res.status(400).json({ error: 'Exam already submitted' }); } const questions = JSON.parse(exam.questions || '[]'); const now = Date.now(); const startedAt = new Date(exam.started_at).getTime(); const durationMs = (exam.duration_seconds || 0) * 1000; const graceMs = 5000; if (durationMs > 0 && now > startedAt + durationMs + graceMs) { return res.status(410).json({ error: 'Exam time expired (grace period exceeded)' }); } // Score let correct = 0; for (let i = 0; i < Math.min(answers.length, questions.length); i++) { if (answers[i] === questions[i].answer) { correct++; } } const score = questions.length > 0 ? Math.round((correct / questions.length) * 100) : 0; db.prepare( 'UPDATE exams SET answers = ?, score = ?, status = ? WHERE id = ?' ).run(JSON.stringify(answers), score, 'completed', id); res.json({ id, score, status: 'completed', correct, total: questions.length }); } catch (err) { console.error('[exams] submit error:', err.message); res.status(500).json({ error: err.message }); } }); module.exports = router;