Files
Duriin-API/src/routes/events.js
T

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;