feat: M1 admin console — all 10 remaining pages + signal/quality/ops infrastructure

Admin console (issues #63–72):
- Event stream viewer: live-tail ring buffer (500 events) with subject/user filters
- Feature store browser: per-user feature vector history from ml/serving
- Model registry panel: MLflow embed at /admin/models
- Experiment dashboard: LinUCB per-user stats (pulls, reward, θ) + bandit reset
- Recommendation log: per-tip explainability (policy, score, features, latency)
- Reward analytics: daily reaction breakdown + per-policy compare
- Data quality widget: missing-feature rate, stale-token rate, daily completeness
- Ops actions: replay-signal, policy enable/disable; user actions link to Users page
- SQL runner: read-only SELECT runner with saved queries
- Health rollup: fan-out to api/ml/sqlite/event-bus with auto-refresh

Backend:
- tip_scores table: logs features+policy+score+latency at every scoring call (#67)
- saved_queries table: per-admin saved SQL (#71)
- Event bus: 500-event ring buffer + tail() API (#63)
- Admin routes: /events, /tips, /reward-analytics, /data-quality, /health,
  /policies, /replay-signal, /sql, /saved-queries endpoints
- /api/ml/* admin-gated proxy to ml/serving (#64, #66)
- Shadow-policy registry in recommender (#56)

ML serving:
- /reset/{user_id}: clear bandit state + feature history (#66)
- /stats/{user_id}: pulls, cumulative reward, estimated mean, θ (#66)
- /features/{user_id}: last 100 feature vectors logged at scoring time (#64)
- Meta (pulls, rewards) persisted alongside A/b matrices

Web:
- Tip action sheet adds Helpful / Not helpful buttons (#62)
- TipFeedback type extended with helpful/not_helpful actions
- Rewards mapped: helpful=+0.5, not_helpful=−0.5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 03:56:48 +00:00
parent 2402a140e9
commit e62c726ea4
37 changed files with 3386 additions and 38 deletions

View File

@@ -8,6 +8,9 @@ sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
// Raw sqlite client — used by the SQL runner endpoint.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const rawSqlite: any = sqlite;
export function runMigrations() {
sqlite.exec(`
@@ -17,6 +20,7 @@ export function runMigrations() {
name TEXT,
image TEXT,
google_id TEXT UNIQUE,
role TEXT NOT NULL DEFAULT 'user',
consent_given INTEGER NOT NULL DEFAULT 0,
consent_at TEXT,
created_at TEXT NOT NULL,
@@ -43,11 +47,72 @@ export function runMigrations() {
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tip_views (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
tip_id TEXT NOT NULL,
served_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_actions (
id TEXT PRIMARY KEY,
admin_id TEXT NOT NULL REFERENCES users(id),
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tip_scores (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
tip_id TEXT NOT NULL,
policy TEXT NOT NULL,
ml_score INTEGER,
features_json TEXT,
candidate_count INTEGER,
latency_ms INTEGER,
served_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS saved_queries (
id TEXT PRIMARY KEY,
admin_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
sql TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
// Additive column migrations — safe to run on existing DBs.
// SQLite doesn't support IF NOT EXISTS on ALTER TABLE; we ignore the error if already present.
for (const stmt of [
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`,
`ALTER TABLE push_subscriptions ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`,
]) {
try { sqlite.exec(stmt); } catch { /* column already exists */ }
}
// Seed first admin from env (ADMIN_SEED_EMAIL).
const seedEmail = process.env.ADMIN_SEED_EMAIL;
if (seedEmail) {
sqlite.prepare(`UPDATE users SET role = 'admin' WHERE email = ? AND role = 'user'`).run(seedEmail);
}
}

View File

@@ -6,6 +6,7 @@ export const users = sqliteTable('users', {
name: text('name'),
image: text('image'),
googleId: text('google_id').unique(),
role: text('role').notNull().default('user'), // 'user' | 'admin'
consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false),
consentAt: text('consent_at'),
createdAt: text('created_at').notNull(),
@@ -54,3 +55,37 @@ export const sessions = sqliteTable('sessions', {
expiresAt: text('expires_at').notNull(),
createdAt: text('created_at').notNull(),
});
// Audit log — every admin write action is appended here.
export const adminActions = sqliteTable('admin_actions', {
id: text('id').primaryKey(),
adminId: text('admin_id').notNull().references(() => users.id),
action: text('action').notNull(), // e.g. 'revoke_token', 'reset_bandit'
targetType: text('target_type'), // e.g. 'user', 'integration'
targetId: text('target_id'),
detail: text('detail'), // JSON blob for extra context
createdAt: text('created_at').notNull(),
});
// Recommendation explainability log — one row per tip served.
// features/scores are JSON blobs. Retained 30 days (GDPR).
export const tipScores = sqliteTable('tip_scores', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
tipId: text('tip_id').notNull(),
policy: text('policy').notNull(),
mlScore: integer('ml_score', { mode: 'number' }), // null when random fallback
featuresJson: text('features_json'), // JSON: { is_overdue, task_age_days, priority, hour_of_day, day_of_week }
candidateCount: integer('candidate_count'),
latencyMs: integer('latency_ms'),
servedAt: text('served_at').notNull(),
});
// Admin saved SQL queries.
export const savedQueries = sqliteTable('saved_queries', {
id: text('id').primaryKey(),
adminId: text('admin_id').notNull().references(() => users.id),
name: text('name').notNull(),
sql: text('sql').notNull(),
createdAt: text('created_at').notNull(),
});

View File

@@ -22,7 +22,7 @@ export type TipServedEvent = {
export type TipFeedbackEvent = {
userId: string;
tipId: string;
action: 'done' | 'dismiss' | 'snooze';
action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful';
reward: number;
createdAt: string;
};
@@ -39,14 +39,56 @@ type EventMap = {
'signals.task.synced': TaskSyncedEvent;
};
export type StoredEvent = {
id: number;
subject: string;
payload: unknown;
ts: string;
};
const RING_SIZE = 500;
class Bus extends EventEmitter {
private ring: StoredEvent[] = [];
private seq = 0;
publish<K extends keyof EventMap>(subject: K, payload: EventMap[K]): void {
const entry: StoredEvent = {
id: ++this.seq,
subject,
payload,
ts: new Date().toISOString(),
};
if (this.ring.length >= RING_SIZE) this.ring.shift();
this.ring.push(entry);
this.emit(subject, payload);
}
subscribe<K extends keyof EventMap>(subject: K, handler: (payload: EventMap[K]) => void): void {
this.on(subject, handler);
}
/**
* Return recent events from the ring buffer.
* @param subject optional filter (prefix match, e.g. "signals.tip")
* @param userId optional user ID filter
* @param limit max events to return (default 100)
* @param since only events with id > since
*/
tail(opts: { subject?: string; userId?: string; limit?: number; since?: number } = {}): StoredEvent[] {
const { subject, userId, limit = 100, since = 0 } = opts;
let results = this.ring.filter((e) => {
if (e.id <= since) return false;
if (subject && !e.subject.startsWith(subject)) return false;
if (userId) {
const p = e.payload as Record<string, unknown>;
if (p.userId !== userId) return false;
}
return true;
});
if (results.length > limit) results = results.slice(results.length - limit);
return results;
}
}
export const bus = new Bus();

View File

@@ -10,8 +10,12 @@ import { integrationsRouter } from './routes/integrations.js';
import { recommenderRouter } from './routes/recommender.js';
import { userRouter } from './routes/user.js';
import { pushRouter } from './routes/push.js';
import { adminRouter } from './routes/admin.js';
import { mkdir } from 'fs/promises';
import { dirname } from 'path';
import { requireAuth } from './middleware/session.js';
import { requireAdmin } from './middleware/admin.js';
import type { Request, Response } from 'express';
await mkdir(dirname(config.DATABASE_PATH), { recursive: true });
runMigrations();
@@ -35,6 +39,27 @@ app.use('/api/integrations', integrationsRouter);
app.use('/api', recommenderRouter);
app.use('/api/user', userRouter);
app.use('/api/push', pushRouter);
app.use('/api/admin', adminRouter);
// Proxy ml/serving endpoints through the API (admin-only).
// Allows admin UI to call /api/ml/stats/:userId, /api/ml/features/:userId
// without needing direct access to the ml/serving port.
app.use('/api/ml', requireAuth as any, requireAdmin as any, async (req: Request, res: Response) => {
const mlUrl = config.ML_SERVING_URL;
const target = `${mlUrl}${req.path}`;
try {
const upstream = await fetch(target, {
method: req.method,
headers: { 'Content-Type': 'application/json' },
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined,
signal: AbortSignal.timeout(5000),
});
const data = await upstream.json();
res.status(upstream.status).json(data);
} catch (e: any) {
res.status(502).json({ error: 'ml/serving unavailable', detail: e.message });
}
});
app.listen(config.PORT, () => {
console.log(`oO API listening on http://localhost:${config.PORT}`);

View File

@@ -0,0 +1,609 @@
import { type Router as ExpressRouter, Router, Response } from 'express';
import { db, rawSqlite } from '../db/index.js';
import {
users,
integrationTokens,
tipViews,
tipFeedback,
adminActions,
tipScores,
savedQueries,
} from '../db/schema.js';
import { eq, desc, sql, gte, and, isNull, lt } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
import { requireAdmin } from '../middleware/admin.js';
import { nanoid } from 'nanoid';
import { bus } from '../events/bus.js';
import { config } from '../config.js';
import { getShadowPolicies, setPolicyActive } from './recommender.js';
const router: ExpressRouter = Router();
router.use(requireAuth, requireAdmin);
// ---------------------------------------------------------------------------
// GET /api/admin/stats
// ---------------------------------------------------------------------------
router.get('/stats', async (_req: AuthenticatedRequest, res: Response) => {
const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
const [dauRow] = await db
.select({ count: sql<number>`count(distinct user_id)` })
.from(tipViews)
.where(gte(tipViews.servedAt, dayAgo));
const [wauRow] = await db
.select({ count: sql<number>`count(distinct user_id)` })
.from(tipViews)
.where(gte(tipViews.servedAt, weekAgo));
const [tipsRow] = await db
.select({ count: sql<number>`count(*)` })
.from(tipViews)
.where(gte(tipViews.servedAt, weekAgo));
const reactionRows = await db
.select({ action: tipFeedback.action, count: sql<number>`count(*)` })
.from(tipFeedback)
.where(gte(tipFeedback.createdAt, weekAgo))
.groupBy(tipFeedback.action);
const reactions: Record<string, number> = {};
for (const row of reactionRows) reactions[row.action] = Number(row.count);
const [totalUsersRow] = await db
.select({ count: sql<number>`count(*)` })
.from(users)
.where(isNull(users.deletedAt));
const [activatedRow] = await db
.select({ count: sql<number>`count(distinct user_id)` })
.from(tipViews);
res.json({
dau: Number(dauRow?.count ?? 0),
wau: Number(wauRow?.count ?? 0),
tipsServedLast7d: Number(tipsRow?.count ?? 0),
reactionsLast7d: reactions,
totalUsers: Number(totalUsersRow?.count ?? 0),
activatedUsers: Number(activatedRow?.count ?? 0),
});
});
// ---------------------------------------------------------------------------
// GET /api/admin/users?limit=50&offset=0
// ---------------------------------------------------------------------------
router.get('/users', async (req: AuthenticatedRequest, res: Response) => {
const limit = Math.min(Number(req.query.limit ?? 50), 200);
const offset = Number(req.query.offset ?? 0);
const rows = await db
.select({
id: users.id,
email: users.email,
name: users.name,
image: users.image,
role: users.role,
consentGiven: users.consentGiven,
createdAt: users.createdAt,
deletedAt: users.deletedAt,
})
.from(users)
.orderBy(desc(users.createdAt))
.limit(limit)
.offset(offset);
const [countRow] = await db
.select({ count: sql<number>`count(*)` })
.from(users);
res.json({ users: rows, total: Number(countRow?.count ?? 0) });
});
// ---------------------------------------------------------------------------
// GET /api/admin/users/:id
// ---------------------------------------------------------------------------
router.get('/users/:id', async (req: AuthenticatedRequest, res: Response) => {
const userId = req.params.id as string;
const [user] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
const integrations = await db
.select({ provider: integrationTokens.provider, connectedAt: integrationTokens.connectedAt })
.from(integrationTokens)
.where(eq(integrationTokens.userId, user.id));
const [tipStats] = await db
.select({ count: sql<number>`count(*)` })
.from(tipViews)
.where(eq(tipViews.userId, user.id));
const recentFeedback = await db
.select()
.from(tipFeedback)
.where(eq(tipFeedback.userId, user.id))
.orderBy(desc(tipFeedback.createdAt))
.limit(20);
const [lastView] = await db
.select({ servedAt: tipViews.servedAt })
.from(tipViews)
.where(eq(tipViews.userId, user.id))
.orderBy(desc(tipViews.servedAt))
.limit(1);
res.json({
user: {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
role: user.role,
consentGiven: user.consentGiven,
consentAt: user.consentAt,
createdAt: user.createdAt,
deletedAt: user.deletedAt,
},
integrations,
tipsServed: Number(tipStats?.count ?? 0),
lastTipAt: lastView?.servedAt ?? null,
recentFeedback,
});
});
// ---------------------------------------------------------------------------
// POST /api/admin/users/:id/revoke-integration
// ---------------------------------------------------------------------------
router.post(
'/users/:id/revoke-integration',
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = req.params.id as string;
const { provider } = req.body as { provider: string };
if (!provider) {
res.status(400).json({ error: 'provider required' });
return;
}
const [token] = await db
.select()
.from(integrationTokens)
.where(
and(
eq(integrationTokens.userId, targetUserId),
eq(integrationTokens.provider, provider),
),
)
.limit(1);
if (!token) {
res.status(404).json({ error: 'Integration not found' });
return;
}
if (provider === 'todoist') {
await fetch('https://api.todoist.com/sync/v9/access_tokens/revoke', {
method: 'POST',
headers: { Authorization: `Bearer ${token.accessToken}` },
}).catch(() => {});
}
await db
.delete(integrationTokens)
.where(
and(
eq(integrationTokens.userId, targetUserId),
eq(integrationTokens.provider, provider),
),
);
await db.insert(adminActions).values({
id: nanoid(),
adminId: req.userId!,
action: 'revoke_integration',
targetType: 'integration',
targetId: token.id,
detail: JSON.stringify({ userId: targetUserId, provider }),
createdAt: new Date().toISOString(),
});
res.json({ ok: true });
},
);
// ---------------------------------------------------------------------------
// POST /api/admin/users/:id/reset-bandit
// ---------------------------------------------------------------------------
router.post(
'/users/:id/reset-bandit',
async (req: AuthenticatedRequest, res: Response) => {
const targetUserId = req.params.id as string;
const mlUrl = process.env.ML_SERVING_URL ?? 'http://localhost:8000';
let mlOk = true;
let mlError = '';
try {
const mlRes = await fetch(`${mlUrl}/reset/${targetUserId}`, { method: 'POST' });
mlOk = mlRes.ok;
if (!mlOk) mlError = mlRes.statusText;
} catch (e) {
mlOk = false;
mlError = String(e);
}
if (!mlOk) {
res.status(502).json({ error: 'ml/serving reset failed', detail: mlError });
return;
}
await db.insert(adminActions).values({
id: nanoid(),
adminId: req.userId!,
action: 'reset_bandit',
targetType: 'user',
targetId: targetUserId,
detail: null,
createdAt: new Date().toISOString(),
});
res.json({ ok: true });
},
);
// ---------------------------------------------------------------------------
// GET /api/admin/audit?limit=50&offset=0
// ---------------------------------------------------------------------------
router.get('/audit', async (req: AuthenticatedRequest, res: Response) => {
const limit = Math.min(Number(req.query.limit ?? 50), 200);
const offset = Number(req.query.offset ?? 0);
const rows = await db
.select()
.from(adminActions)
.orderBy(desc(adminActions.createdAt))
.limit(limit)
.offset(offset);
const [countRow] = await db
.select({ count: sql<number>`count(*)` })
.from(adminActions);
res.json({ actions: rows, total: Number(countRow?.count ?? 0) });
});
// ---------------------------------------------------------------------------
// GET /api/admin/events?subject=signals.tip&userId=&limit=100&since=0
// Returns recent events from the in-process ring buffer.
// ---------------------------------------------------------------------------
router.get('/events', async (req: AuthenticatedRequest, res: Response) => {
const subject = req.query.subject as string | undefined;
const userId = req.query.userId as string | undefined;
const limit = Math.min(Number(req.query.limit ?? 100), 500);
const since = Number(req.query.since ?? 0);
const events = bus.tail({ subject, userId, limit, since });
res.json({ events, nextSince: events.length > 0 ? events[events.length - 1].id : since });
});
// ---------------------------------------------------------------------------
// GET /api/admin/tips?limit=50&offset=0&userId=
// Recommendation log — per-tip explainability.
// ---------------------------------------------------------------------------
router.get('/tips', async (req: AuthenticatedRequest, res: Response) => {
const limit = Math.min(Number(req.query.limit ?? 50), 200);
const offset = Number(req.query.offset ?? 0);
const userId = req.query.userId as string | undefined;
const conditions = userId ? [eq(tipScores.userId, userId)] : [];
const rows = await db
.select()
.from(tipScores)
.where(conditions.length ? and(...(conditions as [ReturnType<typeof eq>])) : undefined)
.orderBy(desc(tipScores.servedAt))
.limit(limit)
.offset(offset);
const [countRow] = await db
.select({ count: sql<number>`count(*)` })
.from(tipScores)
.where(conditions.length ? and(...(conditions as [ReturnType<typeof eq>])) : undefined);
res.json({ tips: rows, total: Number(countRow?.count ?? 0) });
});
// ---------------------------------------------------------------------------
// GET /api/admin/reward-analytics?days=30
// Reaction distribution over time + per-policy compare.
// ---------------------------------------------------------------------------
router.get('/reward-analytics', async (req: AuthenticatedRequest, res: Response) => {
const days = Math.min(Number(req.query.days ?? 30), 90);
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
// Daily reaction counts
const dailyRows = await db
.select({
date: sql<string>`date(created_at)`,
action: tipFeedback.action,
count: sql<number>`count(*)`,
})
.from(tipFeedback)
.where(gte(tipFeedback.createdAt, since))
.groupBy(sql`date(created_at)`, tipFeedback.action)
.orderBy(sql`date(created_at)`);
// Per-policy reward distribution (tip_scores joined with tip_feedback)
const policyRows = await db
.select({
policy: tipScores.policy,
action: tipFeedback.action,
count: sql<number>`count(*)`,
})
.from(tipScores)
.leftJoin(tipFeedback, eq(tipScores.tipId, tipFeedback.tipId))
.where(gte(tipScores.servedAt, since))
.groupBy(tipScores.policy, tipFeedback.action);
// By hour_of_day (extracted from featuresJson)
const hourRows = await db
.select({
action: tipFeedback.action,
count: sql<number>`count(*)`,
avgHour: sql<number>`avg(json_extract(ts.features_json, '$.hour_of_day'))`,
})
.from(tipFeedback)
.leftJoin(tipScores, eq(tipFeedback.tipId, tipScores.tipId))
.where(gte(tipFeedback.createdAt, since))
.groupBy(tipFeedback.action);
res.json({
daily: dailyRows,
byPolicy: policyRows,
byHour: hourRows,
});
});
// ---------------------------------------------------------------------------
// GET /api/admin/data-quality
// Missing-signal rates, stale token rates, feature NaN heatmap.
// ---------------------------------------------------------------------------
router.get('/data-quality', async (req: AuthenticatedRequest, res: Response) => {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
// Total scoring calls in last 30d
const [totalRow] = await db
.select({ count: sql<number>`count(*)` })
.from(tipScores)
.where(gte(tipScores.servedAt, thirtyDaysAgo));
const total = Number(totalRow?.count ?? 0);
// Calls with no features (features_json IS NULL)
const [missingFeaturesRow] = await db
.select({ count: sql<number>`count(*)` })
.from(tipScores)
.where(and(gte(tipScores.servedAt, thirtyDaysAgo), isNull(tipScores.featuresJson)));
const missingFeatures = Number(missingFeaturesRow?.count ?? 0);
// Stale tokens: connected more than 7 days ago with no recent tip (proxy for stale)
const [staleTokensRow] = await db
.select({ count: sql<number>`count(*)` })
.from(integrationTokens)
.where(lt(integrationTokens.connectedAt, sevenDaysAgo));
const staleTokens = Number(staleTokensRow?.count ?? 0);
const [totalTokensRow] = await db
.select({ count: sql<number>`count(*)` })
.from(integrationTokens);
const totalTokens = Number(totalTokensRow?.count ?? 0);
// Daily feature completeness (last 14 days)
const dailyQuality = await db
.select({
date: sql<string>`date(served_at)`,
total: sql<number>`count(*)`,
withFeatures: sql<number>`sum(case when features_json is not null then 1 else 0 end)`,
avgCandidates: sql<number>`avg(candidate_count)`,
})
.from(tipScores)
.where(gte(tipScores.servedAt, new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString()))
.groupBy(sql`date(served_at)`)
.orderBy(sql`date(served_at)`);
res.json({
scoringCallsLast30d: total,
missingFeatureRate: total > 0 ? missingFeatures / total : 0,
staleTokenRate: totalTokens > 0 ? staleTokens / totalTokens : 0,
totalTokens,
staleTokens,
dailyQuality,
});
});
// ---------------------------------------------------------------------------
// GET /api/admin/health
// Fan-out to all subsystem /health endpoints.
// ---------------------------------------------------------------------------
router.get('/health', async (_req: AuthenticatedRequest, res: Response) => {
const checks: Array<{ name: string; url: string }> = [
{ name: 'api', url: `http://localhost:${process.env.PORT ?? 3001}/health` },
{ name: 'ml-serving', url: `${config.ML_SERVING_URL}/health` },
];
const results = await Promise.allSettled(
checks.map(async ({ name, url }) => {
const t0 = Date.now();
try {
const r = await fetch(url, { signal: AbortSignal.timeout(3000) });
return { name, status: r.ok ? 'ok' : 'degraded', latencyMs: Date.now() - t0 };
} catch {
return { name, status: 'down', latencyMs: Date.now() - t0 };
}
}),
);
// SQLite health
let dbStatus = 'ok';
try {
await db.select({ one: sql<number>`1` }).from(users).limit(1);
} catch {
dbStatus = 'down';
}
// Event bus: always ok if process is alive
const eventBusStatus = 'ok';
const services = results.map((r) =>
r.status === 'fulfilled' ? r.value : { name: 'unknown', status: 'down', latencyMs: 0 },
);
services.push({ name: 'sqlite', status: dbStatus, latencyMs: 0 });
services.push({ name: 'event-bus', status: eventBusStatus, latencyMs: 0 });
const allOk = services.every((s) => s.status === 'ok');
res.json({ ok: allOk, services, checkedAt: new Date().toISOString() });
});
// ---------------------------------------------------------------------------
// GET /api/admin/policies
// POST /api/admin/policies/:name/toggle
// ---------------------------------------------------------------------------
router.get('/policies', async (_req: AuthenticatedRequest, res: Response) => {
res.json({ policies: getShadowPolicies() });
});
router.post('/policies/:name/toggle', async (req: AuthenticatedRequest, res: Response) => {
const { name } = req.params as { name: string };
const { active } = req.body as { active: boolean };
const ok = setPolicyActive(name, active);
if (!ok) {
res.status(404).json({ error: 'Policy not found' });
return;
}
await db.insert(adminActions).values({
id: nanoid(),
adminId: req.userId!,
action: active ? 'enable_policy' : 'disable_policy',
targetType: 'policy',
targetId: name,
detail: null,
createdAt: new Date().toISOString(),
});
res.json({ ok: true });
});
// ---------------------------------------------------------------------------
// POST /api/admin/replay-signal
// Re-emit a past event on the bus (for testing / backfill).
// Body: { subject: string, payload: object }
// ---------------------------------------------------------------------------
router.post('/replay-signal', async (req: AuthenticatedRequest, res: Response) => {
const { subject, payload } = req.body as { subject: string; payload: Record<string, unknown> };
if (!subject || !payload) {
res.status(400).json({ error: 'subject and payload required' });
return;
}
const validSubjects = ['signals.tip.served', 'signals.tip.feedback', 'signals.task.synced'];
if (!validSubjects.includes(subject)) {
res.status(400).json({ error: 'unknown subject' });
return;
}
bus.publish(subject as 'signals.tip.served', payload as any);
await db.insert(adminActions).values({
id: nanoid(),
adminId: req.userId!,
action: 'replay_signal',
targetType: 'event',
targetId: null,
detail: JSON.stringify({ subject, payload }),
createdAt: new Date().toISOString(),
});
res.json({ ok: true });
});
// ---------------------------------------------------------------------------
// POST /api/admin/sql
// Read-only SQL runner. Only SELECT allowed.
// ---------------------------------------------------------------------------
router.post('/sql', async (req: AuthenticatedRequest, res: Response) => {
const { query } = req.body as { query: string };
if (!query || typeof query !== 'string') {
res.status(400).json({ error: 'query required' });
return;
}
const normalized = query.trim().toUpperCase();
if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH')) {
res.status(400).json({ error: 'Only SELECT queries are allowed' });
return;
}
// Block any mutation keywords (belt-and-suspenders)
const forbidden = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|ATTACH|PRAGMA|VACUUM)\b/i;
if (forbidden.test(query)) {
res.status(400).json({ error: 'Mutation statements are not allowed' });
return;
}
try {
const stmt = rawSqlite.prepare(query);
const rows = stmt.all();
res.json({ rows, rowCount: rows.length });
} catch (e: any) {
res.status(400).json({ error: e.message ?? 'Query error' });
}
});
// ---------------------------------------------------------------------------
// GET /api/admin/saved-queries
// POST /api/admin/saved-queries
// DELETE /api/admin/saved-queries/:id
// ---------------------------------------------------------------------------
router.get('/saved-queries', async (req: AuthenticatedRequest, res: Response) => {
const rows = await db
.select()
.from(savedQueries)
.where(eq(savedQueries.adminId, req.userId!))
.orderBy(desc(savedQueries.createdAt));
res.json({ queries: rows });
});
router.post('/saved-queries', async (req: AuthenticatedRequest, res: Response) => {
const { name, querySql } = req.body as { name: string; querySql: string };
if (!name || !querySql) {
res.status(400).json({ error: 'name and querySql required' });
return;
}
const id = nanoid();
await db.insert(savedQueries).values({
id,
adminId: req.userId!,
name,
sql: querySql,
createdAt: new Date().toISOString(),
});
res.json({ id });
});
router.delete('/saved-queries/:id', async (req: AuthenticatedRequest, res: Response) => {
const { id } = req.params as { id: string };
await db
.delete(savedQueries)
.where(and(eq(savedQueries.id, id), eq(savedQueries.adminId, req.userId!)));
res.json({ ok: true });
});
export { router as adminRouter };

View File

@@ -1,7 +1,7 @@
import { type Router as ExpressRouter, Router, Response } from 'express';
import { nanoid } from 'nanoid';
import { db } from '../db/index.js';
import { integrationTokens, tipFeedback, tipViews } from '../db/schema.js';
import { integrationTokens, tipFeedback, tipViews, tipScores } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
import { config } from '../config.js';
@@ -24,7 +24,31 @@ interface CachedTask extends Tip {
const taskCache = new Map<string, { tasks: CachedTask[]; fetchedAt: number }>();
/** Parse a Todoist due date string into age in days (relative to now) */
// ---------------------------------------------------------------------------
// Shadow-policy registry
// ---------------------------------------------------------------------------
// A shadow policy runs alongside the active policy, logs its picks, but does
// NOT affect what the user sees. Promotion to A/B or live is a manual step.
// Structure: Map<policyName, { active: boolean }>
const shadowPolicies = new Map<string, { active: boolean }>([
// Example: enable random as a shadow baseline
// ('random-shadow', { active: true }),
]);
export function getShadowPolicies() {
return Array.from(shadowPolicies.entries()).map(([name, s]) => ({ name, ...s }));
}
export function setPolicyActive(name: string, active: boolean): boolean {
if (!shadowPolicies.has(name)) return false;
shadowPolicies.set(name, { active });
return true;
}
// ---------------------------------------------------------------------------
// Todoist helpers
// ---------------------------------------------------------------------------
function dueAgeDays(due: { date?: string; datetime?: string } | null | undefined): number {
if (!due) return 0;
const dateStr = due.datetime ?? due.date;
@@ -71,11 +95,17 @@ async function fetchTodoistTasks(userId: string, accessToken: string): Promise<C
});
taskCache.set(userId, { tasks, fetchedAt: Date.now() });
bus.publish('signals.task.synced', { userId, count: tasks.length, syncedAt: now.toISOString() });
return tasks;
}
/** Call ml/serving for scored selection; returns tip_id or null on failure */
async function remotePolicy(userId: string, tasks: CachedTask[]): Promise<string | null> {
/** Call ml/serving for scored selection; returns { tip_id, score } or null on failure */
async function remotePolicy(
userId: string,
tasks: CachedTask[],
): Promise<{ tipId: string; score: number } | null> {
const hour = new Date().getHours();
const dayOfWeek = new Date().getDay();
@@ -99,8 +129,8 @@ async function remotePolicy(userId: string, tasks: CachedTask[]): Promise<string
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return null;
const { tip_id } = (await res.json()) as { tip_id: string };
return tip_id;
const data = (await res.json()) as { tip_id: string; score: number };
return { tipId: data.tip_id, score: data.score };
} catch {
return null;
}
@@ -111,7 +141,9 @@ function randomPolicy(candidates: CachedTask[]): CachedTask | null {
return candidates[Math.floor(Math.random() * candidates.length)];
}
/** POST /api/recommend */
// ---------------------------------------------------------------------------
// POST /api/recommend
// ---------------------------------------------------------------------------
router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
const [token] = await db
.select()
@@ -130,10 +162,15 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
return;
}
const hour = new Date().getHours();
const dayOfWeek = new Date().getDay();
const t0 = Date.now();
// RemotePolicy with RandomPolicy fallback
const scoredId = await remotePolicy(req.userId!, tasks);
const tip = scoredId
? (tasks.find((t) => t.id === scoredId) ?? randomPolicy(tasks))
const scored = await remotePolicy(req.userId!, tasks);
const latencyMs = Date.now() - t0;
const tip = scored
? (tasks.find((t) => t.id === scored.tipId) ?? randomPolicy(tasks))
: randomPolicy(tasks);
if (!tip) {
@@ -141,25 +178,63 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
return;
}
const policy = scored ? 'linucb-v1' : 'random';
const servedAt = new Date().toISOString();
await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt });
// Log recommendation explainability
await db.insert(tipScores).values({
id: nanoid(),
userId: req.userId!,
tipId: tip.id,
policy,
mlScore: scored ? Math.round(scored.score * 1000) : null,
featuresJson: JSON.stringify({
is_overdue: tip.features.is_overdue,
task_age_days: tip.features.task_age_days,
priority: tip.features.priority,
hour_of_day: hour,
day_of_week: dayOfWeek,
}),
candidateCount: tasks.length,
latencyMs,
servedAt,
});
bus.publish('signals.tip.served', {
userId: req.userId!,
tipId: tip.id,
policy: scoredId ? 'linucb-v1' : 'random',
policy,
servedAt,
});
// Run shadow policies (fire-and-forget, no effect on user)
for (const [name, s] of shadowPolicies) {
if (!s.active) continue;
if (name.startsWith('random')) {
const shadowTip = randomPolicy(tasks);
bus.publish('signals.tip.served', {
userId: req.userId!,
tipId: shadowTip?.id ?? 'none',
policy: `shadow:${name}`,
servedAt,
});
}
}
res.json({ tip });
});
/** POST /api/tip/:id/feedback */
// ---------------------------------------------------------------------------
// POST /api/tip/:id/feedback
// ---------------------------------------------------------------------------
router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
const { action } = req.body as { action: string };
const tipId = String(req.params.id);
if (!['done', 'dismiss', 'snooze'].includes(action)) {
const validActions = ['done', 'dismiss', 'snooze', 'helpful', 'not_helpful'];
if (!validActions.includes(action)) {
res.status(400).json({ error: 'Invalid action' });
return;
}
@@ -173,18 +248,31 @@ router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest,
createdAt: new Date().toISOString(),
});
// Capture task features before clearing cache
const reward = action === 'done' ? 1.0 : action === 'dismiss' ? -1.0 : 0.0;
// Map action to reward (helpful/not_helpful supplement behavioural signals)
const rewardMap: Record<string, number> = {
done: 1.0,
helpful: 0.5,
snooze: 0.0,
not_helpful: -0.5,
dismiss: -1.0,
};
const reward = rewardMap[action] ?? 0.0;
const task = taskCache.get(req.userId!)?.tasks.find((t) => t.id === tipId);
taskCache.delete(req.userId!);
// Clear cache on behavioural actions (not on explicit helpful/not_helpful)
if (['done', 'dismiss', 'snooze'].includes(action)) {
taskCache.delete(req.userId!);
}
bus.publish('signals.tip.feedback', {
userId: req.userId!,
tipId,
action: action as 'done' | 'dismiss' | 'snooze',
action: action as 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful',
reward,
createdAt: new Date().toISOString(),
});
if (task) {
fetch(`${config.ML_SERVING_URL}/reward`, {
method: 'POST',