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)
273 lines
9.1 KiB
JavaScript
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;
|