165 lines
5.5 KiB
JavaScript
165 lines
5.5 KiB
JavaScript
const db = require('../db');
|
|
const { findArticlesByEmbedding, getOrCreateQueryEmbedding } = require('../embeddings');
|
|
|
|
function parseLimit(value) {
|
|
const n = Number.parseInt(value, 10);
|
|
return Number.isFinite(n) && n > 0 ? Math.min(n, 100) : 20;
|
|
}
|
|
|
|
function parseOffset(value) {
|
|
const n = Number.parseInt(value, 10);
|
|
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
}
|
|
|
|
const getArticlesForEvent = db.prepare(`
|
|
SELECT id, title, description, content, url, normalized_title, source, pub_date, ingested_at
|
|
FROM articles
|
|
WHERE event_id = ?
|
|
AND content IS NOT NULL AND content != ''
|
|
AND is_index_page = 0
|
|
ORDER BY pub_date_effective DESC, id DESC
|
|
`);
|
|
|
|
function fetchEventsByIds(ids) {
|
|
if (ids.length === 0) return [];
|
|
|
|
const placeholders = ids.map(() => '?').join(', ');
|
|
const rows = db.prepare(`
|
|
SELECT e.id, e.title,
|
|
(SELECT MIN(a.pub_date_effective) FROM articles a WHERE a.event_id = e.id AND a.content IS NOT NULL AND a.content != '' AND a.is_index_page = 0) AS pub_date
|
|
FROM events e
|
|
WHERE e.id IN (${placeholders})
|
|
`).all(...ids);
|
|
|
|
const byId = new Map(rows.map(e => [e.id, e]));
|
|
|
|
// preserve the caller-supplied order (distance order for semantic, sort order for keyword)
|
|
return ids.map(id => {
|
|
const e = byId.get(id);
|
|
return e ? { ...e, articles: getArticlesForEvent.all(e.id) } : null;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
async function eventRoutes(fastify) {
|
|
fastify.get('/events', async (request, reply) => {
|
|
const query = request.query || {};
|
|
const limit = parseLimit(query.limit);
|
|
const offset = parseOffset(query.offset);
|
|
|
|
// semantic path: embed query → find nearest articles → resolve to events
|
|
if (query.semantic !== undefined) {
|
|
const embedding = await getOrCreateQueryEmbedding(query.semantic);
|
|
if (!embedding) {
|
|
reply.code(400);
|
|
return { error: 'Semantic query must not be empty' };
|
|
}
|
|
|
|
// fetch more article candidates than we need so we have enough after dedup
|
|
const neighbors = findArticlesByEmbedding(embedding, Math.min(limit * 20, 2000));
|
|
const neighborIds = neighbors.map(n => n.articleId);
|
|
if (neighborIds.length === 0) return [];
|
|
|
|
const placeholders = neighborIds.map(() => '?').join(', ');
|
|
const conditions = [
|
|
`id IN (${placeholders})`,
|
|
'event_id IS NOT NULL',
|
|
"content IS NOT NULL AND content != ''",
|
|
'is_index_page = 0',
|
|
];
|
|
const params = [...neighborIds];
|
|
|
|
if (query.from) { conditions.push('pub_date_effective >= ?'); params.push(query.from); }
|
|
if (query.to) { conditions.push('pub_date_effective <= ?'); params.push(query.to); }
|
|
|
|
const articleRows = db.prepare(
|
|
`SELECT id, event_id FROM articles WHERE ${conditions.join(' AND ')}`
|
|
).all(...params);
|
|
|
|
const articleEventMap = new Map(articleRows.map(r => [r.id, r.event_id]));
|
|
|
|
// walk neighbors in distance order and collect unique event IDs
|
|
const seen = new Set();
|
|
const orderedEventIds = [];
|
|
for (const id of neighborIds) {
|
|
const eventId = articleEventMap.get(id);
|
|
if (eventId != null && !seen.has(eventId)) {
|
|
seen.add(eventId);
|
|
orderedEventIds.push(eventId);
|
|
}
|
|
}
|
|
|
|
return fetchEventsByIds(orderedEventIds.slice(offset, offset + limit));
|
|
}
|
|
|
|
|
|
// keyword / date filter path
|
|
const conditions = [];
|
|
const params = [];
|
|
|
|
if (query.id) {
|
|
const id = Number.parseInt(query.id, 10);
|
|
if (!Number.isFinite(id)) {
|
|
reply.code(400);
|
|
return { error: 'id must be a number' };
|
|
}
|
|
conditions.push('e.id = ?');
|
|
params.push(id);
|
|
}
|
|
|
|
if (query.keyword) {
|
|
const keywords = [].concat(query.keyword).map(k => k.trim()).filter(Boolean);
|
|
const mode = String(query.keyword_mode || '').toLowerCase() === 'or' ? 'OR' : 'AND';
|
|
const clauses = keywords.map(() => '(a.title LIKE ? OR a.description LIKE ? OR a.content LIKE ?)');
|
|
|
|
conditions.push(`EXISTS (
|
|
SELECT 1 FROM articles a
|
|
WHERE a.event_id = e.id
|
|
AND a.content IS NOT NULL AND a.content != ''
|
|
AND a.is_index_page = 0
|
|
AND (${clauses.join(` ${mode} `)})
|
|
)`);
|
|
|
|
for (const kw of keywords) {
|
|
const like = `%${kw}%`;
|
|
params.push(like, like, like);
|
|
}
|
|
}
|
|
|
|
if (query.from) {
|
|
conditions.push(`EXISTS (
|
|
SELECT 1 FROM articles a WHERE a.event_id = e.id AND a.pub_date_effective >= ?
|
|
)`);
|
|
params.push(query.from);
|
|
}
|
|
|
|
if (query.to) {
|
|
conditions.push(`EXISTS (
|
|
SELECT 1 FROM articles a WHERE a.event_id = e.id AND a.pub_date_effective <= ?
|
|
)`);
|
|
params.push(query.to);
|
|
}
|
|
|
|
const SORT_COLUMNS = {
|
|
pub_date: "(SELECT MIN(a.pub_date_effective) FROM articles a WHERE a.event_id = e.id AND a.content IS NOT NULL AND a.content != '' AND a.is_index_page = 0)",
|
|
id: 'e.id',
|
|
};
|
|
|
|
const sortBy = SORT_COLUMNS[query.sort_by] || SORT_COLUMNS.pub_date;
|
|
const order = String(query.order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
const events = db.prepare(`
|
|
SELECT e.id, e.title,
|
|
(SELECT MIN(a.pub_date_effective) FROM articles a WHERE a.event_id = e.id AND a.content IS NOT NULL AND a.content != '' AND a.is_index_page = 0) AS pub_date
|
|
FROM events e
|
|
${where}
|
|
ORDER BY ${sortBy} ${order}, e.id ${order}
|
|
LIMIT ? OFFSET ?
|
|
`).all(...params, limit, offset);
|
|
|
|
return events.map(e => ({ ...e, articles: getArticlesForEvent.all(e.id) }));
|
|
});
|
|
}
|
|
|
|
module.exports = eventRoutes;
|