Files
studyos/server/routes/flashcards.js
renato97 4ff4302a8c 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)
2026-06-08 18:18:47 -03:00

273 lines
9.1 KiB
JavaScript

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;