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)
109 lines
3.3 KiB
JavaScript
109 lines
3.3 KiB
JavaScript
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;
|