439 lines
No EOL
15 KiB
JavaScript
439 lines
No EOL
15 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const db = require('../db');
|
|
const config = require('../config');
|
|
const Database = require('better-sqlite3');
|
|
|
|
let idb = null;
|
|
|
|
function getIntelligenceDb() {
|
|
if (idb) return idb;
|
|
|
|
const configDir = path.resolve(__dirname, '..', '..');
|
|
const rawPath = process.env.INTELLIGENCE_DB
|
|
|| (config.intelligence_db
|
|
? (path.isAbsolute(config.intelligence_db) ? config.intelligence_db : path.resolve(configDir, config.intelligence_db))
|
|
: path.resolve(configDir, 'intelligence.sqlite'));
|
|
|
|
if (!fs.existsSync(rawPath)) return null;
|
|
|
|
idb = new Database(rawPath);
|
|
return idb;
|
|
}
|
|
|
|
const adminUser = (config.admin && config.admin.username) || 'admin';
|
|
const adminPass = (config.admin && config.admin.password) || 'changeme';
|
|
|
|
function checkAuth(request, reply) {
|
|
const header = request.headers['authorization'] || '';
|
|
if (!header.startsWith('Basic ')) {
|
|
reply.header('WWW-Authenticate', 'Basic realm="Duriin Admin"');
|
|
reply.code(401).send('Unauthorized');
|
|
return false;
|
|
}
|
|
|
|
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
|
const colon = decoded.indexOf(':');
|
|
const user = decoded.slice(0, colon);
|
|
const pass = decoded.slice(colon + 1);
|
|
|
|
if (user !== adminUser || pass !== adminPass) {
|
|
reply.header('WWW-Authenticate', 'Basic realm="Duriin Admin"');
|
|
reply.code(401).send('Unauthorized');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
const htmlPath = path.join(__dirname, '..', '..', 'admin.html');
|
|
|
|
async function adminRoutes(fastify) {
|
|
|
|
fastify.get('/admin', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
reply.type('text/html');
|
|
return fs.createReadStream(htmlPath);
|
|
});
|
|
|
|
// list articles — all of them, not just the ones with embeddings
|
|
fastify.get('/admin/api/articles', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
|
|
const q = request.query || {};
|
|
const limit = Math.min(parseInt(q.limit, 10) || 50, 200);
|
|
const offset = parseInt(q.offset, 10) || 0;
|
|
|
|
const conditions = [];
|
|
const params = [];
|
|
|
|
if (q.keyword) {
|
|
conditions.push('(title LIKE ? OR description LIKE ? OR content LIKE ?)');
|
|
const like = `%${q.keyword}%`;
|
|
params.push(like, like, like);
|
|
}
|
|
|
|
if (q.source) {
|
|
conditions.push('source = ?');
|
|
params.push(q.source);
|
|
}
|
|
|
|
if (q.content_status) {
|
|
if (q.content_status === 'null') {
|
|
conditions.push('content_status IS NULL');
|
|
} else {
|
|
conditions.push('content_status = ?');
|
|
params.push(q.content_status);
|
|
}
|
|
}
|
|
|
|
if (q.from) {
|
|
conditions.push('ingested_at >= ?');
|
|
params.push(q.from);
|
|
}
|
|
|
|
if (q.to) {
|
|
conditions.push('ingested_at <= ?');
|
|
params.push(q.to);
|
|
}
|
|
|
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
const total = db.prepare(`SELECT COUNT(*) as n FROM articles ${where}`).get(...params).n;
|
|
|
|
params.push(limit, offset);
|
|
const rows = db.prepare(`
|
|
SELECT id, title, url, source, pub_date, ingested_at, content_status, is_index_page, has_embedding, language
|
|
FROM articles
|
|
${where}
|
|
ORDER BY ingested_at DESC, id DESC
|
|
LIMIT ? OFFSET ?
|
|
`).all(...params);
|
|
|
|
return { total, rows };
|
|
});
|
|
|
|
fastify.get('/admin/api/articles/:id', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
|
|
const row = db.prepare(`
|
|
SELECT id, title, description, content, url, normalized_title, source,
|
|
pub_date, pub_date_effective, ingested_at, content_status, content_error,
|
|
content_attempted_at, content_attempt_count, is_index_page, has_embedding, language, event_id
|
|
FROM articles WHERE id = ?
|
|
`).get(request.params.id);
|
|
|
|
if (!row) { reply.code(404); return { error: 'not found' }; }
|
|
return row;
|
|
});
|
|
|
|
fastify.patch('/admin/api/articles/:id', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
|
|
const id = parseInt(request.params.id, 10);
|
|
const body = request.body || {};
|
|
|
|
// only allow editing these fields
|
|
const allowed = ['title', 'description', 'content', 'content_status', 'is_index_page', 'language', 'pub_date'];
|
|
const updates = [];
|
|
const params = [];
|
|
|
|
for (const key of allowed) {
|
|
if (Object.prototype.hasOwnProperty.call(body, key)) {
|
|
updates.push(`${key} = ?`);
|
|
params.push(body[key]);
|
|
}
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
reply.code(400);
|
|
return { error: 'no valid fields provided' };
|
|
}
|
|
|
|
params.push(id);
|
|
db.prepare(`UPDATE articles SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
return { ok: true };
|
|
});
|
|
|
|
fastify.delete('/admin/api/articles/:id', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
|
|
const id = parseInt(request.params.id, 10);
|
|
|
|
// remove embeddings first so foreign key stuff doesnt bite us
|
|
db.prepare(`DELETE FROM article_embedding_store WHERE article_id = ?`).run(id);
|
|
db.prepare(`DELETE FROM article_embedding_meta WHERE article_id = ?`).run(id);
|
|
|
|
try {
|
|
db.prepare(`DELETE FROM article_embeddings WHERE article_id = ?`).run(id);
|
|
} catch (_) {}
|
|
|
|
db.prepare(`DELETE FROM articles WHERE id = ?`).run(id);
|
|
return { ok: true };
|
|
});
|
|
|
|
// sources list for filter dropdown
|
|
fastify.get('/admin/api/sources', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
|
|
const rows = db.prepare(`SELECT DISTINCT source FROM articles ORDER BY source`).all();
|
|
return rows.map(r => r.source);
|
|
});
|
|
|
|
// events
|
|
fastify.get('/admin/api/events', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
|
|
const q = request.query || {};
|
|
const limit = Math.min(parseInt(q.limit, 10) || 50, 200);
|
|
const offset = parseInt(q.offset, 10) || 0;
|
|
|
|
const total = db.prepare(`SELECT COUNT(*) as n FROM events`).get().n;
|
|
const rows = db.prepare(`
|
|
SELECT e.id, e.title, e.created_at, COUNT(a.id) as article_count
|
|
FROM events e
|
|
LEFT JOIN articles a ON a.event_id = e.id
|
|
GROUP BY e.id
|
|
ORDER BY e.created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
`).all(limit, offset);
|
|
|
|
return { total, rows };
|
|
});
|
|
|
|
fastify.delete('/admin/api/events/:id', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
|
|
const id = parseInt(request.params.id, 10);
|
|
// detach articles from this event but dont delete the articles themselves
|
|
db.prepare(`UPDATE articles SET event_id = NULL WHERE event_id = ?`).run(id);
|
|
db.prepare(`DELETE FROM events WHERE id = ?`).run(id);
|
|
return { ok: true };
|
|
});
|
|
|
|
fastify.patch('/admin/api/events/:id', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
|
|
const id = parseInt(request.params.id, 10);
|
|
const body = request.body || {};
|
|
|
|
if (!body.title) { reply.code(400); return { error: 'title is required' }; }
|
|
db.prepare(`UPDATE events SET title = ? WHERE id = ?`).run(body.title, id);
|
|
return { ok: true };
|
|
});
|
|
|
|
// intelligence endpoints
|
|
fastify.get('/admin/api/intelligence/stats', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
const db = getIntelligenceDb();
|
|
if (!db) return { available: false };
|
|
|
|
const queue = db.prepare(`SELECT status, COUNT(*) as n FROM article_queue GROUP BY status`).all();
|
|
const knowledge = db.prepare(`SELECT COUNT(*) as n FROM event_knowledge`).get().n;
|
|
const predictions = db.prepare(`SELECT COUNT(*) as n FROM event_predictions`).get().n;
|
|
const companies = db.prepare(`SELECT COUNT(*) as n FROM tracked_companies`).get().n;
|
|
const embeddings = db.prepare(`SELECT COUNT(*) as n FROM company_embeddings`).get().n;
|
|
|
|
let workerRates = [];
|
|
try {
|
|
workerRates = db.prepare(`
|
|
SELECT
|
|
worker,
|
|
SUM(CASE WHEN completed_at >= datetime('now', '-5 minutes') THEN 1 ELSE 0 END) as n5m,
|
|
SUM(CASE WHEN completed_at >= datetime('now', '-1 minute') THEN 1 ELSE 0 END) as n1m
|
|
FROM worker_events
|
|
GROUP BY worker
|
|
`).all();
|
|
} catch (_) {}
|
|
|
|
return { available: true, queue, knowledge, predictions, companies, embeddings, workerRates };
|
|
});
|
|
|
|
fastify.get('/admin/api/intelligence/companies', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
const db = getIntelligenceDb();
|
|
if (!db) return [];
|
|
return db.prepare(`SELECT * FROM tracked_companies ORDER BY name`).all();
|
|
});
|
|
|
|
fastify.get('/admin/api/intelligence/knowledge', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
const db = getIntelligenceDb();
|
|
if (!db) return { total: 0, rows: [] };
|
|
|
|
const q = request.query || {};
|
|
const limit = Math.min(parseInt(q.limit, 10) || 50, 200);
|
|
const offset = parseInt(q.offset, 10) || 0;
|
|
const companyId = q.company_id ? parseInt(q.company_id, 10) : null;
|
|
const type = q.type || null;
|
|
|
|
const conditions = [];
|
|
const params = [];
|
|
if (companyId) { conditions.push('ek.company_id = ?'); params.push(companyId); }
|
|
if (type) { conditions.push('ek.type = ?'); params.push(type); }
|
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
const sortCol = q.sort === 'event_date' ? 'ek.event_date' : 'ek.id';
|
|
|
|
const total = db.prepare(`SELECT COUNT(*) as n FROM event_knowledge ek ${where}`).get(...params).n;
|
|
const rows = db.prepare(`
|
|
SELECT ek.id, ek.event_id, ek.type, ek.data, ek.event_date, ek.created_at,
|
|
tc.name as company_name
|
|
FROM event_knowledge ek
|
|
JOIN tracked_companies tc ON tc.id = ek.company_id
|
|
${where}
|
|
ORDER BY ${sortCol} DESC
|
|
LIMIT ? OFFSET ?
|
|
`).all(...params, limit, offset);
|
|
|
|
return { total, rows };
|
|
});
|
|
|
|
fastify.get('/admin/api/intelligence/predictions', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
const db = getIntelligenceDb();
|
|
if (!db) return { total: 0, rows: [] };
|
|
|
|
const q = request.query || {};
|
|
const limit = Math.min(parseInt(q.limit, 10) || 50, 200);
|
|
const offset = parseInt(q.offset, 10) || 0;
|
|
const companyId = q.company_id ? parseInt(q.company_id, 10) : null;
|
|
|
|
const conditions = [];
|
|
const params = [];
|
|
if (companyId) { conditions.push('ep.company_id = ?'); params.push(companyId); }
|
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
const sortCol = q.sort === 'event_date' ? 'ep.event_date' : 'ep.id';
|
|
|
|
const total = db.prepare(`SELECT COUNT(*) as n FROM event_predictions ep ${where}`).get(...params).n;
|
|
const rows = db.prepare(`
|
|
SELECT ep.*, tc.name as company_name
|
|
FROM event_predictions ep
|
|
JOIN tracked_companies tc ON tc.id = ep.company_id
|
|
${where}
|
|
ORDER BY ${sortCol} DESC
|
|
LIMIT ? OFFSET ?
|
|
`).all(...params, limit, offset);
|
|
|
|
return { total, rows };
|
|
});
|
|
|
|
// intelligence graph — nodes + edges from company_relationships
|
|
fastify.get('/admin/api/intelligence/graph', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
const idb = getIntelligenceDb();
|
|
if (!idb) return { nodes: [], edges: [] };
|
|
|
|
const edges = idb.prepare(`SELECT * FROM company_relationships`).all();
|
|
|
|
const trackedIds = new Set();
|
|
for (const e of edges) {
|
|
trackedIds.add(e.from_company_id);
|
|
if (e.to_company_id) trackedIds.add(e.to_company_id);
|
|
}
|
|
|
|
const allTracked = idb.prepare(`SELECT id, name, ticker FROM tracked_companies`).all();
|
|
|
|
// build a lowercase name+alias set so we can exclude untracked entities
|
|
// that are just unresolved references to tracked companies
|
|
const trackedNameSet = new Set();
|
|
for (const c of allTracked) {
|
|
trackedNameSet.add(c.name.toLowerCase());
|
|
trackedNameSet.add(c.ticker.toLowerCase());
|
|
}
|
|
|
|
const untrackedSeen = new Set();
|
|
for (const e of edges) {
|
|
if (!e.to_company_id && e.to_entity) {
|
|
if (!trackedNameSet.has(e.to_entity.toLowerCase())) {
|
|
untrackedSeen.add(e.to_entity);
|
|
}
|
|
}
|
|
}
|
|
|
|
const nodes = [
|
|
...allTracked
|
|
.filter(c => trackedIds.has(c.id))
|
|
.map(c => ({ id: c.id, name: c.name, ticker: c.ticker, tracked: true })),
|
|
|
|
...[...untrackedSeen].map(name => ({ id: null, name, ticker: null, tracked: false })),
|
|
];
|
|
|
|
return { nodes, edges };
|
|
});
|
|
|
|
|
|
// per-company facts for the graph sidebar
|
|
fastify.get('/admin/api/intelligence/facts/:company_id', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
const idb = getIntelligenceDb();
|
|
if (!idb) return [];
|
|
|
|
const companyId = parseInt(request.params.company_id, 10);
|
|
return idb.prepare(`
|
|
SELECT type, claim, confidence, confirmation_count
|
|
FROM company_facts
|
|
WHERE company_id = ?
|
|
ORDER BY confirmation_count DESC
|
|
LIMIT 10
|
|
`).all(companyId);
|
|
});
|
|
|
|
|
|
// raw sql console — supports multiple statements separated by ;
|
|
fastify.post('/admin/api/sql', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
|
|
const { sql, database } = request.body || {};
|
|
if (!sql || !sql.trim()) { reply.code(400); return { error: 'no sql provided' }; }
|
|
|
|
const target = database === 'intelligence' ? getIntelligenceDb() : db;
|
|
if (!target) { reply.code(400); return { error: 'database not available' }; }
|
|
|
|
// split on semicolons, drop empty statements
|
|
const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0);
|
|
|
|
const results = [];
|
|
const start = Date.now();
|
|
|
|
for (const s of statements) {
|
|
try {
|
|
const stmt = target.prepare(s);
|
|
if (stmt.reader) {
|
|
results.push({ sql: s, rows: stmt.all() });
|
|
} else {
|
|
const info = stmt.run();
|
|
results.push({ sql: s, changes: info.changes, lastInsertRowid: info.lastInsertRowid });
|
|
}
|
|
} catch (err) {
|
|
results.push({ sql: s, error: err.message });
|
|
}
|
|
}
|
|
|
|
return { results, elapsed: Date.now() - start };
|
|
});
|
|
|
|
// stats for dashboard header
|
|
fastify.get('/admin/api/stats', async (request, reply) => {
|
|
if (!checkAuth(request, reply)) return;
|
|
|
|
const total = db.prepare(`SELECT COUNT(*) as n FROM articles`).get().n;
|
|
const withContent = db.prepare(`SELECT COUNT(*) as n FROM articles WHERE content IS NOT NULL AND content != ''`).get().n;
|
|
const withEmbedding = db.prepare(`SELECT COUNT(*) as n FROM articles WHERE has_embedding = 1`).get().n;
|
|
const eventCount = db.prepare(`SELECT COUNT(*) as n FROM events`).get().n;
|
|
|
|
const bySource = db.prepare(`
|
|
SELECT source, COUNT(*) as n FROM articles GROUP BY source ORDER BY n DESC
|
|
`).all();
|
|
|
|
const byStatus = db.prepare(`
|
|
SELECT COALESCE(content_status, 'null') as status, COUNT(*) as n
|
|
FROM articles GROUP BY content_status ORDER BY n DESC
|
|
`).all();
|
|
|
|
return { total, withContent, withEmbedding, eventCount, bySource, byStatus };
|
|
});
|
|
}
|
|
|
|
module.exports = adminRoutes; |