pediatric-ai-scribe-v3/src/routes/learningAdmin.js

423 lines
18 KiB
JavaScript

// ============================================================
// LEARNING ADMIN ROUTES — CMS for categories, content, quizzes
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
var { generateContentEmbedding, isEmbeddingsAvailable } = require('../utils/embeddings');
router.use(authMiddleware);
router.use(moderatorMiddleware);
// ── Helpers ──────────────────────────────────────────────────
function slugify(text) {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 100);
}
var ALLOWED_SLUG_TABLES = ['learning_categories', 'learning_content'];
async function uniqueSlug(table, base) {
if (ALLOWED_SLUG_TABLES.indexOf(table) === -1) throw new Error('Invalid table for slug generation');
var slug = slugify(base);
var exists = await db.get('SELECT id FROM ' + table + ' WHERE slug = ?', [slug]);
if (!exists) return slug;
var i = 2;
while (true) {
var attempt = slug + '-' + i;
var exists2 = await db.get('SELECT id FROM ' + table + ' WHERE slug = ?', [attempt]);
if (!exists2) return attempt;
i++;
if (i > 100) return slug + '-' + Date.now();
}
}
// ============================================================
// CATEGORIES — CRUD
// ============================================================
router.get('/categories', async function(req, res) {
try {
var categories = await db.all(
`SELECT c.*, (SELECT COUNT(*) FROM learning_content lc WHERE lc.category_id = c.id) as content_count
FROM learning_categories c ORDER BY c.sort_order ASC, c.name ASC`,
[]
);
res.json({ success: true, categories: categories });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.post('/categories', async function(req, res) {
try {
var { name, description, sort_order } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
var existing = await db.get('SELECT id, slug FROM learning_categories WHERE LOWER(name) = LOWER(?)', [name.trim()]);
if (existing) return res.json({ success: true, id: existing.id, slug: existing.slug, existing: true });
var slug = await uniqueSlug('learning_categories', name.trim());
var result = await db.run(
'INSERT INTO learning_categories (name, slug, description, sort_order) VALUES (?, ?, ?, ?)',
[name.trim(), slug, description || '', sort_order || 0]
);
res.json({ success: true, id: result.lastInsertRowid, slug: slug });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.put('/categories/:id', async function(req, res) {
try {
var { name, description, sort_order } = req.body;
var cat = await db.get('SELECT * FROM learning_categories WHERE id = ?', [req.params.id]);
if (!cat) return res.status(404).json({ error: 'Category not found' });
await db.run(
'UPDATE learning_categories SET name = ?, description = ?, sort_order = ? WHERE id = ?',
[name || cat.name, description !== undefined ? description : cat.description, sort_order !== undefined ? sort_order : cat.sort_order, cat.id]
);
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.delete('/categories/:id', async function(req, res) {
try {
// Set content in this category to uncategorized (NULL)
await db.run('UPDATE learning_content SET category_id = NULL WHERE category_id = ?', [req.params.id]);
await db.run('DELETE FROM learning_categories WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// CONTENT — CRUD
// ============================================================
router.get('/content', async function(req, res) {
try {
var content = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.published, c.category_id,
c.created_at, c.updated_at,
cat.name as category_name,
u.name as author_name,
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
LEFT JOIN users u ON c.author_id = u.id
ORDER BY c.updated_at DESC`,
[]
);
res.json({ success: true, content: content });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.get('/content/:id', async function(req, res) {
try {
var item = await db.get(
`SELECT c.*, cat.name as category_name
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
WHERE c.id = ?`,
[req.params.id]
);
if (!item) return res.status(404).json({ error: 'Content not found' });
// Get questions + options
var questions = await db.all(
'SELECT * FROM learning_questions WHERE content_id = ? ORDER BY sort_order ASC',
[item.id]
);
for (var i = 0; i < questions.length; i++) {
questions[i].options = await db.all(
'SELECT * FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC',
[questions[i].id]
);
}
item.questions = questions;
res.json({ success: true, content: item });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.post('/content', async function(req, res) {
try {
var { title, body, category_id, subject, content_type, published } = req.body;
if (!title || !title.trim()) return res.status(400).json({ error: 'Title required' });
var slug = await uniqueSlug('learning_content', title.trim());
var result = await db.run(
'INSERT INTO learning_content (title, slug, body, category_id, subject, content_type, published, author_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id]
);
var contentId = result.lastInsertRowid;
// Generate embedding asynchronously (don't block response)
if (isEmbeddingsAvailable() && body && body.trim()) {
generateContentEmbedding({ title: title.trim(), subject: subject || '', body: body })
.then(function(embedding) {
return db.query(
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
[JSON.stringify(embedding), contentId]
);
})
.then(function() { console.log('[Embeddings] Generated for content ID:', contentId); })
.catch(function(err) { console.error('[Embeddings] Failed for content ID ' + contentId + ':', err.message); });
}
res.json({ success: true, id: contentId, slug: slug });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.put('/content/:id', async function(req, res) {
try {
var item = await db.get('SELECT * FROM learning_content WHERE id = ?', [req.params.id]);
if (!item) return res.status(404).json({ error: 'Content not found' });
var { title, body, category_id, subject, content_type, published } = req.body;
var newTitle = title !== undefined ? title : item.title;
var newBody = body !== undefined ? body : item.body;
var newSubject = subject !== undefined ? subject : item.subject;
await db.run(
'UPDATE learning_content SET title = ?, body = ?, category_id = ?, subject = ?, content_type = ?, published = ?, updated_at = NOW() WHERE id = ?',
[
newTitle,
newBody,
category_id !== undefined ? (category_id || null) : item.category_id,
newSubject,
content_type !== undefined ? content_type : item.content_type,
published !== undefined ? published : item.published,
item.id
]
);
// Regenerate embedding if title/body/subject changed (async, don't block)
if (isEmbeddingsAvailable() && (title !== undefined || body !== undefined || subject !== undefined)) {
if (newBody && newBody.trim()) {
generateContentEmbedding({ title: newTitle, subject: newSubject, body: newBody })
.then(function(embedding) {
return db.query(
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
[JSON.stringify(embedding), item.id]
);
})
.then(function() { console.log('[Embeddings] Regenerated for content ID:', item.id); })
.catch(function(err) { console.error('[Embeddings] Failed to regenerate for content ID ' + item.id + ':', err.message); });
}
}
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.delete('/content/:id', async function(req, res) {
try {
await db.run('DELETE FROM learning_content WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// QUESTIONS — CRUD (nested under content)
// ============================================================
router.post('/content/:contentId/questions', async function(req, res) {
try {
var { question_text, question_type, explanation, options } = req.body;
if (!question_text) return res.status(400).json({ error: 'Question text required' });
// Get max sort_order
var maxOrder = await db.get('SELECT COALESCE(MAX(sort_order), -1) as mx FROM learning_questions WHERE content_id = ?', [req.params.contentId]);
var sortOrder = (maxOrder ? maxOrder.mx : -1) + 1;
var result = await db.run(
'INSERT INTO learning_questions (content_id, question_text, question_type, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
[req.params.contentId, question_text, question_type || 'mcq', explanation || '', sortOrder]
);
var questionId = result.lastInsertRowid;
// Insert options if provided
if (options && Array.isArray(options)) {
for (var i = 0; i < options.length; i++) {
var opt = options[i];
await db.run(
'INSERT INTO learning_options (question_id, option_text, is_correct, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
[questionId, opt.option_text, opt.is_correct ? true : false, opt.explanation || '', i]
);
}
}
res.json({ success: true, id: questionId });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.put('/questions/:id', async function(req, res) {
try {
var q = await db.get('SELECT * FROM learning_questions WHERE id = ?', [req.params.id]);
if (!q) return res.status(404).json({ error: 'Question not found' });
var { question_text, question_type, explanation, options } = req.body;
await db.run(
'UPDATE learning_questions SET question_text = ?, question_type = ?, explanation = ? WHERE id = ?',
[question_text || q.question_text, question_type || q.question_type, explanation !== undefined ? explanation : q.explanation, q.id]
);
// Replace options if provided
if (options && Array.isArray(options)) {
await db.run('DELETE FROM learning_options WHERE question_id = ?', [q.id]);
for (var i = 0; i < options.length; i++) {
var opt = options[i];
await db.run(
'INSERT INTO learning_options (question_id, option_text, is_correct, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
[q.id, opt.option_text, opt.is_correct ? true : false, opt.explanation || '', i]
);
}
}
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.delete('/questions/:id', async function(req, res) {
try {
await db.run('DELETE FROM learning_questions WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// STATS
// ============================================================
router.get('/stats', async function(req, res) {
try {
var totalContent = await db.get('SELECT COUNT(*) as count FROM learning_content', []);
var published = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE published = true', []);
var totalCategories = await db.get('SELECT COUNT(*) as count FROM learning_categories', []);
var totalQuizzes = await db.get("SELECT COUNT(DISTINCT content_id) as count FROM learning_questions", []);
var totalAttempts = await db.get('SELECT COUNT(*) as count FROM learning_progress', []);
var withEmbeddings = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL', []);
res.json({
success: true,
stats: {
totalContent: parseInt(totalContent.count),
publishedContent: parseInt(published.count),
totalCategories: parseInt(totalCategories.count),
totalQuizzes: parseInt(totalQuizzes.count),
totalAttempts: parseInt(totalAttempts.count),
withEmbeddings: parseInt(withEmbeddings.count),
embeddingsEnabled: isEmbeddingsAvailable()
}
});
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// EMBEDDINGS — Backfill & Management
// ============================================================
// Generate embeddings for all content (or just missing ones)
router.post('/embeddings/generate', async function(req, res) {
try {
if (!isEmbeddingsAvailable()) {
return res.status(400).json({ error: 'Embeddings not configured. Set LITELLM_API_BASE, VERTEX_PROJECT, or OPENAI_API_KEY' });
}
var { regenerateAll } = req.body;
// Get content without embeddings (or all if regenerateAll=true)
var whereClause = regenerateAll ? '' : 'WHERE embedding IS NULL';
var content = await db.all(
'SELECT id, title, subject, body FROM learning_content ' + whereClause + ' ORDER BY id ASC',
[]
);
if (content.length === 0) {
return res.json({ success: true, message: 'All content already has embeddings', processed: 0 });
}
// Process in background
var processed = 0;
var failed = 0;
console.log('[Embeddings] Starting batch generation for ' + content.length + ' items...');
// Don't await — run in background
async function generateEmbeddingsInBackground() {
for (var i = 0; i < content.length; i++) {
var item = content[i];
try {
if (!item.body || !item.body.trim()) {
console.log('[Embeddings] Skipping empty content ID:', item.id);
continue;
}
var embedding = await generateContentEmbedding(item);
await db.query(
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
[JSON.stringify(embedding), item.id]
);
processed++;
console.log('[Embeddings] Generated ' + processed + '/' + content.length + ' (ID: ' + item.id + ')');
} catch (err) {
failed++;
console.error('[Embeddings] Failed for content ID ' + item.id + ':', err.message);
}
}
console.log('[Embeddings] Batch complete: ' + processed + ' succeeded, ' + failed + ' failed');
// Create index if we have enough embeddings now
if (processed >= 10) {
try {
var lists = Math.max(10, Math.floor(Math.sqrt(processed)));
await db.query(
'CREATE INDEX IF NOT EXISTS idx_learning_content_embedding ON learning_content USING ivfflat (embedding vector_cosine_ops) WITH (lists = ' + lists + ')'
);
console.log('[Embeddings] Vector index created/updated');
} catch (e) {
console.error('[Embeddings] Index creation failed:', e.message);
}
}
}
generateEmbeddingsInBackground();
res.json({
success: true,
message: 'Embedding generation started in background',
total: content.length
});
} catch (err) {
console.error('[LearningAdmin]', err.message);
res.status(500).json({ error: 'Request failed' });
}
});
// Check embedding status
router.get('/embeddings/status', async function(req, res) {
try {
var total = await db.get('SELECT COUNT(*) as count FROM learning_content', []);
var withEmbeddings = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL', []);
var missing = parseInt(total.count) - parseInt(withEmbeddings.count);
res.json({
success: true,
enabled: isEmbeddingsAvailable(),
total: parseInt(total.count),
withEmbeddings: parseInt(withEmbeddings.count),
missing: missing,
model: process.env.EMBEDDING_MODEL || 'vertex_ai/text-embedding-005',
dimensions: parseInt(process.env.EMBEDDING_DIMENSIONS) || 768
});
} catch (err) {
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;