feat: overhaul Duriin autonomy console

This commit is contained in:
ImBenji
2026-08-04 21:05:47 +01:00
parent 0724f5dc36
commit 5037e1e192
10 changed files with 793 additions and 374 deletions
+97 -8
View File
@@ -54,6 +54,7 @@ const pagesDir = path.join(publicDir, 'pages');
// map pretty url → page html file. keep these close to the routes so its
// obvious when a page gets added or renamed.
const pageMap = {
'/admin/autonomy': path.join(pagesDir, 'autonomy.html'),
'/admin/ingest/articles': path.join(pagesDir, 'ingest', 'articles.html'),
'/admin/ingest/events': path.join(pagesDir, 'ingest', 'events.html'),
'/admin/stats': path.join(pagesDir, 'stats.html'),
@@ -77,21 +78,19 @@ async function adminRoutes(fastify) {
if (!checkAuth(request, reply)) return reply;
});
// static assets (css + js) under /admin/assets/*
// cache for an hour — avoids per-navigation revalidation round-trips
// for the admin panel. during dev use a hard-reload (cmd-shift-r)
// or bump the script src query string to bust it.
// Static assets are revalidated so an operator never runs stale UI code
// against a newly deployed autonomy API.
fastify.register(fastifyStatic, {
root: assetsDir,
prefix: '/admin/assets/',
decorateReply: false,
cacheControl: true,
maxAge: 3600 * 1000, // 1h, in ms (fastify-static forwards to send())
maxAge: 0,
});
// top-level entry — redirect into ingest/articles
// top-level entry — the autonomy control room is the primary product surface
fastify.get('/admin', async (request, reply) => {
reply.redirect('/admin/ingest/articles');
reply.redirect('/admin/autonomy');
});
// ingest root — redirect to the articles subsection
@@ -113,6 +112,96 @@ async function adminRoutes(fastify) {
fastify.get(route, async (request, reply) => sendPage(reply, filePath));
}
// Autonomy control-room data. Keep this behind admin auth: it exposes model
// output, broker state and operational queue details that do not belong on the
// public status endpoint.
fastify.get('/admin/api/autonomy/overview', async (request, reply) => {
if (!checkAuth(request, reply)) return;
const intelligenceDb = getIntelligenceDb();
if (!intelligenceDb) return { enabled: false, reason: 'intelligence database unavailable' };
const hasSchema = intelligenceDb.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='autonomy_jobs'"
).get();
if (!hasSchema) return { enabled: false, reason: 'autonomy schema is not initialized' };
const jobs = intelligenceDb.prepare(`
SELECT lane, status, COUNT(*) AS count
FROM autonomy_jobs GROUP BY lane, status ORDER BY lane, status
`).all();
const predictionCounts = intelligenceDb.prepare(`
SELECT status, COUNT(*) AS count FROM autonomy_predictions GROUP BY status
`).all();
const decisionCounts = intelligenceDb.prepare(`
SELECT action, COUNT(*) AS count FROM autonomy_decisions GROUP BY action
`).all();
const proposalCounts = intelligenceDb.prepare(`
SELECT status, COUNT(*) AS count FROM autonomy_proposals GROUP BY status
`).all();
const outcomeSummary = intelligenceDb.prepare(`
SELECT COUNT(*) AS total, SUM(direction_correct) AS correct,
AVG(excess_return) AS average_excess_return
FROM autonomy_outcomes
`).get();
const instruments = intelligenceDb.prepare(`
SELECT COUNT(*) AS count FROM autonomy_instruments WHERE active=1 AND tradable=1
`).get();
const latestPredictions = intelligenceDb.prepare(`
SELECT p.id, p.instrument, p.direction, p.event_type, p.causal_channel,
p.horizon_days, p.information_cutoff, p.evidence_article_ids,
p.invalidation_condition, p.learning_eligible, p.status, p.created_at,
d.action, d.calibrated_probability, d.expected_excess_return, d.rationale,
o.excess_return, o.direction_correct
FROM autonomy_predictions p
LEFT JOIN autonomy_decisions d ON d.id = (
SELECT MAX(d2.id) FROM autonomy_decisions d2 WHERE d2.prediction_id = p.id
)
LEFT JOIN autonomy_outcomes o ON o.prediction_id = p.id
ORDER BY p.id DESC LIMIT 12
`).all().map((row) => {
let evidenceCount = 0;
try { evidenceCount = JSON.parse(row.evidence_article_ids || '[]').length; } catch (_) {}
const { evidence_article_ids: ignored, ...safeRow } = row;
return { ...safeRow, evidence_count: evidenceCount };
});
const latestOrders = intelligenceDb.prepare(`
SELECT oi.id, oi.client_order_id, oi.instrument, oi.side, oi.notional,
oi.status, oi.broker_order_id, oi.attempts, oi.last_error,
oi.created_at, oi.updated_at, d.action
FROM autonomy_order_intents oi
JOIN autonomy_decisions d ON d.id = oi.decision_id
ORDER BY oi.id DESC LIMIT 12
`).all();
const account = intelligenceDb.prepare(`
SELECT broker, equity, cash, buying_power, captured_at
FROM autonomy_account_snapshots ORDER BY id DESC LIMIT 1
`).get() || null;
const calibration = intelligenceDb.prepare(`
SELECT cohort_key, sample_size, effective_sample_size, directional_probability,
expected_excess_return, lower_return, upper_return, created_at
FROM autonomy_calibration_snapshots ORDER BY id DESC LIMIT 8
`).all();
return {
enabled: true,
mode: process.env.AUTONOMY_EXECUTION_MODE || 'shadow',
broker: {
name: 'Alpaca Paper',
configured: Boolean(process.env.ALPACA_PAPER_KEY_ID && process.env.ALPACA_PAPER_SECRET_KEY),
},
jobs,
predictionCounts,
decisionCounts,
proposalCounts,
outcomes: outcomeSummary,
allowlistedInstruments: instruments.count,
latestPredictions,
latestOrders,
account,
calibration,
generatedAt: new Date().toISOString(),
};
});
// list articles — all of them, not just the ones with embeddings
fastify.get('/admin/api/articles', async (request, reply) => {
if (!checkAuth(request, reply)) return;
@@ -779,4 +868,4 @@ async function adminRoutes(fastify) {
});
}
module.exports = adminRoutes;
module.exports = adminRoutes;