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:
82
server/routes/__tests__/config.test.js
Normal file
82
server/routes/__tests__/config.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
71
server/routes/__tests__/progress.test.js
Normal file
71
server/routes/__tests__/progress.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
278
server/routes/exams.js
Normal 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
272
server/routes/flashcards.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
108
server/routes/search.js
Normal 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;
|
||||
Reference in New Issue
Block a user