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:
108
server/routes/search.js
Normal file
108
server/routes/search.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/search?q=term
|
||||
router.get('/', (req, res) => {
|
||||
const q = req.query.q || '';
|
||||
const term = q.trim();
|
||||
if (!term) {
|
||||
return res.json({ messages: [], pdfs: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const messageResults = [];
|
||||
const pdfResults = [];
|
||||
const fts5 = db._fts5Available;
|
||||
|
||||
// Sanitize term for FTS5: quote each whitespace-separated term
|
||||
const safeTerm = term.split(/\s+/).filter(Boolean).map(t => `"${t.replace(/"/g, '""')}"`).join(' ');
|
||||
const fts5Term = fts5 ? safeTerm : null;
|
||||
|
||||
if (fts5 && fts5Term) {
|
||||
// FTS5 path: use MATCH with snippet highlighting and BM25 ranking
|
||||
const messageRows = db.prepare(`
|
||||
SELECT m.id, m.conversation_id, m.content,
|
||||
snippet(messages_fts, 1, '<b>', '</b>', '…', 16) as snippet,
|
||||
rank
|
||||
FROM messages_fts
|
||||
JOIN messages m ON m.id = messages_fts.rowid
|
||||
WHERE messages_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT 25
|
||||
`).all(fts5Term);
|
||||
|
||||
const pdfRows = db.prepare(`
|
||||
SELECT p.id, p.original_name, p.content_markdown,
|
||||
snippet(pdfs_fts, 1, '<b>', '</b>', '…', 16) as snippet,
|
||||
rank
|
||||
FROM pdfs_fts
|
||||
JOIN pdfs p ON p.id = pdfs_fts.rowid
|
||||
WHERE pdfs_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT 25
|
||||
`).all(fts5Term);
|
||||
|
||||
for (const row of messageRows) {
|
||||
messageResults.push({
|
||||
type: 'message',
|
||||
id: row.id,
|
||||
title: row.content?.slice(0, 60) || 'Mensaje',
|
||||
snippet: row.snippet || '',
|
||||
rank: row.rank || 0,
|
||||
conversation_id: row.conversation_id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of pdfRows) {
|
||||
pdfResults.push({
|
||||
type: 'pdf',
|
||||
id: row.id,
|
||||
title: row.original_name || 'PDF',
|
||||
snippet: row.snippet || '',
|
||||
rank: row.rank || 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// LIKE fallback (no ranking, no snippets)
|
||||
const likeTerm = `%${term}%`;
|
||||
const messageRows = db.prepare(
|
||||
`SELECT id, conversation_id, content FROM messages WHERE content LIKE ? LIMIT 25`
|
||||
).all(likeTerm);
|
||||
const pdfRows = db.prepare(
|
||||
`SELECT id, original_name, content_markdown FROM pdfs WHERE content_markdown LIKE ? LIMIT 25`
|
||||
).all(likeTerm);
|
||||
|
||||
for (const row of messageRows) {
|
||||
messageResults.push({
|
||||
type: 'message',
|
||||
id: row.id,
|
||||
title: row.content?.slice(0, 60) || 'Mensaje',
|
||||
snippet: row.content?.slice(0, 120) || '',
|
||||
rank: 0,
|
||||
conversation_id: row.conversation_id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of pdfRows) {
|
||||
pdfResults.push({
|
||||
type: 'pdf',
|
||||
id: row.id,
|
||||
title: row.original_name || 'PDF',
|
||||
snippet: row.content_markdown?.slice(0, 120) || '',
|
||||
rank: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return grouped results — keep message BM25 and PDF BM25 ranks separate (different indexes)
|
||||
messageResults.sort((a, b) => (a.rank || 0) - (b.rank || 0));
|
||||
pdfResults.sort((a, b) => (a.rank || 0) - (b.rank || 0));
|
||||
res.json({ messages: messageResults.slice(0, 25), pdfs: pdfResults.slice(0, 25) });
|
||||
} catch (err) {
|
||||
console.error('[search] error:', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user