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;