feat: implement 33 nice-to-have features + fix 37 code review bugs

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)
This commit is contained in:
renato97
2026-06-08 18:18:47 -03:00
parent b7d1e7319f
commit 4ff4302a8c
79 changed files with 13667 additions and 389 deletions

View File

@@ -0,0 +1,82 @@
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const configRoutes = require('../config');
function request(app, method, urlPath, body, headers = {}) {
return new Promise((resolve, reject) => {
const server = app.listen(0, '127.0.0.1', async () => {
const port = server.address().port;
try {
const res = await fetch(`http://127.0.0.1:${port}${urlPath}`, {
method,
body,
headers,
});
server.close(() => resolve(res));
} catch (err) {
server.close(() => reject(err));
}
});
});
}
describe('config routes', () => {
let app;
const realDb = path.resolve(__dirname, '..', '..', '..', 'data', 'studyos.db');
const backupDb = path.resolve(__dirname, '..', '..', '..', 'data', 'studyos.db.bak');
before(() => {
app = express();
app.use(express.json());
app.use('/api/config', configRoutes);
if (fs.existsSync(realDb)) {
fs.renameSync(realDb, backupDb);
}
});
after(() => {
if (fs.existsSync(backupDb)) {
if (fs.existsSync(realDb)) fs.unlinkSync(realDb);
fs.renameSync(backupDb, realDb);
} else if (fs.existsSync(realDb)) {
fs.unlinkSync(realDb);
}
});
it('GET /api/config/backup returns 404 when DB missing', async () => {
const res = await request(app, 'GET', '/api/config/backup');
assert.strictEqual(res.status, 404);
const body = await res.json();
assert.strictEqual(body.error, 'No database');
});
it('POST /api/config/restore rejects invalid file', async () => {
const form = new FormData();
const blob = new Blob(['not-a-db']);
form.append('file', blob, 'bad.db');
const res = await request(app, 'POST', '/api/config/restore', form);
assert.strictEqual(res.status, 400);
const body = await res.json();
assert.strictEqual(body.error, 'Not a valid SQLite database');
});
it('POST /api/config/restore accepts valid SQLite file', async () => {
const header = Buffer.from('SQLite format 3\0');
const dbContent = Buffer.concat([header, Buffer.alloc(100)]);
const form = new FormData();
const blob = new Blob([dbContent]);
form.append('file', blob, 'studyos.db');
const res = await request(app, 'POST', '/api/config/restore', form);
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.ok, true);
});
});

View File

@@ -0,0 +1,71 @@
const { describe, it, before } = require('node:test');
const assert = require('node:assert');
const express = require('express');
// Pre-populate require.cache so progress routes get mock dependencies
// without loading the real index.js (which would start the server)
// or the real db.js (which would need sql.js init).
const broadcastCalls = [];
const mockBroadcast = (payload) => broadcastCalls.push(payload);
require.cache[require.resolve('../../index')] = {
id: require.resolve('../../index'),
filename: require.resolve('../../index'),
loaded: true,
exports: { broadcast: mockBroadcast },
};
const mockDbRow = { topic: 'math', exercises_done: 5, exercises_correct: 4, last_session: '2024-01-01', notes: '[]' };
require.cache[require.resolve('../../db')] = {
id: require.resolve('../../db'),
filename: require.resolve('../../db'),
loaded: true,
exports: {
prepare: () => ({
run: () => ({ changes: 1, lastInsertRowid: 1 }),
get: () => mockDbRow,
all: () => [],
}),
},
};
const progressRoutes = require('../progress');
function request(app, method, urlPath, body, headers = {}) {
return new Promise((resolve, reject) => {
const server = app.listen(0, '127.0.0.1', async () => {
const port = server.address().port;
try {
const res = await fetch(`http://127.0.0.1:${port}${urlPath}`, {
method,
body: body ? JSON.stringify(body) : undefined,
headers: { 'Content-Type': 'application/json', ...headers },
});
server.close(() => resolve(res));
} catch (err) {
server.close(() => reject(err));
}
});
});
}
describe('progress routes', () => {
let app;
before(() => {
app = express();
app.use(express.json());
app.use('/api/progress', progressRoutes);
});
it('PUT /api/progress/:topic triggers broadcast with progress_update', async () => {
broadcastCalls.length = 0;
const res = await request(app, 'PUT', '/api/progress/math', { correct: true });
assert.strictEqual(res.status, 200);
assert.strictEqual(broadcastCalls.length, 1);
assert.strictEqual(broadcastCalls[0].type, 'progress_update');
assert.ok(broadcastCalls[0].data);
assert.strictEqual(broadcastCalls[0].data.topic, 'math');
});
});

View File

