feat: ε-greedy v1 as active policy; dwell-time reward inference; offline sim framework
- 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>
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
@@ -33,8 +36,10 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,40 @@ export function runMigrations() {
|
||||
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.
|
||||
@@ -106,6 +140,8 @@ export function runMigrations() {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export const tipFeedback = sqliteTable('tip_feedback', {
|
||||
tipId: text('tip_id').notNull(),
|
||||
action: text('action').notNull(), // 'done' | 'dismiss' | 'snooze'
|
||||
sourceId: text('source_id'),
|
||||
dwellMs: integer('dwell_ms'), // ms between servedAt and feedback; null if unknown
|
||||
rewardMilli: integer('reward_milli'), // inferred reward × 1000 (e.g. 1000 = +1.0)
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -81,6 +83,43 @@ export const tipScores = sqliteTable('tip_scores', {
|
||||
servedAt: text('served_at').notNull(),
|
||||
});
|
||||
|
||||
// ── Simulation runs ──────────────────────────────────────────────────────────
|
||||
// One row per offline simulation run (two-policy comparison).
|
||||
export const simRuns = sqliteTable('sim_runs', {
|
||||
id: text('id').primaryKey(),
|
||||
policyA: text('policy_a').notNull(),
|
||||
policyB: text('policy_b').notNull(),
|
||||
nUsers: integer('n_users').notNull(),
|
||||
nRounds: integer('n_rounds').notNull(),
|
||||
tasksPerRound: integer('tasks_per_round').notNull().default(8),
|
||||
useLlm: integer('use_llm', { mode: 'boolean' }).notNull().default(false),
|
||||
status: text('status').notNull().default('pending'), // 'pending'|'running'|'done'|'failed'
|
||||
summaryJson: text('summary_json'), // JSON: { [policy]: PolicySummary }
|
||||
winner: text('winner'),
|
||||
personaBreakdownJson: text('persona_breakdown_json'), // JSON: { [persona]: { [policy]: {reward,n} } }
|
||||
createdAt: text('created_at').notNull(),
|
||||
finishedAt: text('finished_at'),
|
||||
});
|
||||
|
||||
// One row per tip served in a simulation round.
|
||||
export const simEvents = sqliteTable('sim_events', {
|
||||
id: text('id').primaryKey(),
|
||||
runId: text('run_id').notNull().references(() => simRuns.id),
|
||||
round: integer('round').notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
persona: text('persona').notNull(),
|
||||
policy: text('policy').notNull(),
|
||||
tipContent: text('tip_content').notNull(),
|
||||
priority: integer('priority').notNull(),
|
||||
isOverdue: integer('is_overdue', { mode: 'boolean' }).notNull(),
|
||||
action: text('action').notNull(), // 'done' | 'snooze' | 'dismiss'
|
||||
dwellMs: integer('dwell_ms'), // simulated ms between tip appear and user action
|
||||
rewardMilli: integer('reward_milli').notNull(), // inferred reward × 1000
|
||||
hour: integer('hour').notNull(),
|
||||
dayOfWeek: integer('day_of_week').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
// Admin saved SQL queries.
|
||||
export const savedQueries = sqliteTable('saved_queries', {
|
||||
id: text('id').primaryKey(),
|
||||
|
||||
173
services/api/src/events/__tests__/bus.test.ts
Normal file
173
services/api/src/events/__tests__/bus.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Bus, bus } from '../bus.js';
|
||||
|
||||
// Use a fresh Bus instance for isolation in most tests
|
||||
function makeBus() {
|
||||
return new Bus();
|
||||
}
|
||||
|
||||
describe('EventBus — delivery', () => {
|
||||
it('delivers a published event to subscribers', () => {
|
||||
const b = makeBus();
|
||||
const handler = vi.fn();
|
||||
b.subscribe('signals.tip.served', handler);
|
||||
|
||||
const payload = { userId: 'u1', tipId: 'tip:1', policy: 'random', servedAt: new Date().toISOString() };
|
||||
b.publish('signals.tip.served', payload);
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it('delivers to multiple subscribers on the same subject', () => {
|
||||
const b = makeBus();
|
||||
const h1 = vi.fn();
|
||||
const h2 = vi.fn();
|
||||
b.subscribe('signals.tip.served', h1);
|
||||
b.subscribe('signals.tip.served', h2);
|
||||
|
||||
b.publish('signals.tip.served', { userId: 'u', tipId: 't', policy: 'p', servedAt: '' });
|
||||
|
||||
expect(h1).toHaveBeenCalledOnce();
|
||||
expect(h2).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not deliver to handlers on a different subject', () => {
|
||||
const b = makeBus();
|
||||
const feedbackHandler = vi.fn();
|
||||
b.subscribe('signals.tip.feedback', feedbackHandler);
|
||||
|
||||
b.publish('signals.tip.served', { userId: 'u', tipId: 't', policy: 'p', servedAt: '' });
|
||||
|
||||
expect(feedbackHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call a handler after bus.off()', () => {
|
||||
const b = makeBus();
|
||||
const handler = vi.fn();
|
||||
b.subscribe('signals.tip.served', handler);
|
||||
b.off('signals.tip.served', handler);
|
||||
|
||||
b.publish('signals.tip.served', { userId: 'u', tipId: 't', policy: 'p', servedAt: '' });
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw when publishing with no subscribers', () => {
|
||||
const b = makeBus();
|
||||
expect(() =>
|
||||
b.publish('signals.task.synced', { userId: 'u', count: 3, syncedAt: '' }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('reward maps correctly: done=1, dismiss=-1, snooze=0', () => {
|
||||
const b = makeBus();
|
||||
const cases: Array<['done' | 'dismiss' | 'snooze', number]> = [
|
||||
['done', 1.0],
|
||||
['dismiss', -1.0],
|
||||
['snooze', 0.0],
|
||||
];
|
||||
|
||||
for (const [action, expected] of cases) {
|
||||
const handler = vi.fn();
|
||||
b.subscribe('signals.tip.feedback', handler);
|
||||
|
||||
const payload = {
|
||||
userId: 'u1',
|
||||
tipId: 'todoist:42',
|
||||
action,
|
||||
reward: action === 'done' ? 1.0 : action === 'dismiss' ? -1.0 : 0.0,
|
||||
dwellMs: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
b.publish('signals.tip.feedback', payload);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ reward: expected }));
|
||||
b.off('signals.tip.feedback', handler);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventBus — ring buffer / tail()', () => {
|
||||
it('tail() returns published events', () => {
|
||||
const b = makeBus();
|
||||
b.publish('signals.tip.served', { userId: 'u1', tipId: 't1', policy: 'p', servedAt: '' });
|
||||
b.publish('signals.tip.served', { userId: 'u2', tipId: 't2', policy: 'p', servedAt: '' });
|
||||
|
||||
const events = b.tail();
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('tail() filters by subject prefix', () => {
|
||||
const b = makeBus();
|
||||
b.publish('signals.tip.served', { userId: 'u', tipId: 't', policy: 'p', servedAt: '' });
|
||||
b.publish('signals.task.synced', { userId: 'u', count: 1, syncedAt: '' });
|
||||
|
||||
const tipEvents = b.tail({ subject: 'signals.tip' });
|
||||
expect(tipEvents.every((e) => e.subject.startsWith('signals.tip'))).toBe(true);
|
||||
|
||||
const taskEvents = b.tail({ subject: 'signals.task' });
|
||||
expect(taskEvents.every((e) => e.subject.startsWith('signals.task'))).toBe(true);
|
||||
});
|
||||
|
||||
it('tail() filters by userId', () => {
|
||||
const b = makeBus();
|
||||
b.publish('signals.tip.served', { userId: 'alice', tipId: 't1', policy: 'p', servedAt: '' });
|
||||
b.publish('signals.tip.served', { userId: 'bob', tipId: 't2', policy: 'p', servedAt: '' });
|
||||
|
||||
const aliceEvents = b.tail({ userId: 'alice' });
|
||||
expect(aliceEvents.every((e) => (e.payload as any).userId === 'alice')).toBe(true);
|
||||
});
|
||||
|
||||
it('tail() respects limit', () => {
|
||||
const b = makeBus();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
b.publish('signals.tip.served', { userId: 'u', tipId: `t${i}`, policy: 'p', servedAt: '' });
|
||||
}
|
||||
const events = b.tail({ limit: 3 });
|
||||
expect(events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('tail() returns only events after `since` id', () => {
|
||||
const b = makeBus();
|
||||
b.publish('signals.tip.served', { userId: 'u', tipId: 't1', policy: 'p', servedAt: '' });
|
||||
const snap = b.tail();
|
||||
const lastId = snap[snap.length - 1].id;
|
||||
|
||||
b.publish('signals.tip.served', { userId: 'u', tipId: 't2', policy: 'p', servedAt: '' });
|
||||
|
||||
const after = b.tail({ since: lastId });
|
||||
expect(after).toHaveLength(1);
|
||||
expect((after[0].payload as any).tipId).toBe('t2');
|
||||
});
|
||||
|
||||
it('assigns monotonically increasing ids', () => {
|
||||
const b = makeBus();
|
||||
b.publish('signals.tip.served', { userId: 'u', tipId: 't1', policy: 'p', servedAt: '' });
|
||||
b.publish('signals.tip.served', { userId: 'u', tipId: 't2', policy: 'p', servedAt: '' });
|
||||
|
||||
const events = b.tail();
|
||||
const ids = events.map((e) => e.id);
|
||||
for (let i = 1; i < ids.length; i++) {
|
||||
expect(ids[i]).toBeGreaterThan(ids[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('ring buffer caps at 500 entries and evicts oldest', () => {
|
||||
const b = makeBus();
|
||||
// Publish 502 events — the first two should be evicted
|
||||
for (let i = 0; i < 502; i++) {
|
||||
b.publish('signals.tip.served', { userId: 'u', tipId: `t${i}`, policy: 'p', servedAt: '' });
|
||||
}
|
||||
const all = b.tail({ limit: 1000 });
|
||||
expect(all).toHaveLength(500);
|
||||
// Oldest surviving entry should be the 3rd published (index 2)
|
||||
expect((all[0].payload as any).tipId).toBe('t2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventBus — singleton bus export', () => {
|
||||
it('singleton bus is a Bus instance', () => {
|
||||
expect(bus).toBeInstanceOf(Bus);
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,9 @@ export type TipServedEvent = {
|
||||
export type TipFeedbackEvent = {
|
||||
userId: string;
|
||||
tipId: string;
|
||||
action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful';
|
||||
reward: number;
|
||||
action: 'done' | 'dismiss' | 'snooze';
|
||||
reward: number; // inferred from action + dwellMs (see inferReward in recommender.ts)
|
||||
dwellMs: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -91,4 +92,5 @@ class Bus extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
export { Bus };
|
||||
export const bus = new Bus();
|
||||
|
||||
109
services/api/src/middleware/__tests__/admin.test.ts
Normal file
109
services/api/src/middleware/__tests__/admin.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Response, NextFunction } from 'express';
|
||||
import type { AuthenticatedRequest } from '../session.js';
|
||||
|
||||
// Mock the db module so requireAdmin uses our test db
|
||||
const mockSelect = vi.fn();
|
||||
vi.mock('../../db/index.js', () => ({ db: { select: mockSelect } }));
|
||||
|
||||
// Import AFTER mock is set up
|
||||
const { requireAdmin } = await import('../admin.js');
|
||||
|
||||
function makeRes() {
|
||||
const json = vi.fn();
|
||||
const status = vi.fn().mockReturnValue({ json });
|
||||
return { status, json, _status: status, _json: json } as unknown as Response & {
|
||||
_status: ReturnType<typeof vi.fn>;
|
||||
_json: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function makeReq(userId?: string): AuthenticatedRequest {
|
||||
return { userId } as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
describe('requireAdmin middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls next() when user has role=admin', async () => {
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ role: 'admin' }]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const next: NextFunction = vi.fn() as unknown as NextFunction;
|
||||
await requireAdmin(makeReq('user-1'), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns 403 when user has role=user', async () => {
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ role: 'user' }]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
const next: NextFunction = vi.fn() as unknown as NextFunction;
|
||||
await requireAdmin(makeReq('user-2'), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 when user is not found', async () => {
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
const next: NextFunction = vi.fn() as unknown as NextFunction;
|
||||
await requireAdmin(makeReq('unknown'), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 when userId is undefined (unauthenticated request)', async () => {
|
||||
// DB will return empty — userId is undefined so the query matches nothing
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
const next: NextFunction = vi.fn() as unknown as NextFunction;
|
||||
await requireAdmin(makeReq(undefined), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates DB errors (does not swallow exceptions)', async () => {
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockRejectedValue(new Error('DB down')),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
const next: NextFunction = vi.fn() as unknown as NextFunction;
|
||||
await expect(requireAdmin(makeReq('user-1'), res, next)).rejects.toThrow('DB down');
|
||||
});
|
||||
});
|
||||
27
services/api/src/middleware/admin.ts
Normal file
27
services/api/src/middleware/admin.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { users } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { AuthenticatedRequest } from './session.js';
|
||||
|
||||
/**
|
||||
* Requires the session user to have role='admin'.
|
||||
* Must be used after requireAuth (which sets req.userId).
|
||||
*/
|
||||
export async function requireAdmin(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
const [user] = await db
|
||||
.select({ role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, req.userId!))
|
||||
.limit(1);
|
||||
|
||||
if (!user || user.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
370
services/api/src/routes/__tests__/admin.test.ts
Normal file
370
services/api/src/routes/__tests__/admin.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Admin route integration tests.
|
||||
*
|
||||
* A real Express app + in-memory SQLite DB per test suite.
|
||||
* Auth and admin middleware are mocked so we can focus on route logic.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
import express from 'express';
|
||||
import * as http from 'http';
|
||||
import { makeTestDb } from '../../test/db.js';
|
||||
import { users, integrationTokens, tipViews, tipFeedback } from '../../db/schema.js';
|
||||
|
||||
// ---- in-memory DB ----
|
||||
const testDb = makeTestDb();
|
||||
|
||||
vi.mock('../../db/index.js', () => ({ db: testDb }));
|
||||
|
||||
// Bypass auth — all requests arrive pre-authenticated as 'admin-1'
|
||||
vi.mock('../../middleware/session.js', () => ({
|
||||
sessionMiddleware: (_req: express.Request, _res: express.Response, next: express.NextFunction) =>
|
||||
next(),
|
||||
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
||||
(req as any).userId = 'admin-1';
|
||||
next();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../middleware/admin.js', () => ({
|
||||
requireAdmin: (_req: express.Request, _res: express.Response, next: express.NextFunction) =>
|
||||
next(),
|
||||
}));
|
||||
|
||||
const { adminRouter } = await import('../admin.js');
|
||||
|
||||
// ---- seed ----
|
||||
const NOW = new Date().toISOString();
|
||||
const DAY_AGO = new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.insert(users).values([
|
||||
{ id: 'admin-1', email: 'admin@test.com', role: 'admin', consentGiven: true, consentAt: NOW, createdAt: NOW },
|
||||
{ id: 'user-1', email: 'alice@test.com', role: 'user', consentGiven: true, consentAt: NOW, createdAt: NOW },
|
||||
{ id: 'user-2', email: 'bob@test.com', role: 'user', consentGiven: false, createdAt: NOW },
|
||||
]);
|
||||
await testDb.insert(integrationTokens).values([
|
||||
{ id: 'tok-1', userId: 'user-1', provider: 'todoist', accessToken: 'secret', connectedAt: NOW },
|
||||
]);
|
||||
await testDb.insert(tipViews).values([
|
||||
{ id: 'tv-1', userId: 'user-1', tipId: 'tip:a', servedAt: DAY_AGO },
|
||||
{ id: 'tv-2', userId: 'user-1', tipId: 'tip:b', servedAt: NOW },
|
||||
{ id: 'tv-3', userId: 'user-2', tipId: 'tip:c', servedAt: NOW },
|
||||
]);
|
||||
await testDb.insert(tipFeedback).values([
|
||||
{ id: 'tf-1', userId: 'user-1', tipId: 'tip:a', action: 'done', createdAt: DAY_AGO },
|
||||
{ id: 'tf-2', userId: 'user-1', tipId: 'tip:b', action: 'snooze', createdAt: NOW },
|
||||
]);
|
||||
});
|
||||
|
||||
// ---- test helpers ----
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin', adminRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
function call(
|
||||
server: http.Server,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { port } = server.address() as { port: number };
|
||||
const req = http.request(
|
||||
{ method, hostname: '127.0.0.1', port, path, headers: { 'Content-Type': 'application/json' } },
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (c) => (data += c));
|
||||
res.on('end', () => {
|
||||
try { resolve({ status: res.statusCode!, body: JSON.parse(data) }); }
|
||||
catch { resolve({ status: res.statusCode!, body: data }); }
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function startServer(app: express.Application): Promise<{ server: http.Server; call: (method: string, path: string, body?: unknown) => ReturnType<typeof call> }> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(app);
|
||||
server.listen(0, () =>
|
||||
resolve({ server, call: (m, p, b) => call(server, m, p, b) }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- tests ----
|
||||
describe('GET /api/admin/stats', () => {
|
||||
it('returns dau, wau, tips, reactions, user totals', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status, body } = await call('GET', '/api/admin/stats');
|
||||
const b = body as Record<string, unknown>;
|
||||
expect(status).toBe(200);
|
||||
expect(typeof b.dau).toBe('number');
|
||||
expect(typeof b.wau).toBe('number');
|
||||
expect(b.tipsServedLast7d).toBeGreaterThanOrEqual(3);
|
||||
expect(b.totalUsers).toBe(3);
|
||||
expect(b.activatedUsers).toBeGreaterThanOrEqual(2);
|
||||
expect(b.reactionsLast7d).toBeDefined();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/users', () => {
|
||||
it('returns paginated list with total', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status, body } = await call('GET', '/api/admin/users?limit=10&offset=0');
|
||||
const b = body as { users: unknown[]; total: number };
|
||||
expect(status).toBe(200);
|
||||
expect(b.total).toBe(3);
|
||||
expect(b.users).toHaveLength(3);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('respects limit', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status, body } = await call('GET', '/api/admin/users?limit=2&offset=0');
|
||||
const b = body as { users: unknown[]; total: number };
|
||||
expect(status).toBe(200);
|
||||
expect(b.users).toHaveLength(2);
|
||||
expect(b.total).toBe(3);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/users/:id', () => {
|
||||
it('returns user detail with integrations and tip stats', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status, body } = await call('GET', '/api/admin/users/user-1');
|
||||
const b = body as {
|
||||
user: { email: string; role: string };
|
||||
integrations: { provider: string }[];
|
||||
tipsServed: number;
|
||||
recentFeedback: unknown[];
|
||||
};
|
||||
expect(status).toBe(200);
|
||||
expect(b.user.email).toBe('alice@test.com');
|
||||
expect(b.user.role).toBe('user');
|
||||
expect(b.integrations).toHaveLength(1);
|
||||
expect(b.integrations[0].provider).toBe('todoist');
|
||||
expect(b.tipsServed).toBe(2);
|
||||
expect(b.recentFeedback).toHaveLength(2);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 404 for unknown user', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status } = await call('GET', '/api/admin/users/nonexistent');
|
||||
expect(status).toBe(404);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/audit', () => {
|
||||
it('returns list and total', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status, body } = await call('GET', '/api/admin/audit');
|
||||
const b = body as { actions: unknown[]; total: number };
|
||||
expect(status).toBe(200);
|
||||
expect(Array.isArray(b.actions)).toBe(true);
|
||||
expect(typeof b.total).toBe('number');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/users/:id/revoke-integration', () => {
|
||||
it('removes the integration and writes an audit entry', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status, body } = await call(
|
||||
'POST', '/api/admin/users/user-1/revoke-integration', { provider: 'todoist' },
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect((body as { ok: boolean }).ok).toBe(true);
|
||||
|
||||
// Integration should be gone
|
||||
const detail = await call('GET', '/api/admin/users/user-1');
|
||||
expect((detail.body as { integrations: unknown[] }).integrations).toHaveLength(0);
|
||||
|
||||
// Audit log should contain the action
|
||||
const audit = await call('GET', '/api/admin/audit');
|
||||
const actions = (audit.body as { actions: { action: string }[] }).actions;
|
||||
expect(actions.some((x) => x.action === 'revoke_integration')).toBe(true);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent integration', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status } = await call(
|
||||
'POST', '/api/admin/users/user-2/revoke-integration', { provider: 'todoist' },
|
||||
);
|
||||
expect(status).toBe(404);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 when provider is missing', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status } = await call('POST', '/api/admin/users/user-1/revoke-integration', {});
|
||||
expect(status).toBe(400);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 404 when target user does not exist', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status } = await call(
|
||||
'POST', '/api/admin/users/ghost/revoke-integration', { provider: 'todoist' },
|
||||
);
|
||||
expect(status).toBe(404);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/users — pagination', () => {
|
||||
it('offset skips rows', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const page0 = await call('GET', '/api/admin/users?limit=2&offset=0');
|
||||
const page1 = await call('GET', '/api/admin/users?limit=2&offset=2');
|
||||
const b0 = page0.body as { users: { id: string }[]; total: number };
|
||||
const b1 = page1.body as { users: { id: string }[]; total: number };
|
||||
expect(b0.users).toHaveLength(2);
|
||||
expect(b1.users).toHaveLength(1);
|
||||
// total stays constant
|
||||
expect(b0.total).toBe(3);
|
||||
expect(b1.total).toBe(3);
|
||||
// no overlap
|
||||
const ids0 = b0.users.map((u) => u.id);
|
||||
const ids1 = b1.users.map((u) => u.id);
|
||||
expect(ids0.every((id) => !ids1.includes(id))).toBe(true);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('offset beyond total returns empty users array', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status, body } = await call('GET', '/api/admin/users?limit=10&offset=999');
|
||||
const b = body as { users: unknown[]; total: number };
|
||||
expect(status).toBe(200);
|
||||
expect(b.users).toHaveLength(0);
|
||||
expect(b.total).toBe(3);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('limit is capped at 200', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
// Passing a huge limit should not crash and should return at most all users
|
||||
const { status, body } = await call('GET', '/api/admin/users?limit=9999');
|
||||
const b = body as { users: unknown[] };
|
||||
expect(status).toBe(200);
|
||||
expect(b.users.length).toBeLessThanOrEqual(200);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/events', () => {
|
||||
it('returns events array and nextSince', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status, body } = await call('GET', '/api/admin/events');
|
||||
const b = body as { events: unknown[]; nextSince: number };
|
||||
expect(status).toBe(200);
|
||||
expect(Array.isArray(b.events)).toBe(true);
|
||||
expect(typeof b.nextSince).toBe('number');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/health', () => {
|
||||
it('returns 200 with ok, services array, and checkedAt', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { status, body } = await call('GET', '/api/admin/health');
|
||||
const b = body as { ok: boolean; services: { name: string; status: string }[]; checkedAt: string };
|
||||
expect(status).toBe(200);
|
||||
expect(typeof b.ok).toBe('boolean');
|
||||
expect(Array.isArray(b.services)).toBe(true);
|
||||
expect(typeof b.checkedAt).toBe('string');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/users/:id — edge cases', () => {
|
||||
it('user with no integrations and no tips has empty arrays and 0 count', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
// user-2 has no integrations and no feedback seeded
|
||||
const { status, body } = await call('GET', '/api/admin/users/user-2');
|
||||
const b = body as {
|
||||
user: { id: string };
|
||||
integrations: unknown[];
|
||||
tipsServed: number;
|
||||
recentFeedback: unknown[];
|
||||
};
|
||||
expect(status).toBe(200);
|
||||
expect(b.integrations).toHaveLength(0);
|
||||
expect(b.recentFeedback).toHaveLength(0);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/stats — field types', () => {
|
||||
it('reactionsLast7d has correct action counts', async () => {
|
||||
const { server, call } = await startServer(buildApp());
|
||||
try {
|
||||
const { body } = await call('GET', '/api/admin/stats');
|
||||
const b = body as { reactionsLast7d: Record<string, number> };
|
||||
// We seeded 'done' and 'snooze' feedback
|
||||
expect(typeof b.reactionsLast7d['done']).toBe('number');
|
||||
expect(typeof b.reactionsLast7d['snooze']).toBe('number');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
adminActions,
|
||||
tipScores,
|
||||
savedQueries,
|
||||
simRuns,
|
||||
simEvents,
|
||||
} from '../db/schema.js';
|
||||
import { eq, desc, sql, gte, and, isNull, lt } from 'drizzle-orm';
|
||||
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
|
||||
@@ -16,6 +18,16 @@ import { nanoid } from 'nanoid';
|
||||
import { bus } from '../events/bus.js';
|
||||
import { config } from '../config.js';
|
||||
import { getShadowPolicies, setPolicyActive } from './recommender.js';
|
||||
import { spawn } from 'child_process';
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// In-memory tracker for running sim processes
|
||||
const _simProcesses = new Map<string, { pid: number; startedAt: string }>();
|
||||
|
||||
const router: ExpressRouter = Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
@@ -606,4 +618,158 @@ router.delete('/saved-queries/:id', async (req: AuthenticatedRequest, res: Respo
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/admin/simulate/start
|
||||
// Spawn ml/experiments/sim/runner.py in the background; return run_id.
|
||||
// ---------------------------------------------------------------------------
|
||||
router.post('/simulate/start', async (req: AuthenticatedRequest, res: Response) => {
|
||||
const {
|
||||
nUsers = 5,
|
||||
nRounds = 20,
|
||||
tasksPerRound = 8,
|
||||
useLlm = false,
|
||||
judgeMode = 'rule',
|
||||
policies = ['linucb-v1', 'egreedy-v1'],
|
||||
} = req.body as {
|
||||
nUsers?: number;
|
||||
nRounds?: number;
|
||||
tasksPerRound?: number;
|
||||
useLlm?: boolean;
|
||||
judgeMode?: 'rule' | 'llm' | 'claude-code';
|
||||
policies?: string[];
|
||||
};
|
||||
|
||||
if (policies.length < 2) {
|
||||
res.status(400).json({ error: 'At least two policies required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.insert(simRuns).values({
|
||||
id,
|
||||
policyA: policies[0],
|
||||
policyB: policies[1],
|
||||
nUsers,
|
||||
nRounds,
|
||||
tasksPerRound,
|
||||
useLlm,
|
||||
status: 'running',
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
const runnerPath = resolve(__dirname, '../../../../ml/experiments/sim/runner.py');
|
||||
const venvPython = resolve(__dirname, '../../../../ml/serving/.venv/bin/python');
|
||||
const pythonBin = existsSync(venvPython) ? venvPython : 'python3';
|
||||
const outPath = `/tmp/oo-sim-${id}.json`;
|
||||
|
||||
const args = [
|
||||
runnerPath,
|
||||
'--n-users', String(nUsers),
|
||||
'--n-rounds', String(nRounds),
|
||||
'--tasks-per-round', String(tasksPerRound),
|
||||
'--ml-url', config.ML_SERVING_URL,
|
||||
'--policies', ...policies,
|
||||
'--out', outPath,
|
||||
'--judge', judgeMode === 'llm' ? 'llm' : judgeMode === 'claude-code' ? 'rule' : 'rule',
|
||||
// claude-code mode isn't auto-runnable from the API (requires human in the loop)
|
||||
// it falls back to rule judge when triggered from the panel
|
||||
];
|
||||
|
||||
const child = spawn(pythonBin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
if (child.pid) {
|
||||
_simProcesses.set(id, { pid: child.pid, startedAt: now });
|
||||
}
|
||||
|
||||
// Capture stderr for debugging
|
||||
const stderrLines: string[] = [];
|
||||
child.stderr?.on('data', (d: Buffer) => stderrLines.push(d.toString()));
|
||||
|
||||
child.on('exit', async (code) => {
|
||||
_simProcesses.delete(id);
|
||||
const finishedAt = new Date().toISOString();
|
||||
|
||||
if (code === 0 && existsSync(outPath)) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
|
||||
// Bulk-insert sim events
|
||||
const eventRows = (raw.events ?? []).map((ev: Record<string, unknown>) => ({
|
||||
id: nanoid(),
|
||||
runId: id,
|
||||
round: Number(ev.round),
|
||||
userId: String(ev.user_id),
|
||||
persona: String(ev.persona),
|
||||
policy: String(ev.policy),
|
||||
tipContent: String(ev.tip_content),
|
||||
priority: Number(ev.priority),
|
||||
isOverdue: Boolean(ev.is_overdue),
|
||||
action: String(ev.action),
|
||||
dwellMs: ev.dwell_ms != null ? Number(ev.dwell_ms) : null,
|
||||
rewardMilli: Math.round(Number(ev.reward) * 1000),
|
||||
hour: Number(ev.hour),
|
||||
dayOfWeek: Number(ev.day_of_week),
|
||||
createdAt: now,
|
||||
}));
|
||||
|
||||
for (const row of eventRows) {
|
||||
await db.insert(simEvents).values(row).catch(() => {});
|
||||
}
|
||||
|
||||
await db.update(simRuns).set({
|
||||
status: 'done',
|
||||
summaryJson: JSON.stringify(raw.summary),
|
||||
winner: raw.winner,
|
||||
personaBreakdownJson: JSON.stringify(raw.persona_breakdown),
|
||||
finishedAt,
|
||||
}).where(eq(simRuns.id, id));
|
||||
|
||||
try { unlinkSync(outPath); } catch { /* ignore */ }
|
||||
} catch (e) {
|
||||
await db.update(simRuns).set({ status: 'failed', finishedAt }).where(eq(simRuns.id, id));
|
||||
}
|
||||
} else {
|
||||
await db.update(simRuns).set({ status: 'failed', finishedAt }).where(eq(simRuns.id, id));
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ id, status: 'running' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/admin/simulate/runs
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/simulate/runs', async (_req: AuthenticatedRequest, res: Response) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(simRuns)
|
||||
.orderBy(desc(simRuns.createdAt))
|
||||
.limit(50);
|
||||
res.json({ runs: rows });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/admin/simulate/:id
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/simulate/:id', async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const [run] = await db.select().from(simRuns).where(eq(simRuns.id, id)).limit(1);
|
||||
if (!run) {
|
||||
res.status(404).json({ error: 'Run not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(simEvents)
|
||||
.where(eq(simEvents.runId, id))
|
||||
.orderBy(simEvents.round)
|
||||
.limit(5000);
|
||||
|
||||
const isRunning = _simProcesses.has(id);
|
||||
res.json({ run: { ...run, isRunning }, events });
|
||||
});
|
||||
|
||||
export { router as adminRouter };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type Router as ExpressRouter, Router, Response } from 'express';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { db } from '../db/index.js';
|
||||
import { integrationTokens, tipFeedback, tipViews, tipScores } from '../db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
|
||||
import { config } from '../config.js';
|
||||
import { bus } from '../events/bus.js';
|
||||
@@ -105,7 +105,7 @@ async function fetchTodoistTasks(userId: string, accessToken: string): Promise<C
|
||||
async function remotePolicy(
|
||||
userId: string,
|
||||
tasks: CachedTask[],
|
||||
): Promise<{ tipId: string; score: number } | null> {
|
||||
): Promise<{ tipId: string; score: number; policy: string } | null> {
|
||||
const hour = new Date().getHours();
|
||||
const dayOfWeek = new Date().getDay();
|
||||
|
||||
@@ -121,8 +121,9 @@ async function remotePolicy(
|
||||
context: { hour_of_day: hour, day_of_week: dayOfWeek },
|
||||
};
|
||||
|
||||
// Active policy: egreedy-v1 (selected over linucb-v1 after offline sim — ADR-0007)
|
||||
try {
|
||||
const res = await fetch(`${config.ML_SERVING_URL}/score`, {
|
||||
const res = await fetch(`${config.ML_SERVING_URL}/score/egreedy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -130,7 +131,7 @@ async function remotePolicy(
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { tip_id: string; score: number };
|
||||
return { tipId: data.tip_id, score: data.score };
|
||||
return { tipId: data.tip_id, score: data.score, policy: 'egreedy-v1' };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -178,7 +179,7 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
|
||||
return;
|
||||
}
|
||||
|
||||
const policy = scored ? 'linucb-v1' : 'random';
|
||||
const policy = scored ? scored.policy : 'random';
|
||||
const servedAt = new Date().toISOString();
|
||||
|
||||
await db.insert(tipViews).values({ id: nanoid(), userId: req.userId!, tipId: tip.id, servedAt });
|
||||
@@ -226,55 +227,85 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
|
||||
res.json({ tip });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reward inference from action + dwell time
|
||||
//
|
||||
// Feedback is now 3 signals only: done / snooze / dismiss.
|
||||
// "Helpfulness" is inferred from how long the user took to act on a tip:
|
||||
// dismiss → -1.0 (clear rejection)
|
||||
// snooze → +0.1 (tip noticed, timing off — mild positive)
|
||||
// done < 15 s → -0.3 (almost certainly a stale task, not magic)
|
||||
// done 15 s – 2 min → +1.0 (magic zone: user saw tip and acted)
|
||||
// done 2 – 10 min → +0.6 (good: user engaged, acted in same session)
|
||||
// done > 10 min → +0.3 (eventually done; tip may have helped, unclear)
|
||||
// ---------------------------------------------------------------------------
|
||||
function inferReward(action: string, dwellMs: number | null): number {
|
||||
if (action === 'dismiss') return -1.0;
|
||||
if (action === 'snooze') return 0.1;
|
||||
// done — use dwell time
|
||||
if (dwellMs === null || dwellMs < 0) return 0.5; // unknown dwell: neutral positive
|
||||
if (dwellMs < 15_000) return -0.3; // stale / reflex
|
||||
if (dwellMs < 120_000) return 1.0; // magic zone
|
||||
if (dwellMs < 600_000) return 0.6; // good
|
||||
return 0.3; // eventually
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
const now = new Date();
|
||||
|
||||
const validActions = ['done', 'dismiss', 'snooze', 'helpful', 'not_helpful'];
|
||||
const validActions = ['done', 'dismiss', 'snooze'];
|
||||
if (!validActions.includes(action)) {
|
||||
res.status(400).json({ error: 'Invalid action' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute dwell time from the most recent tipViews record for this user+tip
|
||||
let dwellMs: number | null = null;
|
||||
const [lastView] = await db
|
||||
.select({ servedAt: tipViews.servedAt })
|
||||
.from(tipViews)
|
||||
.where(and(eq(tipViews.userId, req.userId!), eq(tipViews.tipId, tipId)))
|
||||
.orderBy(desc(tipViews.servedAt))
|
||||
.limit(1);
|
||||
|
||||
if (lastView?.servedAt) {
|
||||
dwellMs = now.getTime() - new Date(lastView.servedAt).getTime();
|
||||
}
|
||||
|
||||
const reward = inferReward(action, dwellMs);
|
||||
|
||||
await db.insert(tipFeedback).values({
|
||||
id: nanoid(),
|
||||
userId: req.userId!,
|
||||
tipId,
|
||||
action,
|
||||
sourceId: tipId.startsWith('todoist:') ? tipId.slice(8) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
dwellMs: dwellMs !== null ? Math.round(dwellMs) : null,
|
||||
rewardMilli: Math.round(reward * 1000),
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// Clear cache on behavioural actions (not on explicit helpful/not_helpful)
|
||||
if (['done', 'dismiss', 'snooze'].includes(action)) {
|
||||
taskCache.delete(req.userId!);
|
||||
}
|
||||
taskCache.delete(req.userId!);
|
||||
|
||||
bus.publish('signals.tip.feedback', {
|
||||
userId: req.userId!,
|
||||
tipId,
|
||||
action: action as 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful',
|
||||
action: action as 'done' | 'dismiss' | 'snooze',
|
||||
reward,
|
||||
createdAt: new Date().toISOString(),
|
||||
dwellMs,
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
|
||||
if (task) {
|
||||
fetch(`${config.ML_SERVING_URL}/reward`, {
|
||||
// Send reward to egreedy-v1 (active policy — ADR-0007)
|
||||
fetch(`${config.ML_SERVING_URL}/reward/egreedy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -282,6 +313,7 @@ router.post('/tip/:id/feedback', requireAuth, async (req: AuthenticatedRequest,
|
||||
tip_id: tipId,
|
||||
reward,
|
||||
features: task.features,
|
||||
day_of_week: new Date().getDay(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ router.get('/me', requireAuth, async (req: AuthenticatedRequest, res: Response)
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
consentGiven: user.consentGiven,
|
||||
});
|
||||
|
||||
84
services/api/src/test/db.ts
Normal file
84
services/api/src/test/db.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Creates an isolated in-memory SQLite DB with the full schema applied.
|
||||
* Use this in tests instead of the shared `db` singleton.
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from '../db/schema.js';
|
||||
|
||||
export function makeTestDb() {
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
return drizzle(sqlite, { schema });
|
||||
}
|
||||
|
||||
export type TestDb = ReturnType<typeof makeTestDb>;
|
||||
13
services/api/vitest.config.ts
Normal file
13
services/api/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov'],
|
||||
include: ['src/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user