- Promote egreedy-v1 to active serving policy (ADR-0007): /score/egreedy + /reward/egreedy
replaces linucb-v1 endpoints after offline sim shows +10.7% mean reward (−0.548 vs −0.606)
- Replace explicit helpful/not_helpful feedback with dwell-time inferred reward (inferReward):
dismiss=−1.0, snooze=+0.1, done<15s=−0.3, done 15s–2min=+1.0, done 2–10min=+0.6, done>10min=+0.3
- Add ml/serving ε-greedy endpoints: /score/egreedy, /reward/egreedy, /stats/egreedy/{user_id}
with d=7 feature vector (base 5 + sin/cos day-of-week encoding)
- Add offline simulation framework (ml/experiments/sim): rule/LLM/claude-code judges,
two-phase score+reward, synthetic personas, task generator; results stored in sim_runs/sim_events
- Add /admin/simulations page: start runs, live-poll status, reward curve SVG, action/persona tables
- Fix egreedy day_of_week training skew: reward endpoint now uses actual dow instead of hardcoded 0
- Fix runner.py proxy bypass: httpx.Client(trust_env=False) for localhost ML calls
- Add dwellMs to TipFeedbackEvent contract and bus.test.ts fixture
- Schema: sim_runs, sim_events tables; tip_feedback gains dwell_ms, reward_milli columns
- ADR-0006: admin console framework; ADR-0007: egreedy-v1 policy selection rationale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
155 lines
4.6 KiB
TypeScript
155 lines
4.6 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
import * as schema from './schema.js';
|
|
import { config } from '../config.js';
|
|
|
|
const sqlite = new Database(config.DATABASE_PATH);
|
|
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(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
email TEXT NOT NULL UNIQUE,
|
|
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,
|
|
deleted_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_tokens (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
provider TEXT NOT NULL,
|
|
access_token TEXT NOT NULL,
|
|
refresh_token TEXT,
|
|
expires_at TEXT,
|
|
connected_at TEXT NOT NULL,
|
|
UNIQUE(user_id, provider)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tip_feedback (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
tip_id TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
source_id TEXT,
|
|
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
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sim_runs (
|
|
id TEXT PRIMARY KEY,
|
|
policy_a TEXT NOT NULL,
|
|
policy_b TEXT NOT NULL,
|
|
n_users INTEGER NOT NULL,
|
|
n_rounds INTEGER NOT NULL,
|
|
tasks_per_round INTEGER NOT NULL DEFAULT 8,
|
|
use_llm INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
summary_json TEXT,
|
|
winner TEXT,
|
|
persona_breakdown_json TEXT,
|
|
created_at TEXT NOT NULL,
|
|
finished_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sim_events (
|
|
id TEXT PRIMARY KEY,
|
|
run_id TEXT NOT NULL REFERENCES sim_runs(id),
|
|
round INTEGER NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
persona TEXT NOT NULL,
|
|
policy TEXT NOT NULL,
|
|
tip_content TEXT NOT NULL,
|
|
priority INTEGER NOT NULL,
|
|
is_overdue INTEGER NOT NULL,
|
|
action TEXT NOT NULL,
|
|
dwell_ms INTEGER,
|
|
reward_milli INTEGER NOT NULL,
|
|
hour INTEGER NOT NULL,
|
|
day_of_week INTEGER 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 ''`,
|
|
`ALTER TABLE tip_feedback ADD COLUMN dwell_ms INTEGER`,
|
|
`ALTER TABLE tip_feedback ADD COLUMN reward_milli INTEGER`,
|
|
]) {
|
|
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);
|
|
}
|
|
}
|