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

@@ -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(),
});