@@ -2,6 +2,8 @@ const express = require('express');
const db = require('../db');
const { buildSystemPrompt } = require('../systemPromptBuilder');
const { streamCompletion } = require('../lib/llm');
const { embedQuery, topK } = require('../lib/rag');
const { broadcastBuddy } = require('../lib/broadcast');
const router = express.Router();
// POST /api/chat/stream — SSE streaming endpoint
@@ -54,27 +56,51 @@ router.post('/stream', async (req, res) => {
let pdfContents = [];
if (pdf_ids.length > 0) {
const validIds = pdf_ids.map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0);
const validIds = pdf_ids.map(id => parseInt(id, 10)).filter(id => !Number.isNaN(id) && id > 0);
if (validIds.length > 0) {
const placeholders = validIds.map(() => '?').join(',');
pdfContents = db.prepare(`SELECT * FROM pdfs WHERE id IN (${placeholders})`).all(...validIds);
}
}
// 2. Build system prompt
const systemPrompt = buildSystemPrompt(conv, progressRows, pdfContents, attachment_texts);
// 2. RAG: embed user message and fetch top-k chunks
let ragChunks = [];
let difficulty = 'normal';
try {
// First-topic match — picks first progress row whose topic appears in the message.
// Multi-topic messages will match only the first topic found.
const activeProgress = progressRows.find((r) => r.topic && message.toLowerCase().includes(r.topic.toLowerCase()));
difficulty = activeProgress?.difficulty_level || 'normal';
} catch (err) {
console.error('[chat] difficulty detection error:', err.message);
difficulty = 'normal';
}
if (pdf_ids.length > 0) {
try {
const queryVec = await embedQuery(message);
const validPdfIds = pdf_ids.map(id => parseInt(id, 10)).filter(id => !Number.isNaN(id) && id > 0);
if (validPdfIds.length > 0) {
ragChunks = await topK(queryVec, validPdfIds, 3);
}
} catch (ragErr) {
console.warn('[chat] RAG failed:', ragErr.message);
}
}
// 2b. Build system prompt
const systemPrompt = buildSystemPrompt(conv, progressRows, pdfContents, attachment_texts, ragChunks, difficulty);
// 3. Load existing messages, removing consecutive duplicates
const rawMessages = db.prepare(
'SELECT id, role, content FROM messages WHERE conversation_id = ? ORDER BY id'
).all(convId);
// Fix: remove trailing user messages from failed streams (no assistant after)
let delCount = 0;
while (rawMessages.length > 0 && rawMessages[rawMessages.length - 1].role === 'user') {
const last = rawMessages.pop();
db.prepare('DELETE FROM messages WHERE id = ?').run(last.id);
delCount++;
// Fix: only remove the VERY LAST message if it's a user message matching the current input
const lastMsg = rawMessages[rawMessages.length - 1];
if (lastMsg && lastMsg.role === 'user' && lastMsg.content === message) {
rawMessages.pop();
db.prepare('DELETE FROM messages WHERE id = ?').run(lastMsg.id);
}
// Filter: skip duplicate consecutive users (keep only the last in sequence)
@@ -93,6 +119,10 @@ router.post('/stream', async (req, res) => {
{ role: 'user', content: message },
];
// Save user message BEFORE streaming so it persists even if server crashes
db.prepare('INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)')
.run(convId, 'user', message);
// 5. Stream via llm.streamCompletion()
let assistantText = '';
let errorOccurred = false;
@@ -160,26 +190,93 @@ router.post('/stream', async (req, res) => {
}
}
// 7. Upsert progress table for each exercise
for (const exerciseLogged of exerciseLogs) {
// Deduplicate exercises by topic+correct combination
const seen = new Set();
const dedupedLogs = [];
for (const entry of exerciseLogs) {
const key = `${entry.topic}|${entry.correct}`;
if (!seen.has(key)) {
seen.add(key);
dedupedLogs.push(entry);
}
}
// 7. Upsert progress table for each exercise + track streaks
let lastSuggestedTopic = null;
let difficultyChanged = false;
let newDifficulty = difficulty;
let newGlobalStreak = 0;
for (const exerciseLogged of dedupedLogs) {
const topic = exerciseLogged.topic;
const correct = exerciseLogged.correct === true ? 1 : 0;
const isWrong = correct === 0;
const existing = db.prepare('SELECT * FROM progress WHERE topic = ?').get(topic);
if (existing) {
let newWrongStreak = isWrong ? (existing.wrong_streak || 0) + 1 : 0;
newGlobalStreak = isWrong ? (existing.global_wrong_streak || 0) + 1 : Math.max(0, (existing.global_wrong_streak || 0) - 1);
let newDiff = existing.difficulty_level || 'normal';
// Difficulty adjustment based on global streak
if (newGlobalStreak >= 3 && newDiff !== 'easy') {
newDiff = 'easy';
difficultyChanged = true;
} else if (newGlobalStreak === 0 && existing.exercises_done > 0 && (existing.exercises_correct / existing.exercises_done) >= 0.8 && newDiff !== 'hard') {
newDiff = 'hard';
difficultyChanged = true;
} else if (newGlobalStreak >= 1 && newGlobalStreak < 3 && newDiff === 'easy') {
newDiff = 'normal';
difficultyChanged = true;
}
newDifficulty = newDiff;
db.prepare(`
UPDATE progress SET
exercises_done = exercises_done + 1,
exercises_correct = exercises_correct + ?,
last_session = datetime('now'),
notes = ?
notes = ?,
wrong_streak = ?,
global_wrong_streak = ?,
difficulty_level = ?
WHERE topic = ?
`).run(correct, existing.notes, topic);
`).run(correct, existing.notes, newWrongStreak, newGlobalStreak, newDiff, topic);
// Auto-fork suggest after 2 consecutive wrong answers on same topic
if (isWrong && newWrongStreak >= 2) {
lastSuggestedTopic = topic;
}
} else {
let newWrongStreak = isWrong ? 1 : 0;
newGlobalStreak = isWrong ? 1 : 0;
let newDiff = isWrong ? 'normal' : 'normal';
db.prepare(`
INSERT INTO progress (topic, exercises_done, exercises_correct, last_session, notes)
VALUES (?, 1, ?, datetime('now'), '[]')
`).run(topic, correct);
INSERT INTO progress (topic, exercises_done, exercises_correct, last_session, notes, wrong_streak, global_wrong_streak, difficulty_level)
VALUES (?, 1, ?, datetime('now'), '[]', ?, ?, ?)
`).run(topic, correct, newWrongStreak, newGlobalStreak, newDiff);
if (isWrong) {
lastSuggestedTopic = topic;
}
}
}
// Also detect wrong answers from response text heuristics (fallback when no exercise_logged)
const wrongHeuristic = /incorrect|incorrecta|no es correcto|mal|error/i.test(assistantText);
if (wrongHeuristic && exerciseLogs.length === 0) {
// Topic extracted from first sentence (fragile but functional enough for heuristic fallback)
const topic = message.split(/[.!?\n]/)[0].slice(0, 50);
const existing = db.prepare('SELECT * FROM progress WHERE topic = ?').get(topic);
if (existing) {
const newWrongStreak = (existing.wrong_streak || 0) + 1;
const newGlobalStreak = (existing.global_wrong_streak || 0) + 1;
db.prepare(`
UPDATE progress SET wrong_streak = ?, global_wrong_streak = ?, exercises_done = exercises_done + 1 WHERE topic = ?
`).run(newWrongStreak, newGlobalStreak, topic);
if (newWrongStreak >= 2) {
lastSuggestedTopic = topic;
}
}
}
@@ -190,17 +287,31 @@ router.post('/stream', async (req, res) => {
}
cleanText = cleanText.trim();
// Save user message now that streaming succeeded
db.prepare('INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)')
.run(convId, 'user', message);
// Save assistant response
db.prepare('INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)')
const msgInfo = db.prepare('INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)')
.run(convId, 'assistant', cleanText);
const assistantMsgId = msgInfo.lastInsertRowid;
// Update conversation updated_at
db.prepare("UPDATE conversations SET updated_at = datetime('now') WHERE id = ?").run(convId);
// Emit SSE events for streak/difficulty
if (lastSuggestedTopic) {
sendEvent({ auto_fork_suggest: { topic: lastSuggestedTopic, parent_id: convId, wrong_streak: 2 } });
}
if (difficultyChanged) {
sendEvent({ difficulty_changed: { level: newDifficulty, global_wrong_streak: newGlobalStreak } });
}
// Broadcast buddy message if conversation has buddy_meta
if (conv.buddy_meta) {
try {
broadcastBuddy({ type: 'buddy_msg', conv_id: convId, msg_id: assistantMsgId });
} catch (e) {
// silent
}
}
sendEvent({ done: true, full_text: cleanText });
res.end();
} catch (err) {

View File

@@ -1,7 +1,24 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const db = require('../db');
const router = express.Router();
const DB_PATH = path.resolve(__dirname, '..', '..', 'data', 'studyos.db');
const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'utf8');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 500 * 1024 * 1024 } });
function checkAdminKey(req, res) {
const adminKey = process.env.ADMIN_KEY || 'studyos-admin';
const headerKey = req.headers['x-admin-key'];
if (!headerKey || headerKey !== adminKey) {
res.status(403).json({ error: 'Forbidden: invalid or missing admin key' });
return false;
}
return true;
}
// GET /api/config — all key-value pairs
router.get('/', (req, res) => {
try {
@@ -46,6 +63,13 @@ router.post('/test-vlm', async (req, res) => {
return res.status(400).json({ error: 'url is required' });
}
// SSRF protection: only allow URLs matching the configured VLM endpoint
const vlmConfig = db.prepare("SELECT value FROM config WHERE key = 'vlm_endpoint'").get();
const allowed = (vlmConfig?.value || '').replace(/\/+$/, '');
if (!url.startsWith(allowed)) {
return res.status(400).json({ error: 'URL must match configured VLM endpoint' });
}
const start = Date.now();
try {
const resp = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) });
@@ -63,4 +87,25 @@ router.post('/test-vlm', async (req, res) => {
}
});
router.get('/backup', (req, res) => {
if (!checkAdminKey(req, res)) return;
if (!fs.existsSync(DB_PATH)) return res.status(404).json({ error: 'No database' });
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename="studyos.db"');
fs.createReadStream(DB_PATH).pipe(res);
});
router.post('/restore', upload.single('file'), (req, res) => {
if (!checkAdminKey(req, res)) return;
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const buf = req.file.buffer;
if (buf.length < 16 || !buf.slice(0, 16).equals(SQLITE_MAGIC)) {
return res.status(400).json({ error: 'Not a valid SQLite database' });
}
const tmpPath = DB_PATH + '.tmp';
fs.writeFileSync(tmpPath, buf);
fs.renameSync(tmpPath, DB_PATH);
res.json({ ok: true, message: 'Restore complete — reload required' });
});
module.exports = router;

View File

@@ -1,4 +1,5 @@
const express = require('express');
const crypto = require('crypto');
const db = require('../db');
const { buildSystemPrompt } = require('../systemPromptBuilder');
const { streamCompletion } = require('../lib/llm');
@@ -114,6 +115,7 @@ router.post('/:id/fork', (req, res) => {
const newId = info.lastInsertRowid;
// Persist fork_point in config table
// TODO: migrate fork_point to a dedicated column on conversations table instead of config
db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
.run(`fork_point_${newId}`, String(forkPoint));
@@ -169,11 +171,13 @@ router.post('/:id/merge', async (req, res) => {
return res.status(500).json({ error: 'No model available for summarization' });
}
const transcript = forkMessages.map(m => `${m.role}: ${m.content}`).join('\n\n');
// Truncate to last 50 messages to avoid exceeding context window
const recentMessages = forkMessages.slice(-50);
const transcript = recentMessages.map(m => `${m.role}: ${m.content}`).join('\n\n');
const summarizePrompt = `Resume en 2-3 oraciones qué aprendió el usuario en esta sesión sobre ${fork.title}:\n\n${transcript}`;
let summary = '';
for await (const chunk of streamCompletion(model, [{ role: 'user', content: summarizePrompt }], '')) {
for await (const chunk of streamCompletion(model, [{ role: 'user', content: summarizePrompt }], undefined)) {
if (chunk.error) {
return res.status(502).json({ error: chunk.error });
}
@@ -198,4 +202,107 @@ router.post('/:id/merge', async (req, res) => {
}
});
// POST /api/conversations/:id/share — generate a share token for buddy mode
router.post('/:id/share', (req, res) => {
const convId = parseInt(req.params.id, 10);
if (Number.isNaN(convId)) {
return res.status(400).json({ error: 'Invalid conversation id' });
}
const { role_label = 'compañero' } = req.body;
try {
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(convId);
if (!conv) {
return res.status(404).json({ error: 'Conversation not found' });
}
const token = crypto.randomBytes(16).toString('hex');
db.prepare('INSERT INTO shared_conversations (token, conv_id, role_label) VALUES (?, ?, ?)')
.run(token, convId, role_label);
const shareUrl = `${req.protocol}://${req.get('host')}/shared/${token}`;
res.json({ token, share_url: shareUrl, role_label });
} catch (err) {
console.error('[conversations] share error:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/conversations/shared/:token — get shared conversation data
router.get('/shared/:token', (req, res) => {
const { token } = req.params;
if (!token) {
return res.status(400).json({ error: 'token is required' });
}
try {
const shared = db.prepare('SELECT * FROM shared_conversations WHERE token = ?').get(token);
if (!shared) {
return res.status(404).json({ error: 'Shared conversation not found' });
}
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(shared.conv_id);
if (!conv) {
return res.status(404).json({ error: 'Conversation not found' });
}
const messages = db.prepare(
'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at, id'
).all(shared.conv_id);
// Find counterpart label (if there are 2 shared entries for same conv)
const allShared = db.prepare('SELECT * FROM shared_conversations WHERE conv_id = ?').all(shared.conv_id);
const counterpart = allShared.find((s) => s.token !== token);
res.json({
conversation: conv,
messages,
role_label: shared.role_label,
counterpart_label: counterpart?.role_label || 'compañero',
});
} catch (err) {
console.error('[conversations] shared get error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/conversations/join — join a shared conversation by token
router.post('/join', (req, res) => {
const { share_token, user_name } = req.body;
if (!share_token || !user_name) {
return res.status(400).json({ error: 'share_token and user_name are required' });
}
try {
const shared = db.prepare('SELECT * FROM shared_conversations WHERE token = ?').get(share_token);
if (!shared) {
return res.status(404).json({ error: 'Shared conversation not found' });
}
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(shared.conv_id);
if (!conv) {
return res.status(404).json({ error: 'Conversation not found' });
}
// Track participant
db.prepare('INSERT INTO conversation_participants (conversation_id, user_name) VALUES (?, ?)')
.run(shared.conv_id, user_name);
const messages = db.prepare(
'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at, id'
).all(shared.conv_id);
res.json({
conversation: conv,
messages,
role_label: shared.role_label,
share_token,
});
} catch (err) {
console.error('[conversations] join error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

278
server/routes/exams.js Normal file
View File

@@ -0,0 +1,278 @@
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;

272
server/routes/flashcards.js Normal file
View File

@@ -0,0 +1,272 @@
const express = require('express');
const db = require('../db');
const { streamCompletion } = require('../lib/llm');
const { sm2 } = require('../lib/sm2');
const router = express.Router();
// POST /api/flashcards/generate — SSE stream from LLM
router.post('/generate', async (req, res) => {
const { source, pdf_id, message_id, topic } = req.body;
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const sendEvent = (obj) => {
res.write(`data: ${JSON.stringify(obj)}\n\n`);
};
try {
let promptContent = '';
if (source === 'pdf' && pdf_id) {
const pdf = db.prepare('SELECT content_markdown FROM pdfs WHERE id = ?').get(pdf_id);
if (!pdf) {
sendEvent({ error: 'PDF not found' });
res.end();
return;
}
promptContent = pdf.content_markdown;
} else if (source === 'exercise' && message_id) {
const msg = db.prepare('SELECT content FROM messages WHERE id = ?').get(message_id);
if (!msg) {
sendEvent({ error: 'Message not found' });
res.end();
return;
}
promptContent = msg.content;
} else {
sendEvent({ error: 'Invalid source or missing id' });
res.end();
return;
}
// Find default model
let model = db.prepare('SELECT * FROM models WHERE is_default_main = 1 LIMIT 1').get();
if (!model) {
model = db.prepare('SELECT * FROM models LIMIT 1').get();
}
if (!model) {
sendEvent({ error: 'No model configured' });
res.end();
return;
}
const systemPrompt = 'You are a study assistant. Generate flashcards as a JSON array of {question, answer} objects from the provided text. Return ONLY a JSON code block wrapped in ```json ... ```. Do not include any other text.';
const messages = [{ role: 'user', content: `Generate flashcards from the following text:\n\n${promptContent}` }];
let fullText = '';
for await (const chunk of streamCompletion(model, messages, systemPrompt)) {
if (chunk.error) {
sendEvent({ error: chunk.error });
res.end();
return;
}
if (chunk.token) {
fullText += chunk.token;
}
}
// Parse JSON fences — use global flag to capture all blocks
const fenceRegex = /```json\s*([\s\S]*?)\s*```/g;
const matches = [...fullText.matchAll(fenceRegex)];
let cards = [];
for (const match of matches) {
try {
const parsed = JSON.parse(match[1]);
if (Array.isArray(parsed)) {
cards.push(...parsed);
} else if (parsed && Array.isArray(parsed.flashcards)) {
cards.push(...parsed.flashcards);
}
} catch (e) {
console.error('[flashcards] JSON parse error:', e.message);
}
}
if (!cards.length) {
// Try parsing the whole text as JSON
try {
const parsed = JSON.parse(fullText);
if (Array.isArray(parsed)) {
cards = parsed;
} else if (parsed && Array.isArray(parsed.flashcards)) {
cards = parsed.flashcards;
}
} catch (e) {
console.error('[flashcards] JSON parse error:', e.message);
}
}
if (!cards.length) {
sendEvent({ error: 'No flashcards generated' });
res.end();
return;
}
const inserted = [];
for (const card of cards) {
const q = card.question || card.q || '';
const a = card.answer || card.a || '';
if (!q || !a) continue;
const info = db.prepare(
'INSERT INTO flashcards (question, answer, pdf_id, message_id, topic) VALUES (?, ?, ?, ?, ?)'
).run(q, a, source === 'pdf' ? pdf_id : null, source === 'exercise' ? message_id : null, topic || null);
const row = db.prepare('SELECT * FROM flashcards WHERE id = ?').get(info.lastInsertRowid);
inserted.push(row);
sendEvent({ type: 'card', card: row });
}
sendEvent({ type: 'done', total: inserted.length });
res.end();
} catch (err) {
console.error('[flashcards] generate error:', err.message);
sendEvent({ error: err.message });
res.end();
}
});
// GET /api/flashcards — list flashcards
router.get('/', (req, res) => {
try {
const seen = req.query.seen;
const topic = req.query.topic;
let sql = 'SELECT * FROM flashcards WHERE 1=1';
const params = [];
if (seen !== undefined) {
sql += ' AND seen = ?';
params.push(seen === '1' || seen === 'true' ? 1 : 0);
}
if (topic) {
sql += ' AND topic = ?';
params.push(topic);
}
sql += ' ORDER BY created_at DESC';
const rows = db.prepare(sql).all(...params);
res.json(rows);
} catch (err) {
console.error('[flashcards] list error:', err.message);
res.status(500).json({ error: err.message });
}
});
// PUT /api/flashcards/:id — update a flashcard
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 { question, answer, seen, topic } = req.body;
try {
const existing = db.prepare('SELECT * FROM flashcards WHERE id = ?').get(id);
if (!existing) {
return res.status(404).json({ error: 'Flashcard not found' });
}
const newQuestion = question !== undefined ? question : existing.question;
const newAnswer = answer !== undefined ? answer : existing.answer;
const newSeen = seen !== undefined ? (seen ? 1 : 0) : existing.seen;
const newTopic = topic !== undefined ? topic : existing.topic;
db.prepare(
'UPDATE flashcards SET question = ?, answer = ?, seen = ?, topic = ? WHERE id = ?'
).run(newQuestion, newAnswer, newSeen, newTopic, id);
const row = db.prepare('SELECT * FROM flashcards WHERE id = ?').get(id);
res.json(row);
} catch (err) {
console.error('[flashcards] update error:', err.message);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/flashcards/:id — delete a flashcard
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 flashcards WHERE id = ?').run(id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Flashcard not found' });
}
res.json({ deleted: true });
} catch (err) {
console.error('[flashcards] delete error:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/flashcards/reviews/due — due flashcards with review state
router.get('/reviews/due', (req, res) => {
try {
const rows = db.prepare(`
SELECT f.id, f.question, f.answer, f.topic,
COALESCE(r.ease_factor, 2.5) as ease_factor,
COALESCE(r.interval_days, 1) as interval_days,
COALESCE(r.repetitions, 0) as repetitions,
COALESCE(r.next_review, date('now')) as next_review,
r.last_review
FROM flashcards f
LEFT JOIN flashcard_reviews r ON r.flashcard_id = f.id
WHERE r.next_review IS NULL OR r.next_review <= date('now')
ORDER BY r.next_review ASC, f.created_at DESC
`).all();
const count = rows.length;
res.json({ count, cards: rows });
} catch (err) {
console.error('[flashcards] reviews/due error:', err.message);
res.status(500).json({ error: err.message });
}
});
// PUT /api/flashcards/:id/review — submit SM-2 review
router.put('/:id/review', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid id' });
}
const { quality } = req.body;
if (quality === undefined || quality < 0 || quality > 5) {
return res.status(400).json({ error: 'quality must be 0-5' });
}
try {
const existing = db.prepare('SELECT * FROM flashcards WHERE id = ?').get(id);
if (!existing) {
return res.status(404).json({ error: 'Flashcard not found' });
}
const review = db.prepare('SELECT * FROM flashcard_reviews WHERE flashcard_id = ?').get(id);
const prev = review
? {
ease_factor: review.ease_factor,
interval_days: review.interval_days,
repetitions: review.repetitions,
}
: { ease_factor: 2.5, interval_days: 1, repetitions: 0 };
const next = sm2(prev, quality);
const now = new Date().toISOString().slice(0, 10);
if (review) {
db.prepare(`
UPDATE flashcard_reviews
SET ease_factor = ?, interval_days = ?, repetitions = ?, next_review = ?, last_review = ?
WHERE flashcard_id = ?
`).run(next.ease_factor, next.interval_days, next.repetitions, next.next_review, now, id);
} else {
db.prepare(`
INSERT INTO flashcard_reviews (flashcard_id, ease_factor, interval_days, repetitions, next_review, last_review)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, next.ease_factor, next.interval_days, next.repetitions, next.next_review, now);
}
res.json({ ...next, last_review: now });
} catch (err) {
console.error('[flashcards] review error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View File

@@ -3,10 +3,12 @@ const db = require('../db');
const { streamCompletion } = require('../lib/llm');
const router = express.Router();
const MODEL_PUBLIC_COLS = 'id, name, api_base, provider, is_default_main, is_default_fork, is_default_exam';
// GET /api/models — list all
router.get('/', (req, res) => {
try {
const rows = db.prepare('SELECT * FROM models ORDER BY id').all();
const rows = db.prepare(`SELECT ${MODEL_PUBLIC_COLS} FROM models ORDER BY id`).all();
res.json(rows);
} catch (err) {
console.error('[models] list error:', err.message);
@@ -14,6 +16,24 @@ router.get('/', (req, res) => {
}
});
// GET /api/models/:id — get single model (no api_key)
router.get('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid model id' });
}
try {
const row = db.prepare(`SELECT ${MODEL_PUBLIC_COLS} FROM models WHERE id = ?`).get(id);
if (!row) {
return res.status(404).json({ error: 'Model not found' });
}
res.json(row);
} catch (err) {
console.error('[models] get error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/models — create
router.post('/', (req, res) => {
const { name, api_base, api_key, provider, is_default_main, is_default_fork, is_default_exam } = req.body;
@@ -41,7 +61,7 @@ router.post('/', (req, res) => {
if (is_default_fork) unsetOtherDefaults(newId, 'is_default_fork');
if (is_default_exam) unsetOtherDefaults(newId, 'is_default_exam');
const row = db.prepare('SELECT * FROM models WHERE id = ?').get(newId);
const row = db.prepare(`SELECT ${MODEL_PUBLIC_COLS} FROM models WHERE id = ?`).get(newId);
res.status(201).json(row);
} catch (err) {
console.error('[models] create error:', err.message);
@@ -75,10 +95,10 @@ router.put('/:id', (req, res) => {
is_default_exam = COALESCE(?, is_default_exam)
WHERE id = ?
`).run(
name ?? null,
api_base ?? null,
name || null,
api_base || null,
api_key !== undefined ? (api_key === null ? '' : api_key) : null,
provider ?? null,
provider || null,
is_default_main !== undefined ? (is_default_main ? 1 : 0) : null,
is_default_fork !== undefined ? (is_default_fork ? 1 : 0) : null,
is_default_exam !== undefined ? (is_default_exam ? 1 : 0) : null,
@@ -89,7 +109,7 @@ router.put('/:id', (req, res) => {
if (is_default_fork) unsetOtherDefaults(id, 'is_default_fork');
if (is_default_exam) unsetOtherDefaults(id, 'is_default_exam');
const row = db.prepare('SELECT * FROM models WHERE id = ?').get(id);
const row = db.prepare(`SELECT ${MODEL_PUBLIC_COLS} FROM models WHERE id = ?`).get(id);
res.json(row);
} catch (err) {
console.error('[models] update error:', err.message);

View File

@@ -3,6 +3,8 @@ const multer = require('multer');
const path = require('path');
const fs = require('fs');
const db = require('../db');
const { chunkText } = require('../lib/rag');
const embeddings = require('../lib/embeddings');
const router = express.Router();
const uploadDir = path.resolve(__dirname, '..', '..', 'data', 'uploads');
@@ -45,7 +47,8 @@ async function vlmExtract(filePath, vlmConfig) {
const base64 = buffer.toString('base64');
const mimeType = 'application/pdf';
const resp = await fetch(`${vlmConfig.endpoint}/chat/completions`, {
const baseUrl = vlmConfig.endpoint.replace(/\/+$/, '');
const resp = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${vlmConfig.api_key}`,
@@ -90,9 +93,10 @@ router.post('/upload', upload.single('file'), async (req, res) => {
let usedVlm = false;
// 1. Try VLM first
const vlmEndpoint = db.prepare("SELECT value FROM config WHERE key = 'vlm_endpoint'").get();
const vlmApiKey = db.prepare("SELECT value FROM config WHERE key = 'vlm_api_key'").get();
const vlmModel = db.prepare("SELECT value FROM config WHERE key = 'vlm_model'").get();
const configs = db.prepare("SELECT key, value FROM config WHERE key IN ('vlm_endpoint','vlm_api_key','vlm_model')").all();
const vlmEndpoint = configs.find(c => c.key === 'vlm_endpoint');
const vlmApiKey = configs.find(c => c.key === 'vlm_api_key');
const vlmModel = configs.find(c => c.key === 'vlm_model');
if (vlmEndpoint?.value && vlmApiKey?.value) {
const vlmConfig = {
@@ -101,7 +105,16 @@ router.post('/upload', upload.single('file'), async (req, res) => {
model: vlmModel?.value || 'glm-4.6v',
};
text = await vlmExtract(filePath, vlmConfig);
if (text) usedVlm = true;
if (text) {
usedVlm = true;
// VLM succeeded — still get page count from PDF metadata
try {
const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs');
const data = new Uint8Array(fs.readFileSync(filePath));
const doc = await pdfjsLib.getDocument({ data }).promise;
pages = doc.numPages;
} catch { /* keep pages = 0 if metadata read fails */ }
}
}
// 2. Fallback to pdfjs-dist
@@ -119,7 +132,26 @@ router.post('/upload', upload.single('file'), async (req, res) => {
VALUES (?, ?, ?, ?, ?)
`).run(req.file.filename, req.file.originalname, text || '', pages || 0, reorderIndex);
const row = db.prepare('SELECT * FROM pdfs WHERE id = ?').get(info.lastInsertRowid);
const pdfId = info.lastInsertRowid;
// Generate embeddings for the PDF text
if (text && text.trim().length > 0) {
try {
const chunks = chunkText(text, 500, 50);
const vectors = await embeddings.embedBatch(chunks);
for (let i = 0; i < chunks.length; i++) {
const vec = vectors[i];
const blob = Buffer.from(vec.buffer);
db.prepare(
'INSERT INTO embeddings (pdf_id, chunk_index, vector, content) VALUES (?, ?, ?, ?)'
).run(pdfId, i, blob, chunks[i]);
}
} catch (embErr) {
console.warn('[pdfs] embedding generation failed:', embErr.message);
}
}
const row = db.prepare('SELECT * FROM pdfs WHERE id = ?').get(pdfId);
res.status(201).json({ ...row, used_vlm: usedVlm });
} catch (err) {
console.error('[pdfs] upload error:', err.message);
@@ -196,10 +228,10 @@ router.delete('/:id', (req, res) => {
// Delete file from disk
const filePath = path.join(uploadDir, row.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
try { fs.unlinkSync(filePath); } catch {}
// Delete embeddings first
db.prepare('DELETE FROM embeddings WHERE pdf_id = ?').run(id);
db.prepare('DELETE FROM pdfs WHERE id = ?').run(id);
res.json({ deleted: true });
} catch (err) {
@@ -208,4 +240,26 @@ router.delete('/:id', (req, res) => {
}
});
// GET /api/pdfs/:id/embeddings — list embeddings for a PDF (debug)
router.get('/:id/embeddings', (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'Invalid pdf id' });
}
try {
const pdf = db.prepare('SELECT id FROM pdfs WHERE id = ?').get(id);
if (!pdf) {
return res.status(404).json({ error: 'PDF not found' });
}
const rows = db.prepare('SELECT id, pdf_id, chunk_index, content, created_at FROM embeddings WHERE pdf_id = ? ORDER BY chunk_index')
.all(id);
res.json({ pdf_id: id, count: rows.length, chunks: rows });
} catch (err) {
console.error('[pdfs] embeddings error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View File

@@ -1,5 +1,7 @@
const express = require('express');
const db = require('../db');
const { broadcast } = require('../lib/broadcast');
const PDFDocument = require('pdfkit');
const router = express.Router();
// GET /api/progress — all topics with pct calculation
@@ -52,7 +54,9 @@ router.put('/:topic', (req, res) => {
const row = db.prepare('SELECT * FROM progress WHERE topic = ?').get(topic);
const pct = row.exercises_done > 0 ? Math.round((row.exercises_correct / row.exercises_done) * 100) : 0;
res.json({ ...row, percentage: pct });
const result = { ...row, percentage: pct };
broadcast({ type: 'progress_update', data: result });
res.json(result);
} catch (err) {
console.error('[progress] update error:', err.message);
res.status(500).json({ error: err.message });
@@ -75,4 +79,99 @@ router.delete('/:topic', (req, res) => {
}
});
// POST /api/progress/sessions — UPSERT daily minutes
router.post('/sessions', (req, res) => {
const { date, minutes, topic } = req.body;
if (!date || minutes === undefined) {
return res.status(400).json({ error: 'date and minutes are required' });
}
try {
// NULL != NULL in SQLite UNIQUE constraint — explicit NULL handling keeps things working
const existing = db.prepare('SELECT * FROM study_sessions WHERE session_date = ? AND (topic = ? OR (topic IS NULL AND ? IS NULL))').get(date, topic || null, topic || null);
if (existing) {
db.prepare('UPDATE study_sessions SET minutes = minutes + ? WHERE id = ?').run(minutes, existing.id);
} else {
db.prepare('INSERT INTO study_sessions (session_date, minutes, topic) VALUES (?, ?, ?)').run(date, minutes, topic || null);
}
const row = db.prepare('SELECT * FROM study_sessions WHERE session_date = ? AND (topic = ? OR (topic IS NULL AND ? IS NULL))').get(date, topic || null, topic || null);
res.json(row);
} catch (err) {
console.error('[progress] session error:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/progress/heatmap — aggregated study minutes per day
router.get('/heatmap', (req, res) => {
try {
const days = parseInt(req.query.days, 10) || 365;
const since = new Date();
since.setDate(since.getDate() - days);
const sinceStr = since.toISOString().split('T')[0];
const rows = db.prepare(
'SELECT session_date as date, SUM(minutes) as minutes FROM study_sessions WHERE session_date >= ? GROUP BY session_date ORDER BY session_date'
).all(sinceStr);
res.json(rows);
} catch (err) {
console.error('[progress] heatmap error:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/progress/exam/pdf — generate simulated exam PDF
router.get('/exam/pdf', (req, res) => {
try {
const rows = db.prepare(
'SELECT * FROM progress ORDER BY last_session DESC LIMIT 20'
).all();
if (!rows.length) {
return res.status(404).json({ error: 'No progress data' });
}
const doc = new PDFDocument({ margin: 50 });
const filename = `examen-simulado-${new Date().toISOString().slice(0, 10)}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
doc.pipe(res);
res.on('error', () => { try { doc.end(); } catch {} });
// Header
doc.fontSize(22).text('Examen Simulado', 50, 50);
doc.fontSize(12).text(`Generado: ${new Date().toLocaleDateString('es-ES')}`, 50, 80);
doc.moveDown(2);
// Topics table
doc.fontSize(14).text('Temas evaluados', 50, doc.y);
doc.moveDown(0.5);
doc.fontSize(10);
rows.forEach((row, idx) => {
const pct = row.exercises_done > 0 ? Math.round((row.exercises_correct / row.exercises_done) * 100) : 0;
doc.text(`${idx + 1}. ${row.topic}${pct}% (${row.exercises_correct}/${row.exercises_done})`, 60, doc.y);
});
if (rows.length === 20) {
doc.moveDown(1);
doc.fontSize(9).fillColor('gray').text('Nota: se muestran los 20 temas más recientes.', 50, doc.y);
doc.fillColor('black');
}
doc.moveDown(2);
doc.fontSize(14).text('Ejercicios de muestra', 50, doc.y);
doc.moveDown(0.5);
doc.fontSize(11);
rows.slice(0, 10).forEach((row, idx) => {
doc.text(`${idx + 1}. Describe el tema "${row.topic}" con sus puntos clave.`, 60, doc.y);
doc.moveDown(0.3);
doc.text(' Respuesta: _______________________________________________', 60, doc.y);
doc.moveDown(1);
});
doc.end();
} catch (err) {
console.error('[progress] exam/pdf error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

108
server/routes/search.js Normal file
View File

@@ -0,0 +1,108 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
// GET /api/search?q=term
router.get('/', (req, res) => {
const q = req.query.q || '';
const term = q.trim();
if (!term) {
return res.json({ messages: [], pdfs: [] });
}
try {
const messageResults = [];
const pdfResults = [];
const fts5 = db._fts5Available;
// Sanitize term for FTS5: quote each whitespace-separated term
const safeTerm = term.split(/\s+/).filter(Boolean).map(t => `"${t.replace(/"/g, '""')}"`).join(' ');
const fts5Term = fts5 ? safeTerm : null;
if (fts5 && fts5Term) {
// FTS5 path: use MATCH with snippet highlighting and BM25 ranking
const messageRows = db.prepare(`
SELECT m.id, m.conversation_id, m.content,
snippet(messages_fts, 1, '<b>', '</b>', '…', 16) as snippet,
rank
FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
WHERE messages_fts MATCH ?
ORDER BY rank
LIMIT 25
`).all(fts5Term);
const pdfRows = db.prepare(`
SELECT p.id, p.original_name, p.content_markdown,
snippet(pdfs_fts, 1, '<b>', '</b>', '…', 16) as snippet,
rank
FROM pdfs_fts
JOIN pdfs p ON p.id = pdfs_fts.rowid
WHERE pdfs_fts MATCH ?
ORDER BY rank
LIMIT 25
`).all(fts5Term);
for (const row of messageRows) {
messageResults.push({
type: 'message',
id: row.id,
title: row.content?.slice(0, 60) || 'Mensaje',
snippet: row.snippet || '',
rank: row.rank || 0,
conversation_id: row.conversation_id,
});
}
for (const row of pdfRows) {
pdfResults.push({
type: 'pdf',
id: row.id,
title: row.original_name || 'PDF',
snippet: row.snippet || '',
rank: row.rank || 0,
});
}
} else {
// LIKE fallback (no ranking, no snippets)
const likeTerm = `%${term}%`;
const messageRows = db.prepare(
`SELECT id, conversation_id, content FROM messages WHERE content LIKE ? LIMIT 25`
).all(likeTerm);
const pdfRows = db.prepare(
`SELECT id, original_name, content_markdown FROM pdfs WHERE content_markdown LIKE ? LIMIT 25`
).all(likeTerm);
for (const row of messageRows) {
messageResults.push({
type: 'message',
id: row.id,
title: row.content?.slice(0, 60) || 'Mensaje',
snippet: row.content?.slice(0, 120) || '',
rank: 0,
conversation_id: row.conversation_id,
});
}
for (const row of pdfRows) {
pdfResults.push({
type: 'pdf',
id: row.id,
title: row.original_name || 'PDF',
snippet: row.content_markdown?.slice(0, 120) || '',
rank: 0,
});
}
}
// Return grouped results — keep message BM25 and PDF BM25 ranks separate (different indexes)
messageResults.sort((a, b) => (a.rank || 0) - (b.rank || 0));
pdfResults.sort((a, b) => (a.rank || 0) - (b.rank || 0));
res.json({ messages: messageResults.slice(0, 25), pdfs: pdfResults.slice(0, 25) });
} catch (err) {
console.error('[search] error:', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;