add intelligence and SQL tabs to admin interface with corresponding API endpoints

This commit is contained in:
ImBenji 2026-04-22 20:50:08 +01:00
parent ac7c87c6cf
commit 18d062fd2d
9 changed files with 428 additions and 228 deletions

View file

@ -247,6 +247,8 @@
<button class="tab-btn active" data-tab="articles">Articles</button> <button class="tab-btn active" data-tab="articles">Articles</button>
<button class="tab-btn" data-tab="events">Events</button> <button class="tab-btn" data-tab="events">Events</button>
<button class="tab-btn" data-tab="stats">Stats</button> <button class="tab-btn" data-tab="stats">Stats</button>
<button class="tab-btn" data-tab="intelligence">Intelligence</button>
<button class="tab-btn" data-tab="sql">SQL</button>
</div> </div>
</header> </header>
@ -320,6 +322,63 @@
</div> </div>
</div> </div>
<!-- Intelligence tab -->
<div id="tab-intelligence" style="display:none">
<div id="intel-unavailable" style="display:none; color:#94a3b8; padding: 24px 0">intelligence.sqlite not found — is the intelligence worker running?</div>
<div id="intel-content">
<div style="display:flex; gap:24px; flex-wrap:wrap; margin-bottom:24px" id="intel-stats-row"></div>
<div style="display:flex; gap:16px; margin-bottom:14px; flex-wrap:wrap; align-items:flex-end">
<label style="display:flex; flex-direction:column; gap:4px; font-size:12px; color:#94a3b8">Company
<select id="i-company"><option value="">All companies</option></select>
</label>
<label style="display:flex; flex-direction:column; gap:4px; font-size:12px; color:#94a3b8">View
<select id="i-view">
<option value="knowledge">Knowledge</option>
<option value="predictions">Predictions</option>
</select>
</label>
<label style="display:flex; flex-direction:column; gap:4px; font-size:12px; color:#94a3b8">Type
<select id="i-type">
<option value="">All types</option>
<option value="relationship">Relationship</option>
<option value="theme">Theme</option>
<option value="factor">Factor</option>
</select>
</label>
<button class="primary" onclick="loadIntelligence()">Filter</button>
</div>
<table>
<thead id="intel-thead"></thead>
<tbody id="intel-tbody"></tbody>
</table>
<div class="pagination">
<button id="iPrevBtn">← Prev</button>
<span id="iPageInfo"></span>
<button id="iNextBtn">Next →</button>
</div>
</div>
</div>
<!-- SQL tab -->
<div id="tab-sql" style="display:none">
<div style="display:flex; gap:10px; margin-bottom:10px; align-items:center">
<select id="sql-db" style="min-width:160px">
<option value="archive">archive.sqlite</option>
<option value="intelligence">intelligence.sqlite</option>
</select>
<button class="primary" id="sql-run-btn">Run</button>
<span id="sql-elapsed" style="color:#64748b; font-size:12px"></span>
</div>
<textarea id="sql-input" style="width:100%; min-height:120px; font-family:monospace; font-size:13px; margin-bottom:12px" placeholder="SELECT ..."></textarea>
<div id="sql-error" style="color:#fca5a5; font-size:13px; margin-bottom:10px; display:none"></div>
<div id="sql-results" style="overflow-x:auto"></div>
</div>
<!-- Stats tab --> <!-- Stats tab -->
<div id="tab-stats" style="display:none"> <div id="tab-stats" style="display:none">
<div style="display:flex; gap:32px; flex-wrap:wrap; padding-top:8px"> <div style="display:flex; gap:32px; flex-wrap:wrap; padding-top:8px">
@ -653,12 +712,191 @@ document.getElementById('eDeleteBtn').onclick = async () => {
} catch (e) { toast('Delete failed', true); } } catch (e) { toast('Delete failed', true); }
}; };
// ── intelligence ───────────────────────────────────────────────────────────
let intelOffset = 0;
async function loadIntelligenceStats() {
const data = await api('/admin/api/intelligence/stats');
if (!data.available) {
document.getElementById('intel-unavailable').style.display = '';
document.getElementById('intel-content').style.display = 'none';
return false;
}
document.getElementById('intel-unavailable').style.display = 'none';
document.getElementById('intel-content').style.display = '';
const queueMap = {};
(data.queue || []).forEach(r => queueMap[r.status] = r.n);
document.getElementById('intel-stats-row').innerHTML = [
['Queue pending', (queueMap.pending || 0).toLocaleString()],
['Processed', (queueMap.processed || 0).toLocaleString()],
['Skipped', (queueMap.skipped || 0).toLocaleString()],
['Knowledge rows', data.knowledge.toLocaleString()],
['Predictions', data.predictions.toLocaleString()],
['Companies', `${data.embeddings}/${data.companies} embedded`],
].map(([label, value]) => `
<div class="stat">
<span class="label">${label}</span>
<span class="value" style="font-size:16px">${value}</span>
</div>
`).join('');
return true;
}
async function loadIntelligenceCompanies() {
const companies = await api('/admin/api/intelligence/companies');
const sel = document.getElementById('i-company');
sel.innerHTML = '<option value="">All companies</option>';
companies.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = `${c.name} (${c.ticker})`;
sel.appendChild(opt);
});
}
async function loadIntelligence() {
const view = document.getElementById('i-view').value;
const companyId = document.getElementById('i-company').value;
const type = document.getElementById('i-type').value;
const params = new URLSearchParams({ limit: PAGE, offset: intelOffset });
if (companyId) params.set('company_id', companyId);
if (view === 'knowledge') {
document.getElementById('i-type').parentElement.style.display = '';
if (type) params.set('type', type);
const data = await api(`/admin/api/intelligence/knowledge?${params}`);
document.getElementById('intel-thead').innerHTML = `
<tr><th>ID</th><th>Company</th><th>Event</th><th>Type</th><th>Data</th><th>Created</th></tr>`;
document.getElementById('intel-tbody').innerHTML = data.rows.map(r => {
let parsed = {};
try { parsed = JSON.parse(r.data); } catch (_) {}
const summary = Object.values(parsed).filter(v => typeof v === 'string').join(' · ').slice(0, 120);
return `<tr>
<td style="color:#64748b">${r.id}</td>
<td style="white-space:nowrap">${r.company_name}</td>
<td style="color:#64748b">${r.event_id}</td>
<td><span class="badge null">${r.type}</span></td>
<td><span class="truncate" style="max-width:360px" title="${r.data.replace(/"/g,'&quot;')}">${summary}</span></td>
<td style="color:#64748b; white-space:nowrap">${r.created_at ? r.created_at.slice(0,16) : '—'}</td>
</tr>`;
}).join('');
const total = data.total;
document.getElementById('iPageInfo').textContent =
`${intelOffset + 1}${Math.min(intelOffset + PAGE, total)} of ${total.toLocaleString()}`;
document.getElementById('iPrevBtn').disabled = intelOffset === 0;
document.getElementById('iNextBtn').disabled = intelOffset + PAGE >= total;
} else {
document.getElementById('i-type').parentElement.style.display = 'none';
const data = await api(`/admin/api/intelligence/predictions?${params}`);
document.getElementById('intel-thead').innerHTML = `
<tr><th>ID</th><th>Company</th><th>Event</th><th>Type</th><th>Direction</th><th>Magnitude</th><th>Timeframe</th><th>Rationale</th><th>Created</th></tr>`;
document.getElementById('intel-tbody').innerHTML = data.rows.map(r => `
<tr>
<td style="color:#64748b">${r.id}</td>
<td style="white-space:nowrap">${r.company_name}</td>
<td style="color:#64748b">${r.event_id}</td>
<td><span class="badge null">${r.type}</span></td>
<td>${r.direction || '—'}</td>
<td>${r.magnitude || '—'}</td>
<td>${r.timeframe || '—'}</td>
<td><span class="truncate" style="max-width:300px" title="${(r.rationale||'').replace(/"/g,'&quot;')}">${r.rationale || '—'}</span></td>
<td style="color:#64748b; white-space:nowrap">${r.created_at ? r.created_at.slice(0,16) : '—'}</td>
</tr>
`).join('');
const total = data.total;
document.getElementById('iPageInfo').textContent =
`${intelOffset + 1}${Math.min(intelOffset + PAGE, total)} of ${total.toLocaleString()}`;
document.getElementById('iPrevBtn').disabled = intelOffset === 0;
document.getElementById('iNextBtn').disabled = intelOffset + PAGE >= total;
}
}
document.getElementById('iPrevBtn').onclick = () => { intelOffset = Math.max(0, intelOffset - PAGE); loadIntelligence(); };
document.getElementById('iNextBtn').onclick = () => { intelOffset += PAGE; loadIntelligence(); };
document.getElementById('i-view').onchange = () => { intelOffset = 0; loadIntelligence(); };
// ── sql console ────────────────────────────────────────────────────────────
async function runSql() {
const sql = document.getElementById('sql-input').value.trim();
if (!sql) return;
const database = document.getElementById('sql-db').value;
const errEl = document.getElementById('sql-error');
const resultsEl = document.getElementById('sql-results');
const elapsedEl = document.getElementById('sql-elapsed');
errEl.style.display = 'none';
resultsEl.innerHTML = '';
elapsedEl.textContent = '';
try {
const data = await fetch('/admin/api/sql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sql, database }),
}).then(r => r.json());
if (data.error) {
errEl.textContent = data.error;
errEl.style.display = '';
return;
}
elapsedEl.textContent = `${data.elapsed}ms`;
if (data.rows && data.rows.length > 0) {
const cols = Object.keys(data.rows[0]);
resultsEl.innerHTML = `
<table>
<thead><tr>${cols.map(c => `<th>${c}</th>`).join('')}</tr></thead>
<tbody>${data.rows.map(r =>
`<tr>${cols.map(c => `<td><span class="truncate" style="max-width:300px" title="${String(r[c] ?? '').replace(/"/g,'&quot;')}">${r[c] ?? '<span style="color:#64748b">NULL</span>'}</span></td>`).join('')}</tr>`
).join('')}</tbody>
</table>
<div style="color:#64748b; font-size:12px; margin-top:8px">${data.rows.length} row${data.rows.length !== 1 ? 's' : ''}</div>
`;
} else if (data.rows) {
resultsEl.innerHTML = '<div style="color:#64748b; font-size:13px">No rows returned.</div>';
} else {
resultsEl.innerHTML = `<div style="color:#86efac; font-size:13px">${data.changes} row${data.changes !== 1 ? 's' : ''} affected.</div>`;
}
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = '';
}
}
document.getElementById('sql-run-btn').onclick = runSql;
document.getElementById('sql-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) runSql();
});
// ── tabs ─────────────────────────────────────────────────────────────────── // ── tabs ───────────────────────────────────────────────────────────────────
const tabContents = { const tabContents = {
articles: document.getElementById('tab-articles'), articles: document.getElementById('tab-articles'),
events: document.getElementById('tab-events'), events: document.getElementById('tab-events'),
stats: document.getElementById('tab-stats'), stats: document.getElementById('tab-stats'),
intelligence: document.getElementById('tab-intelligence'),
sql: document.getElementById('tab-sql'),
}; };
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {
@ -671,6 +909,7 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
}); });
if (tab === 'events') loadEvents(); if (tab === 'events') loadEvents();
if (tab === 'stats') loadStats(); if (tab === 'stats') loadStats();
if (tab === 'intelligence') { intelOffset = 0; loadIntelligenceStats().then(ok => { if (ok) { loadIntelligenceCompanies(); loadIntelligence(); } }); }
}; };
}); });

