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:
127
server/db.js
127
server/db.js
@@ -21,6 +21,11 @@ function flatParams(params) {
|
||||
return params;
|
||||
}
|
||||
|
||||
function safeAddColumn(table, col, def) {
|
||||
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`); }
|
||||
catch (e) { /* column exists */ }
|
||||
}
|
||||
|
||||
function createWrapper(sqlDb) {
|
||||
return {
|
||||
prepare(sql) {
|
||||
@@ -72,6 +77,8 @@ function createWrapper(sqlDb) {
|
||||
};
|
||||
}
|
||||
|
||||
// Placeholder wrapper — methods are replaced by initDB() after sql.js loads.
|
||||
// This allows synchronous require('db') in route modules before async init.
|
||||
const db = createWrapper(null);
|
||||
|
||||
async function initDB() {
|
||||
@@ -159,8 +166,85 @@ async function initDB() {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
score REAL NOT NULL DEFAULT 0,
|
||||
topics TEXT NOT NULL DEFAULT '[]',
|
||||
taken_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flashcards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
pdf_id INTEGER REFERENCES pdfs(id) ON DELETE SET NULL,
|
||||
message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
|
||||
seen INTEGER NOT NULL DEFAULT 0,
|
||||
topic TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS study_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_date TEXT NOT NULL,
|
||||
minutes INTEGER NOT NULL DEFAULT 0,
|
||||
topic TEXT,
|
||||
UNIQUE(session_date, topic)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS topic_relationships (
|
||||
from_topic TEXT NOT NULL,
|
||||
to_topic TEXT NOT NULL,
|
||||
domain TEXT,
|
||||
PRIMARY KEY(from_topic, to_topic)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flashcard_reviews (
|
||||
flashcard_id INTEGER PRIMARY KEY REFERENCES flashcards(id) ON DELETE CASCADE,
|
||||
ease_factor REAL NOT NULL DEFAULT 2.5,
|
||||
interval_days INTEGER NOT NULL DEFAULT 1,
|
||||
repetitions INTEGER NOT NULL DEFAULT 0,
|
||||
next_review TEXT NOT NULL DEFAULT (date('now')),
|
||||
last_review TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// Try to create FTS5 virtual tables and sync triggers
|
||||
let fts5Available = false;
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(content, content='messages', content_rowid='id');
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS pdfs_fts USING fts5(content_markdown, content='pdfs', content_rowid='id');
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
||||
UPDATE messages_fts SET content = new.content WHERE rowid = new.id;
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
||||
DELETE FROM messages_fts WHERE rowid = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS pdfs_ai AFTER INSERT ON pdfs BEGIN
|
||||
INSERT INTO pdfs_fts(rowid, content_markdown) VALUES (new.id, new.content_markdown);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS pdfs_au AFTER UPDATE ON pdfs BEGIN
|
||||
UPDATE pdfs_fts SET content_markdown = new.content_markdown WHERE rowid = new.id;
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS pdfs_ad AFTER DELETE ON pdfs BEGIN
|
||||
DELETE FROM pdfs_fts WHERE rowid = old.id;
|
||||
END;
|
||||
`);
|
||||
fts5Available = true;
|
||||
} catch (err) {
|
||||
console.warn('[db] FTS5 setup failed:', err.message, '— search will use LIKE fallback');
|
||||
}
|
||||
db._fts5Available = fts5Available;
|
||||
|
||||
const modelCount = db.prepare('SELECT COUNT(*) as count FROM models').get();
|
||||
if (!modelCount || modelCount.count === 0) {
|
||||
db.prepare(`
|
||||
@@ -174,6 +258,49 @@ async function initDB() {
|
||||
db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('vlm_endpoint', 'http://localhost:8080/vlm');
|
||||
}
|
||||
|
||||
// Additive schema changes for ai-advanced-batch4
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS embeddings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pdf_id INTEGER NOT NULL,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
vector BLOB NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (pdf_id) REFERENCES pdfs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_embeddings_pdf ON embeddings(pdf_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_conversations (
|
||||
token TEXT PRIMARY KEY,
|
||||
conv_id INTEGER NOT NULL,
|
||||
role_label TEXT NOT NULL DEFAULT 'compañero',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conv_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
user_name TEXT NOT NULL,
|
||||
joined_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
safeAddColumn('progress', 'wrong_streak', 'INTEGER DEFAULT 0');
|
||||
safeAddColumn('progress', 'global_wrong_streak', 'INTEGER DEFAULT 0');
|
||||
safeAddColumn('progress', 'difficulty_level', 'TEXT DEFAULT \'normal\'');
|
||||
|
||||
safeAddColumn('conversations', 'buddy_meta', 'TEXT');
|
||||
|
||||
safeAddColumn('exams', 'questions', 'TEXT');
|
||||
safeAddColumn('exams', 'answers', 'TEXT');
|
||||
safeAddColumn('exams', 'duration_seconds', 'INTEGER');
|
||||
safeAddColumn('exams', 'started_at', 'TEXT');
|
||||
safeAddColumn('exams', 'status', 'TEXT DEFAULT \'pending\'');
|
||||
safeAddColumn('exams', 'conversation_id', 'INTEGER');
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
|
||||
const db = require('./db');
|
||||
const { setWss, broadcast, broadcastBuddy } = require('./lib/broadcast');
|
||||
const embeddings = require('./lib/embeddings');
|
||||
const pdfRoutes = require('./routes/pdfs');
|
||||
const conversationRoutes = require('./routes/conversations');
|
||||
const chatRoutes = require('./routes/chat');
|
||||
@@ -12,6 +15,9 @@ const progressRoutes = require('./routes/progress');
|
||||
const notesRoutes = require('./routes/notes');
|
||||
const modelRoutes = require('./routes/models');
|
||||
const configRoutes = require('./routes/config');
|
||||
const examRoutes = require('./routes/exams');
|
||||
const flashcardRoutes = require('./routes/flashcards');
|
||||
const searchRoutes = require('./routes/search');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
@@ -33,9 +39,15 @@ app.use('/api/progress', progressRoutes);
|
||||
app.use('/api/notes', notesRoutes);
|
||||
app.use('/api/models', modelRoutes);
|
||||
app.use('/api/config', configRoutes);
|
||||
app.use('/api/exams', examRoutes);
|
||||
app.use('/api/flashcards', flashcardRoutes);
|
||||
app.use('/api/search', searchRoutes);
|
||||
|
||||
// Serve React build in production
|
||||
const clientDist = path.resolve(__dirname, '..', 'client', 'dist');
|
||||
if (!fs.existsSync(clientDist)) {
|
||||
console.warn('[index] client/dist not found — SPA fallback will 404 until client is built');
|
||||
}
|
||||
app.use(express.static(clientDist));
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api')) return next();
|
||||
@@ -46,6 +58,7 @@ app.get('*', (req, res, next) => {
|
||||
|
||||
// WebSocket server
|
||||
const wss = new WebSocketServer({ server });
|
||||
setWss(wss);
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (message) => {
|
||||
// Echo — extensible for real-time notifications
|
||||
@@ -59,6 +72,10 @@ wss.on('connection', (ws) => {
|
||||
|
||||
async function start() {
|
||||
await db.initDB();
|
||||
// Warm up embeddings pipeline in background (non-blocking)
|
||||
embeddings.warmup().catch((err) => {
|
||||
console.warn('[index] embeddings warmup failed:', err.message);
|
||||
});
|
||||
server.listen(PORT, () => {
|
||||
console.log(`StudyOS server running on http://localhost:${PORT}`);
|
||||
console.log(`WebSocket server running on ws://localhost:${PORT}`);
|
||||
@@ -69,3 +86,5 @@ start().catch(err => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = { app, server, wss };
|
||||
|
||||
26
server/lib/broadcast.js
Normal file
26
server/lib/broadcast.js
Normal file
@@ -0,0 +1,26 @@
|
||||
let _wss = null;
|
||||
|
||||
function setWss(wss) {
|
||||
_wss = wss;
|
||||
}
|
||||
|
||||
function broadcast(payload) {
|
||||
if (!_wss) return;
|
||||
const data = JSON.stringify(payload);
|
||||
_wss.clients.forEach(ws => { if (ws.readyState === 1) ws.send(data); });
|
||||
}
|
||||
|
||||
function broadcastBuddy(payload) {
|
||||
if (!_wss) return;
|
||||
_wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
console.error('[ws] buddy broadcast error:', err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { setWss, broadcast, broadcastBuddy };
|
||||
142
server/lib/embeddings.js
Normal file
142
server/lib/embeddings.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
let pipelinePromise = null;
|
||||
let _transformers = null;
|
||||
|
||||
// LRU cache: sha1(text) -> Float32Array, capped at 256
|
||||
const lru = new Map();
|
||||
const LRU_MAX = 256;
|
||||
|
||||
function _lruKey(text) {
|
||||
return crypto.createHash('sha1').update(text).digest('hex');
|
||||
}
|
||||
|
||||
function _lruGet(key) {
|
||||
const val = lru.get(key);
|
||||
if (val !== undefined) {
|
||||
// move to back (most recently used)
|
||||
lru.delete(key);
|
||||
lru.set(key, val);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
function _lruSet(key, vec) {
|
||||
if (lru.has(key)) {
|
||||
lru.delete(key);
|
||||
} else if (lru.size >= LRU_MAX) {
|
||||
const firstKey = lru.keys().next().value;
|
||||
lru.delete(firstKey);
|
||||
}
|
||||
lru.set(key, vec);
|
||||
}
|
||||
|
||||
async function _getPipeline() {
|
||||
if (pipelinePromise) return pipelinePromise;
|
||||
|
||||
pipelinePromise = (async () => {
|
||||
try {
|
||||
const mod = await import('@xenova/transformers');
|
||||
_transformers = mod;
|
||||
mod.env.cacheDir = path.join(__dirname, '..', '..', 'node_modules', '.cache', 'transformers');
|
||||
|
||||
// Try webgpu first (DirectML on Windows/AMD), fallback to wasm
|
||||
let pipe;
|
||||
try {
|
||||
pipe = await mod.pipeline('feature-extraction', 'Xenova/multilingual-e5-small', {
|
||||
device: 'webgpu',
|
||||
});
|
||||
console.log('[embeddings] pipeline loaded with device=webgpu');
|
||||
} catch (gpuErr) {
|
||||
console.warn('[embeddings] webgpu failed, falling back to wasm:', gpuErr.message);
|
||||
pipe = await mod.pipeline('feature-extraction', 'Xenova/multilingual-e5-small', {
|
||||
device: 'wasm',
|
||||
});
|
||||
console.log('[embeddings] pipeline loaded with device=wasm');
|
||||
}
|
||||
return pipe;
|
||||
} catch (err) {
|
||||
console.error('[embeddings] failed to load pipeline:', err.message);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
return pipelinePromise;
|
||||
}
|
||||
|
||||
async function warmup() {
|
||||
try {
|
||||
await _getPipeline();
|
||||
} catch (err) {
|
||||
console.warn('[embeddings] warmup failed (model will retry on first use):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function embed(text) {
|
||||
if (!text || typeof text !== 'string') {
|
||||
throw new Error('embed() requires a non-empty string');
|
||||
}
|
||||
|
||||
const key = _lruKey(text);
|
||||
const cached = _lruGet(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const pipe = await _getPipeline();
|
||||
const result = await pipe(text, { pooling: 'mean', normalize: true });
|
||||
const vec = result.data instanceof Float32Array ? result.data : new Float32Array(result.data);
|
||||
_lruSet(key, vec);
|
||||
return vec;
|
||||
}
|
||||
|
||||
async function embedBatch(texts) {
|
||||
if (!Array.isArray(texts) || texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const uncached = [];
|
||||
const indices = [];
|
||||
const results = new Array(texts.length);
|
||||
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
const key = _lruKey(texts[i]);
|
||||
const cached = _lruGet(key);
|
||||
if (cached) {
|
||||
results[i] = cached;
|
||||
} else {
|
||||
uncached.push(texts[i]);
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (uncached.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const pipe = await _getPipeline();
|
||||
const BATCH_SIZE = 32;
|
||||
|
||||
for (let start = 0; start < uncached.length; start += BATCH_SIZE) {
|
||||
const batch = uncached.slice(start, start + BATCH_SIZE);
|
||||
const batchResult = await pipe(batch, { pooling: 'mean', normalize: true });
|
||||
// batchResult.data is a flat array for all batches; shape depends on library version
|
||||
// For Transformers.js v2, when batching, result.data is flat and we need to slice
|
||||
const dim = batch.length > 0 ? Math.floor(batchResult.data.length / batch.length) : 384;
|
||||
for (let b = 0; b < batch.length; b++) {
|
||||
const offset = b * dim;
|
||||
const vec = new Float32Array(batchResult.data.slice(offset, offset + dim));
|
||||
const originalIdx = indices[start + b];
|
||||
results[originalIdx] = vec;
|
||||
_lruSet(_lruKey(batch[b]), vec);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
warmup,
|
||||
embed,
|
||||
embedBatch,
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
const Anthropic = require('@anthropic-ai/sdk');
|
||||
const OpenAI = require('openai');
|
||||
|
||||
// NOTE: model objects carry api_key in memory — avoid logging full model objects.
|
||||
// Use model.name or model.provider only in log statements.
|
||||
|
||||
/**
|
||||
* Normalize Anthropic + OpenAI-compatible streams into one AsyncIterable.
|
||||
* Yields: { token, done, fullText } events.
|
||||
|
||||
87
server/lib/rag.js
Normal file
87
server/lib/rag.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const db = require('../db');
|
||||
const embeddings = require('./embeddings');
|
||||
|
||||
/**
|
||||
* Split text into chunks using a sliding window.
|
||||
* Default: 500 chars per chunk, 50 char overlap.
|
||||
* Cap at 200 chunks per PDF.
|
||||
*/
|
||||
function chunkText(text, size = 500, overlap = 50) {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
const step = size - overlap;
|
||||
const chunks = [];
|
||||
for (let i = 0; i < text.length; i += step) {
|
||||
chunks.push(text.slice(i, i + size));
|
||||
if (chunks.length >= 200) break;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cosine similarity between two Float32Arrays.
|
||||
* Returns a value in [-1, 1].
|
||||
*/
|
||||
function cosineSimilarity(a, b) {
|
||||
if (a.length !== b.length) {
|
||||
throw new Error(`cosineSimilarity: length mismatch ${a.length} vs ${b.length}`);
|
||||
}
|
||||
let dot = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const ai = a[i];
|
||||
const bi = b[i];
|
||||
dot += ai * bi;
|
||||
normA += ai * ai;
|
||||
normB += bi * bi;
|
||||
}
|
||||
if (normA === 0 || normB === 0) return 0;
|
||||
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export embed for clarity.
|
||||
*/
|
||||
async function embedQuery(text) {
|
||||
return embeddings.embed(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find top K most relevant chunks for a query vector.
|
||||
* @param {Float32Array} queryVec
|
||||
* @param {number[]} pdfIds
|
||||
* @param {number} k
|
||||
* @returns {Promise<{pdf_id, chunk_index, content, similarity}[]>}
|
||||
*/
|
||||
async function topK(queryVec, pdfIds, k = 3) {
|
||||
if (!pdfIds || pdfIds.length === 0) return [];
|
||||
|
||||
const placeholders = pdfIds.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT pdf_id, chunk_index, vector, content FROM embeddings WHERE pdf_id IN (${placeholders})`
|
||||
).all(...pdfIds);
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const scored = rows.map((row) => {
|
||||
const buf = Buffer.from(row.vector);
|
||||
const chunkVec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
||||
const similarity = cosineSimilarity(queryVec, chunkVec);
|
||||
return {
|
||||
pdf_id: row.pdf_id,
|
||||
chunk_index: row.chunk_index,
|
||||
content: row.content,
|
||||
similarity,
|
||||
};
|
||||
});
|
||||
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
return scored.slice(0, k);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
chunkText,
|
||||
cosineSimilarity,
|
||||
embedQuery,
|
||||
topK,
|
||||
};
|
||||
24
server/lib/sm2.js
Normal file
24
server/lib/sm2.js
Normal file
@@ -0,0 +1,24 @@
|
||||
function sm2(prev, quality) {
|
||||
let { ease_factor: e, interval_days: i, repetitions: r } = prev;
|
||||
if (quality < 3) {
|
||||
r = 0;
|
||||
i = 1;
|
||||
} else {
|
||||
if (r === 0) i = 1;
|
||||
else if (r === 1) i = 6;
|
||||
else i = Math.round(i * e);
|
||||
r += 1;
|
||||
}
|
||||
e = Math.max(1.3, e + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)));
|
||||
// next_review uses local timezone — acceptable for a personal study app
|
||||
const next = new Date();
|
||||
next.setDate(next.getDate() + i);
|
||||
return {
|
||||
ease_factor: +e.toFixed(2),
|
||||
interval_days: i,
|
||||
repetitions: r,
|
||||
next_review: next.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { sm2 };
|
||||
1626
server/package-lock.json
generated
1626
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,18 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
"dev": "node --watch index.js",
|
||||
"test": "node --test routes/__tests__/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.32.0",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"openai": "^4.70.0",
|
||||
"pdfjs-dist": "^4.4.168",
|
||||
"pdfkit": "^0.15.0",
|
||||
"playwright": "^1.60.0",
|
||||
"sql.js": "^1.10.0",
|
||||
"ws": "^8.18.0"
|
||||
|
||||
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;
|
||||
@@ -2,9 +2,9 @@
|
||||
* Builds the system prompt for a conversation based on its type, user progress,
|
||||
* available PDFs, and any attachment texts.
|
||||
*/
|
||||
function buildSystemPrompt(conversation, progressRows = [], pdfContents = [], attachmentTexts = []) {
|
||||
function buildSystemPrompt(conversation, progressRows = [], pdfContents = [], attachmentTexts = [], ragChunks = [], difficulty = 'normal') {
|
||||
if (conversation.type === 'main') {
|
||||
return buildMainPrompt(progressRows, pdfContents, attachmentTexts);
|
||||
return buildMainPrompt(progressRows, pdfContents, attachmentTexts, ragChunks, difficulty, conversation);
|
||||
}
|
||||
if (conversation.type === 'fork') {
|
||||
return buildForkPrompt(conversation);
|
||||
@@ -12,7 +12,7 @@ function buildSystemPrompt(conversation, progressRows = [], pdfContents = [], at
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildMainPrompt(progressRows, pdfContents, attachmentTexts) {
|
||||
function buildMainPrompt(progressRows, pdfContents, attachmentTexts, ragChunks, difficulty, conversation) {
|
||||
let prompt = `Sos un tutor de estudio personal especializado. Tu objetivo es ayudar al usuario a aprender de forma eficiente y con seguimiento real de su progreso.
|
||||
|
||||
PROGRESO ACTUAL DEL USUARIO:
|
||||
@@ -65,6 +65,29 @@ ${attachmentTexts.map((t, i) => `--- Adjunto ${i + 1} ---\n${t}`).join('\n\n')}
|
||||
`;
|
||||
}
|
||||
|
||||
if (ragChunks && ragChunks.length > 0) {
|
||||
prompt += `
|
||||
REFERENCE CONTEXT (fragmentos relevantes de PDFs):
|
||||
${ragChunks.map((c, i) => `[${i + 1}] (PDF ${c.pdf_id}, chunk ${c.chunk_index})\n${c.content}`).join('\n\n')}
|
||||
`;
|
||||
}
|
||||
|
||||
if (difficulty && difficulty !== 'normal') {
|
||||
const levelLabel = difficulty === 'easy' || difficulty === 'facil' ? 'FÁCIL' : difficulty === 'hard' || difficulty === 'dificil' ? 'DIFÍCIL' : 'NORMAL';
|
||||
prompt += `
|
||||
NIVEL: ${levelLabel} — adaptá la profundidad y el lenguaje al nivel.
|
||||
`;
|
||||
}
|
||||
|
||||
if (conversation && conversation.buddy_meta) {
|
||||
const meta = typeof conversation.buddy_meta === 'string' ? JSON.parse(conversation.buddy_meta) : conversation.buddy_meta;
|
||||
const a = meta.role_a || 'Estudiante A';
|
||||
const b = meta.role_b || 'Estudiante B';
|
||||
prompt += `
|
||||
MODO COMPAÑERO DE ESTUDIO: hay 2 usuarios llamados "${a}" y "${b}". Dirigite a ambos.
|
||||
`;
|
||||
}
|
||||
|
||||
prompt += `
|
||||
CAPACIDADES:
|
||||
- Generar exámenes simulados adaptados al progreso
|
||||
|
||||
Reference in New Issue
Block a user