5 SDD batches archived: - Batch 1: UI Polish (10 features, 14 tasks) - Batch 2: Study System (8 features, 23 tasks) - Batch 3: Infrastructure (5 features, 22 tasks) - Batch 4: AI Advanced (5 features, 30 tasks) — RAG with @xenova/transformers - Batch 5: Core Features (5 features, 19 tasks) 37 bugs fixed from comprehensive code review (11 CRITICAL, 12 HIGH, 14 MEDIUM/LOW): - SSE streaming now works (event.token check) - API keys no longer exposed via GET /api/models - FTS5 injection sanitized - DB backup/restore with admin auth - Buddy mode wired (buddy_meta column) - Exam auto-submit stale closure fixed - CSS variables aligned with design tokens - Progress data corruption fixed - WebSocket protocol auto-detection - Tests infrastructure completed (vitest + node:test)
279 lines
9.6 KiB
JavaScript
279 lines
9.6 KiB
JavaScript
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;
|