View file

@ -1,6 +1,6 @@
{ {
"duriin_db": "/data/archive.sqlite", "duriin_db": "./archive.sqlite",
"intelligence_db": "/data/intelligence.sqlite", "intelligence_db": "./intelligence.sqlite",
"llm": { "llm": {
"baseUrl": "https://openrouter.ai/api/v1", "baseUrl": "https://openrouter.ai/api/v1",
"model": "qwen/qwen3-235b-a22b-2507", "model": "qwen/qwen3-235b-a22b-2507",

View file

@ -22,6 +22,8 @@ services:
- ./data:/data - ./data:/data
environment: environment:
NODE_ENV: production NODE_ENV: production
DURIIN_DB: /data/archive.sqlite
INTELLIGENCE_DB: /data/intelligence.sqlite
restart: unless-stopped restart: unless-stopped
networks: networks:
- nginx_proxy_manager_default - nginx_proxy_manager_default

View file

@ -1,4 +1,5 @@
const Database = require("better-sqlite3"); const Database = require("better-sqlite3");
const sqliteVec = require("sqlite-vec");
let archiveDb = null; let archiveDb = null;
let intelligenceDb = null; let intelligenceDb = null;
@ -6,6 +7,7 @@ let intelligenceDb = null;
function getArchiveDb(dbPath) { function getArchiveDb(dbPath) {
if (!archiveDb) { if (!archiveDb) {
archiveDb = new Database(dbPath, { readonly: true }); archiveDb = new Database(dbPath, { readonly: true });
sqliteVec.load(archiveDb);
archiveDb.pragma("journal_mode = WAL"); archiveDb.pragma("journal_mode = WAL");
} }
return archiveDb; return archiveDb;

View file

@ -17,8 +17,8 @@ function resolvePath(p, fallback) {
} }
const config = { const config = {
duriin_db: resolvePath(rawConfig.duriin_db, path.resolve(configDir, "archive.sqlite")), duriin_db: process.env.DURIIN_DB || resolvePath(rawConfig.duriin_db, path.resolve(configDir, "archive.sqlite")),
intelligence_db: resolvePath(rawConfig.intelligence_db, path.resolve(configDir, "intelligence.sqlite")), intelligence_db: process.env.INTELLIGENCE_DB || resolvePath(rawConfig.intelligence_db, path.resolve(configDir, "intelligence.sqlite")),
llm: rawConfig.llm || {}, llm: rawConfig.llm || {},
workers: rawConfig.workers || {}, workers: rawConfig.workers || {},
openRouter: rawConfig.openRouter || {}, openRouter: rawConfig.openRouter || {},

View file

@ -4,7 +4,8 @@
"description": "News ingestion archive server", "description": "News ingestion archive server",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js",
"intelligence": "node intelligence/index.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

236
src/db.js
View file

@ -9,8 +9,6 @@ sqliteVec.load(db);
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
// the image column is retained as a no-op for backwards compat with old rows.
// new code never writes to it; drop in a future migration if you really want
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS articles ( CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -21,97 +19,29 @@ db.exec(`
content_status TEXT, content_status TEXT,
content_error TEXT, content_error TEXT,
content_attempted_at TEXT, content_attempted_at TEXT,
content_attempt_count INTEGER NOT NULL DEFAULT 0,
content_retry_after TEXT,
is_index_page INTEGER NOT NULL DEFAULT 0, is_index_page INTEGER NOT NULL DEFAULT 0,
has_embedding INTEGER NOT NULL DEFAULT 0,
url TEXT NOT NULL UNIQUE, url TEXT NOT NULL UNIQUE,
normalized_title TEXT NOT NULL, normalized_title TEXT NOT NULL,
source TEXT NOT NULL, source TEXT NOT NULL,
pub_date TEXT, pub_date TEXT,
pub_date_effective TEXT,
language TEXT,
event_id INTEGER REFERENCES events(id),
ingested_at TEXT NOT NULL DEFAULT (datetime('now')) ingested_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
`); `);
function rebuildArticlesTableIfNeeded() {
const indexes = db.prepare(`PRAGMA index_list('articles')`).all();
const hasUniqueNormalizedTitleIndex = indexes.some((index) => {
if (index.origin !== 'u' || !index.name) {
return false;
}
const columns = db.prepare(`PRAGMA index_info('${index.name.replace(/'/g, "''")}')`).all();
return columns.length === 1 && columns[0].name === 'normalized_title';
});
if (!hasUniqueNormalizedTitleIndex) {
return;
}
db.exec(`
BEGIN;
CREATE TABLE articles_rebuild (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
content TEXT,
image TEXT,
content_status TEXT,
content_error TEXT,
content_attempted_at TEXT,
is_index_page INTEGER NOT NULL DEFAULT 0,
url TEXT NOT NULL UNIQUE,
normalized_title TEXT NOT NULL,
source TEXT NOT NULL,
pub_date TEXT,
ingested_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO articles_rebuild (
id,
title,
description,
content,
image,
content_status,
content_error,
content_attempted_at,
is_index_page,
url,
normalized_title,
source,
pub_date,
ingested_at
)
SELECT
id,
title,
description,
content,
image,
content_status,
content_error,
content_attempted_at,
0,
url,
normalized_title,
source,
pub_date,
ingested_at
FROM articles;
DROP TABLE articles;
ALTER TABLE articles_rebuild RENAME TO articles;
COMMIT;
`);
}
rebuildArticlesTableIfNeeded();
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_articles_source ON articles(source); CREATE INDEX IF NOT EXISTS idx_articles_source ON articles(source);
CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date); CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
CREATE INDEX IF NOT EXISTS idx_articles_ingested_at ON articles(ingested_at); CREATE INDEX IF NOT EXISTS idx_articles_ingested_at ON articles(ingested_at);
CREATE INDEX IF NOT EXISTS idx_articles_normalized_title ON articles(normalized_title); CREATE INDEX IF NOT EXISTS idx_articles_normalized_title ON articles(normalized_title);
CREATE INDEX IF NOT EXISTS idx_articles_event_id ON articles(event_id);
CREATE INDEX IF NOT EXISTS idx_articles_has_embedding ON articles(has_embedding);
CREATE INDEX IF NOT EXISTS idx_articles_pub_date_effective ON articles(pub_date_effective DESC);
`); `);
db.exec(` db.exec(`
@ -132,93 +62,22 @@ db.exec(`
); );
`); `);
// vec0 table — fixed at 8192 dims to cover any model on openrouter, shorter embeddings get zero-padded db.exec(`
{ CREATE VIRTUAL TABLE IF NOT EXISTS article_embeddings USING vec0(
const existing = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'article_embeddings'`).get(); article_id INTEGER PRIMARY KEY,
const currentDim = existing && existing.sql && existing.sql.match(/FLOAT\[(\d+)\]/); embedding FLOAT[8192]
const needsMigration = existing && (!currentDim || parseInt(currentDim[1], 10) !== 8192); );
`);
if (needsMigration) { db.exec(`
// save everything in vec0 to the store before dropping it, keyed by whatever model is in meta CREATE TABLE IF NOT EXISTS query_embeddings (
try { query TEXT NOT NULL,
const BATCH = 500; model TEXT NOT NULL,
let offset = 0; embedding BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
const fetchBatch = db.prepare(` PRIMARY KEY (query, model)
SELECT e.article_id, m.model, e.embedding );
FROM article_embeddings e `);
JOIN article_embedding_meta m ON m.article_id = e.article_id
LIMIT ? OFFSET ?
`);
const insert = db.prepare(`
INSERT OR IGNORE INTO article_embedding_store (article_id, model, embedding)
VALUES (?, ?, ?)
`);
const insertMany = db.transaction((rows) => {
for (const row of rows) insert.run(row.article_id, row.model, row.embedding);
});
while (true) {
const rows = fetchBatch.all(BATCH, offset);
if (rows.length === 0) break;
insertMany(rows);
offset += rows.length;
if (rows.length < BATCH) break;
}
} catch (err) {
console.error('failed to rescue embeddings from vec0 before migration:', err);
}
db.exec(`DROP TABLE article_embeddings`);
db.exec(`DELETE FROM article_embedding_meta`);
}
if (!existing || needsMigration) {
db.exec(`
CREATE VIRTUAL TABLE article_embeddings USING vec0(
article_id INTEGER PRIMARY KEY,
embedding FLOAT[8192]
);
`);
}
}
// migrate query_embeddings to include model in primary key
{
const cols = db.prepare(`PRAGMA table_info(query_embeddings)`).all();
const hasModel = cols.some(c => c.name === 'model');
if (!hasModel) {
db.exec(`
BEGIN;
CREATE TABLE query_embeddings_new (
query TEXT NOT NULL,
model TEXT NOT NULL,
embedding BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (query, model)
);
DROP TABLE IF EXISTS query_embeddings;
ALTER TABLE query_embeddings_new RENAME TO query_embeddings;
COMMIT;
`);
} else {
db.exec(`
CREATE TABLE IF NOT EXISTS query_embeddings (
query TEXT NOT NULL,
model TEXT NOT NULL,
embedding BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (query, model)
);
`);
}
}
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS events ( CREATE TABLE IF NOT EXISTS events (
@ -228,22 +87,6 @@ db.exec(`
); );
`); `);
for (const statement of [
'ALTER TABLE articles ADD COLUMN event_id INTEGER REFERENCES events(id)',
]) {
try {
db.exec(statement);
} catch (error) {
if (!String(error.message).includes('duplicate column name')) {
throw error;
}
}
}
db.exec(`
CREATE INDEX IF NOT EXISTS idx_articles_event_id ON articles(event_id);
`);
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS gdelt_backfill_windows ( CREATE TABLE IF NOT EXISTS gdelt_backfill_windows (
source_id TEXT NOT NULL, source_id TEXT NOT NULL,
@ -287,9 +130,6 @@ db.exec(`
); );
`); `);
// per-domain fetch policy — caches whether plain http or browser is needed
// so we dont waste a round trip on every article from a known js-only site.
// expires_at lets us re-probe domains that may have recovered
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS domain_fetch_policy ( CREATE TABLE IF NOT EXISTS domain_fetch_policy (
domain TEXT PRIMARY KEY, domain TEXT PRIMARY KEY,
@ -303,30 +143,4 @@ db.exec(`
); );
`); `);
for (const statement of [ module.exports = db;
'ALTER TABLE articles ADD COLUMN image TEXT',
'ALTER TABLE articles ADD COLUMN content_status TEXT',
'ALTER TABLE articles ADD COLUMN content_error TEXT',
'ALTER TABLE articles ADD COLUMN content_attempted_at TEXT',
'ALTER TABLE articles ADD COLUMN content_attempt_count INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE articles ADD COLUMN content_retry_after TEXT',
'ALTER TABLE articles ADD COLUMN is_index_page INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE articles ADD COLUMN has_embedding INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE articles ADD COLUMN pub_date_effective TEXT',
'ALTER TABLE articles ADD COLUMN language TEXT'
]) {
try {
db.exec(statement);
} catch (error) {
if (!String(error.message).includes('duplicate column name')) {
throw error;
}
}
}
db.exec(`
CREATE INDEX IF NOT EXISTS idx_articles_has_embedding ON articles(has_embedding);
CREATE INDEX IF NOT EXISTS idx_articles_pub_date_effective ON articles(pub_date_effective DESC);
`);
module.exports = db;

View file

@ -2,6 +2,24 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const db = require('../db'); const db = require('../db');
const config = require('../config'); 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, { readonly: true });
return idb;
}
const adminUser = (config.admin && config.admin.username) || 'admin'; const adminUser = (config.admin && config.admin.username) || 'admin';
const adminPass = (config.admin && config.admin.password) || 'changeme'; const adminPass = (config.admin && config.admin.password) || 'changeme';
@ -204,6 +222,122 @@ async function adminRoutes(fastify) {
return { ok: true }; 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;
return { available: true, queue, knowledge, predictions, companies, embeddings };
});
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 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.created_at,
tc.name as company_name
FROM event_knowledge ek
JOIN tracked_companies tc ON tc.id = ek.company_id
${where}
ORDER BY ek.id 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 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 ep.id DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
return { total, rows };
});
// raw sql console
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' }; }
try {
const stmt = target.prepare(sql);
const start = Date.now();
let rows, changes, lastInsertRowid;
if (stmt.reader) {
rows = stmt.all();
} else {
const info = stmt.run();
changes = info.changes;
lastInsertRowid = info.lastInsertRowid;
}
return {
rows: rows || null,
changes: changes ?? null,
lastInsertRowid: lastInsertRowid ?? null,
elapsed: Date.now() - start,
};
} catch (err) {
reply.code(400);
return { error: err.message };
}
});
// stats for dashboard header // stats for dashboard header
fastify.get('/admin/api/stats', async (request, reply) => { fastify.get('/admin/api/stats', async (request, reply) => {
if (!checkAuth(request, reply)) return; if (!checkAuth(request, reply)) return;

View file

@ -1,26 +1,34 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os');
const config = require('../config'); const config = require('../config');
const db = require('../db');
async function devRoutes(fastify) { async function devRoutes(fastify) {
if (!config.dev || !config.dev.enabled) return; if (!config.dev || !config.dev.enabled) return;
fastify.get('/dev/db/download', async (req, reply) => { fastify.get('/dev/db/download', async (req, reply) => {
const dbPath = path.resolve(config.duriin_db || './archive.sqlite'); const tmpPath = path.join(os.tmpdir(), `duriin_snapshot_${Date.now()}.sqlite`);
if (!fs.existsSync(dbPath)) { try {
return reply.code(404).send({ error: 'database file not found' }); // VACUUM INTO gives us a consistent, defragmented copy with no mid-write corruption
db.prepare(`VACUUM INTO ?`).run(tmpPath);
const stat = fs.statSync(tmpPath);
reply.header('Content-Type', 'application/octet-stream');
reply.header('Content-Disposition', 'attachment; filename="archive.sqlite"');
reply.header('Content-Length', stat.size);
const stream = fs.createReadStream(tmpPath);
stream.on('close', () => fs.unlink(tmpPath, () => {}));
return reply.send(stream);
} catch (err) {
fs.unlink(tmpPath, () => {});
throw err;
} }
const stat = fs.statSync(dbPath);
const filename = path.basename(dbPath);
reply.header('Content-Type', 'application/octet-stream');
reply.header('Content-Disposition', `attachment; filename="${filename}"`);
reply.header('Content-Length', stat.size);
return reply.send(fs.createReadStream(dbPath));
}); });
} }