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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user