feat(profile): /api/profile + eligibility filter + inference framework (ADR-0014 steps 4-6)
Step 4 — /api/profile read-through API:
GET /api/profile → { user, prefs, consents, contexts }
PATCH /api/profile/prefs/:scope upsert user_preferences (source='user')
PATCH /api/profile/consents grant / revoke consent keys
PATCH /api/profile/contexts create / activate / deactivate contexts
Legacy consentGiven bit folded in as data:core fallback.
Step 5 — registry-driven eligibility filter:
fetchRegistry() exported from agent-registry.ts.
profile/eligibility.ts: getEligibleAgentIds(userId) — filters by required
consents, silenced_in_contexts, and user_preferences[enabled=false].
fetchOrchestratorTip filters agent_outputs to eligible set before calling
ml/serving /recommend. Fail-closed: registry unavailable → empty set.
Step 6 — shared context-inference framework (#111) + time-of-day proof (#112):
ml/agents/inference/: UserHistory, FeedbackEvent, run_inference().
Framework: cold-start, min_history gating, error fallback, structured logs.
TimeOfDayAgent v1.1.0: inferred_params=[preferred_hour]; also reads
quiet_start/quiet_end from agent_prefs. agent_prefs injected by TS caller.
AgentInput gains agent_prefs field.
ml/serving: POST /agents/{agent_id}/infer endpoint.
agent-outputs.ts computeAndStore: loads prefs before compute, calls /infer
after, persists results (source='inferred'); user overrides never touched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import { adminRouter, adminInternalRouter } from './routes/admin.js';
|
||||
import benchRouter from './routes/bench.js';
|
||||
import agentOutputsRouter from './routes/agent-outputs.js';
|
||||
import agentRegistryRouter from './routes/agent-registry.js';
|
||||
import profileRouter from './routes/profile.js';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
import { requireAuth } from './middleware/session.js';
|
||||
@@ -74,6 +75,7 @@ app.use('/api/bench', requireAuth as any, requireAdmin as any, benchRouter);
|
||||
// agent-registry mounts first so /registry beats agent-outputs' /:userId pattern.
|
||||
app.use('/api/agents', agentRegistryRouter);
|
||||
app.use('/api/agents', agentOutputsRouter);
|
||||
app.use('/api/profile', profileRouter);
|
||||
|
||||
app.use('/api/ml', requireAuth as any, requireAdmin as any, async (req: Request, res: Response) => {
|
||||
const mlUrl = config.ML_SERVING_URL;
|
||||
|
||||
130
services/api/src/profile/__tests__/eligibility.test.ts
Normal file
130
services/api/src/profile/__tests__/eligibility.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Unit tests for getEligibleAgentIds (ADR-0014 step 5).
|
||||
* DB is mocked via in-memory SQLite; fetchRegistry is mocked per scenario.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||||
import { makeTestDb } from '../../test/db.js';
|
||||
import { users, userConsents, userPreferences, userContexts } from '../../db/schema.js';
|
||||
|
||||
const testDb = makeTestDb();
|
||||
vi.mock('../../db/index.js', () => ({ db: testDb, rawSqlite: testDb.rawSqlite }));
|
||||
|
||||
// Registry mock — overridden per test.
|
||||
const mockFetchRegistry = vi.fn();
|
||||
vi.mock('../../routes/agent-registry.js', () => ({
|
||||
fetchRegistry: (...args: unknown[]) => mockFetchRegistry(...args),
|
||||
_resetRegistryCache: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getEligibleAgentIds } = await import('../eligibility.js');
|
||||
|
||||
const NOW = new Date().toISOString();
|
||||
const MANIFEST_DEFAULTS = {
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
pref_schema: {},
|
||||
context_schema: [],
|
||||
output_contract: {},
|
||||
ttl_sec: 300,
|
||||
};
|
||||
|
||||
const AGENT_A = { ...MANIFEST_DEFAULTS, id: 'agent-a', required_consents: ['data:core'], silenced_in_contexts: [] };
|
||||
const AGENT_B = { ...MANIFEST_DEFAULTS, id: 'agent-b', required_consents: ['data:core', 'data:todoist'], silenced_in_contexts: [] };
|
||||
const AGENT_C = { ...MANIFEST_DEFAULTS, id: 'agent-c', required_consents: ['data:core'], silenced_in_contexts: ['vacation'] };
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.insert(users).values({
|
||||
id: 'u1', email: 'u@test.com', name: null, image: null, role: 'user',
|
||||
consentGiven: false, createdAt: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchRegistry.mockReset();
|
||||
});
|
||||
|
||||
describe('getEligibleAgentIds', () => {
|
||||
it('returns empty set when registry is unavailable', async () => {
|
||||
mockFetchRegistry.mockRejectedValue(new Error('network'));
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.size).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes agents whose required consents are not granted', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_A, AGENT_B] });
|
||||
// only data:core granted
|
||||
await testDb.insert(userConsents).values({ userId: 'u1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null });
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-a')).toBe(true);
|
||||
expect(ids.has('agent-b')).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes agents when a required consent is revoked', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_B] });
|
||||
// grant then revoke data:todoist
|
||||
await testDb.insert(userConsents).values([
|
||||
{ userId: 'u1', consentKey: 'data:todoist', grantedAt: NOW, revokedAt: NOW },
|
||||
]).onConflictDoUpdate({
|
||||
target: [userConsents.userId, userConsents.consentKey],
|
||||
set: { revokedAt: NOW },
|
||||
});
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-b')).toBe(false);
|
||||
});
|
||||
|
||||
it('respects legacy consentGiven bit as data:core', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_A] });
|
||||
// no consent rows, but legacy bit set
|
||||
await testDb.update(users).set({ consentGiven: true });
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-a')).toBe(true);
|
||||
|
||||
await testDb.update(users).set({ consentGiven: false });
|
||||
});
|
||||
|
||||
it('silences agents whose silenced_in_contexts intersects active contexts', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_A, AGENT_C] });
|
||||
// ensure data:core granted
|
||||
await testDb.insert(userConsents).values({ userId: 'u1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null })
|
||||
.onConflictDoUpdate({ target: [userConsents.userId, userConsents.consentKey], set: { revokedAt: null } });
|
||||
// activate vacation context
|
||||
await testDb.insert(userContexts).values({ userId: 'u1', name: 'vacation', active: true, scheduleJson: null, createdAt: NOW });
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-a')).toBe(true);
|
||||
expect(ids.has('agent-c')).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes agents explicitly disabled via user_preferences', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_A] });
|
||||
await testDb.insert(userConsents).values({ userId: 'u1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null })
|
||||
.onConflictDoUpdate({ target: [userConsents.userId, userConsents.consentKey], set: { revokedAt: null } });
|
||||
await testDb.insert(userPreferences).values({
|
||||
userId: 'u1', scope: 'agent:agent-a', key: 'enabled', valueJson: 'false', source: 'user', updatedAt: NOW,
|
||||
}).onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson: 'false' },
|
||||
});
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-a')).toBe(false);
|
||||
});
|
||||
|
||||
it('includes agents when enabled pref is true (or absent)', async () => {
|
||||
mockFetchRegistry.mockResolvedValue({ agents: [AGENT_A] });
|
||||
await testDb.insert(userConsents).values({ userId: 'u1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null })
|
||||
.onConflictDoUpdate({ target: [userConsents.userId, userConsents.consentKey], set: { revokedAt: null } });
|
||||
await testDb.insert(userPreferences).values({
|
||||
userId: 'u1', scope: 'agent:agent-a', key: 'enabled', valueJson: 'true', source: 'user', updatedAt: NOW,
|
||||
}).onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson: 'true' },
|
||||
});
|
||||
|
||||
const ids = await getEligibleAgentIds('u1');
|
||||
expect(ids.has('agent-a')).toBe(true);
|
||||
});
|
||||
});
|
||||
88
services/api/src/profile/eligibility.ts
Normal file
88
services/api/src/profile/eligibility.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Registry-driven agent eligibility filter (ADR-0014 step 5).
|
||||
*
|
||||
* Rules (all must pass for an agent to be eligible):
|
||||
* 1. All required_consents are granted and not revoked.
|
||||
* 2. No silenced_in_contexts entry matches an active context.
|
||||
* 3. user_preferences[scope='agent:<id>', key='enabled'] is not false.
|
||||
*
|
||||
* Fail-closed: if the registry is unavailable, returns an empty set so the
|
||||
* orchestrator falls back to the random policy rather than proceeding without
|
||||
* consent checks.
|
||||
*/
|
||||
import { db } from '../db/index.js';
|
||||
import { users, userConsents, userPreferences, userContexts } from '../db/schema.js';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { fetchRegistry } from '../routes/agent-registry.js';
|
||||
|
||||
export interface AgentManifestWire {
|
||||
id: string;
|
||||
required_consents: string[];
|
||||
silenced_in_contexts: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RegistryPayload {
|
||||
agents: AgentManifestWire[];
|
||||
}
|
||||
|
||||
export async function getEligibleAgentIds(userId: string): Promise<Set<string>> {
|
||||
let registry: RegistryPayload;
|
||||
try {
|
||||
registry = (await fetchRegistry()) as RegistryPayload;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const [consentRows, prefRows, contextRows, userRow] = await Promise.all([
|
||||
db
|
||||
.select({ consentKey: userConsents.consentKey })
|
||||
.from(userConsents)
|
||||
.where(and(eq(userConsents.userId, userId), isNull(userConsents.revokedAt))),
|
||||
db
|
||||
.select({ scope: userPreferences.scope, key: userPreferences.key, valueJson: userPreferences.valueJson })
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, userId)),
|
||||
db
|
||||
.select({ name: userContexts.name, active: userContexts.active })
|
||||
.from(userContexts)
|
||||
.where(and(eq(userContexts.userId, userId), eq(userContexts.active, true))),
|
||||
db
|
||||
.select({ consentGiven: users.consentGiven })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
// Active consents (granted + not revoked)
|
||||
const activeConsents = new Set(consentRows.map((r) => r.consentKey));
|
||||
// Legacy fallback: consentGiven bit counts as data:core
|
||||
if (!activeConsents.has('data:core') && userRow[0]?.consentGiven) {
|
||||
activeConsents.add('data:core');
|
||||
}
|
||||
|
||||
// Active context names
|
||||
const activeContextNames = new Set(contextRows.map((r) => r.name));
|
||||
|
||||
// Per-agent enabled flag from user_preferences
|
||||
const agentEnabled: Record<string, boolean> = {};
|
||||
for (const p of prefRows) {
|
||||
if (!p.scope.startsWith('agent:')) continue;
|
||||
if (p.key !== 'enabled') continue;
|
||||
try {
|
||||
agentEnabled[p.scope] = JSON.parse(p.valueJson) as boolean;
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
|
||||
const eligible = new Set<string>();
|
||||
for (const manifest of registry.agents) {
|
||||
if (!manifest.required_consents.every((c) => activeConsents.has(c))) continue;
|
||||
if (manifest.silenced_in_contexts.some((ctx) => activeContextNames.has(ctx))) continue;
|
||||
const enabledPref = agentEnabled[`agent:${manifest.id}`];
|
||||
if (enabledPref === false) continue;
|
||||
eligible.add(manifest.id);
|
||||
}
|
||||
return eligible;
|
||||
}
|
||||
201
services/api/src/routes/__tests__/profile.test.ts
Normal file
201
services/api/src/routes/__tests__/profile.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Integration tests for GET/PATCH /api/profile (ADR-0014 step 4).
|
||||
* Real in-memory SQLite; auth middleware mocked so requests arrive as 'user-1'.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import express from 'express';
|
||||
import * as http from 'http';
|
||||
import { makeTestDb } from '../../test/db.js';
|
||||
import { users, userPreferences, userConsents, userContexts } from '../../db/schema.js';
|
||||
|
||||
const testDb = makeTestDb();
|
||||
|
||||
vi.mock('../../db/index.js', () => ({ db: testDb, rawSqlite: testDb.rawSqlite }));
|
||||
|
||||
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 = 'user-1';
|
||||
next();
|
||||
},
|
||||
}));
|
||||
|
||||
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 !== undefined) 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) }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const profileRouter = (await import('../profile.js')).default;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/profile', profileRouter);
|
||||
|
||||
const { server, call: c } = await startServer(app);
|
||||
afterAll(() => server.close());
|
||||
|
||||
const NOW = new Date().toISOString();
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.insert(users).values({
|
||||
id: 'user-1',
|
||||
email: 'a@example.com',
|
||||
name: 'Alice',
|
||||
image: null,
|
||||
role: 'user',
|
||||
consentGiven: false,
|
||||
tone: 'direct',
|
||||
tipKindsJson: JSON.stringify(['task', 'advice']),
|
||||
createdAt: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/profile', () => {
|
||||
it('returns user globals with empty prefs/consents/contexts', async () => {
|
||||
const res = await c('GET', '/api/profile');
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as any;
|
||||
expect(body.user).toMatchObject({ id: 'user-1', tone: 'direct', tipKinds: ['task', 'advice'] });
|
||||
expect(body.prefs).toEqual({});
|
||||
expect(body.consents).toEqual({});
|
||||
expect(body.contexts).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces legacy consentGiven as data:core when no consent row exists', async () => {
|
||||
await testDb.update(users).set({ consentGiven: true, consentAt: NOW });
|
||||
const res = await c('GET', '/api/profile');
|
||||
expect((res.body as any).consents['data:core']).toMatchObject({ revokedAt: null });
|
||||
await testDb.update(users).set({ consentGiven: false });
|
||||
});
|
||||
|
||||
it('includes prefs grouped by scope', async () => {
|
||||
await testDb.insert(userPreferences).values([
|
||||
{ userId: 'user-1', scope: 'orchestrator', key: 'quietHours', valueJson: '"22:00-07:00"', source: 'user', updatedAt: NOW },
|
||||
{ userId: 'user-1', scope: 'agent:focus-area', key: 'areas', valueJson: '["work","health"]', source: 'inferred', updatedAt: NOW },
|
||||
]);
|
||||
const res = await c('GET', '/api/profile');
|
||||
const body = res.body as any;
|
||||
expect(body.prefs['orchestrator']).toMatchObject({ quietHours: '22:00-07:00' });
|
||||
expect(body.prefs['agent:focus-area']).toMatchObject({ areas: ['work', 'health'] });
|
||||
});
|
||||
|
||||
it('includes consents', async () => {
|
||||
await testDb.insert(userConsents).values([
|
||||
{ userId: 'user-1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null },
|
||||
{ userId: 'user-1', consentKey: 'data:todoist', grantedAt: NOW, revokedAt: NOW },
|
||||
]);
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.consents['data:core'].revokedAt).toBeNull();
|
||||
expect(body.consents['data:todoist'].revokedAt).toBe(NOW);
|
||||
});
|
||||
|
||||
it('includes contexts', async () => {
|
||||
await testDb.insert(userContexts).values({
|
||||
userId: 'user-1', name: 'work', active: true, scheduleJson: null, createdAt: NOW,
|
||||
});
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.contexts).toContainEqual(expect.objectContaining({ name: 'work', active: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/profile/prefs/:scope', () => {
|
||||
it('upserts preference keys with source=user', async () => {
|
||||
const res = await c('PATCH', '/api/profile/prefs/orchestrator', { tone: 'gentle' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.prefs['orchestrator']['tone']).toBe('gentle');
|
||||
});
|
||||
|
||||
it('overwrites an inferred value with user source', async () => {
|
||||
await testDb.insert(userPreferences).values({
|
||||
userId: 'user-1', scope: 'agent:momentum', key: 'enabled', valueJson: 'false',
|
||||
source: 'inferred', updatedAt: NOW,
|
||||
}).onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson: 'false', source: 'inferred', updatedAt: NOW },
|
||||
});
|
||||
|
||||
await c('PATCH', '/api/profile/prefs/agent:momentum', { enabled: true });
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.prefs['agent:momentum']['enabled']).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 400 for non-object body', async () => {
|
||||
const res = await c('PATCH', '/api/profile/prefs/orchestrator', [1, 2]);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/profile/consents', () => {
|
||||
it('grants a new consent key', async () => {
|
||||
const res = await c('PATCH', '/api/profile/consents', { grant: ['data:calendar'] });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.consents['data:calendar'].revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('revokes an existing active consent', async () => {
|
||||
await c('PATCH', '/api/profile/consents', { grant: ['agent:overdue-task'] });
|
||||
await c('PATCH', '/api/profile/consents', { revoke: ['agent:overdue-task'] });
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.consents['agent:overdue-task'].revokedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns 400 when grant is not an array', async () => {
|
||||
const res = await c('PATCH', '/api/profile/consents', { grant: 'data:core' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/profile/contexts', () => {
|
||||
it('creates a new context', async () => {
|
||||
const res = await c('PATCH', '/api/profile/contexts', { name: 'vacation', active: false });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.contexts).toContainEqual(expect.objectContaining({ name: 'vacation', active: false }));
|
||||
});
|
||||
|
||||
it('toggles active on existing context', async () => {
|
||||
await c('PATCH', '/api/profile/contexts', { name: 'home', active: false });
|
||||
await c('PATCH', '/api/profile/contexts', { name: 'home', active: true });
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
const ctx = (body.contexts as any[]).find((x) => x.name === 'home');
|
||||
expect(ctx?.active).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 400 when name is missing', async () => {
|
||||
const res = await c('PATCH', '/api/profile/contexts', { active: true });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,8 @@ import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import * as http from 'http';
|
||||
import { makeTestDb } from '../../test/db.js';
|
||||
import { users, integrationTokens, tipScores } from '../../db/schema.js';
|
||||
import { users, integrationTokens, tipScores, agentOutputs, userConsents } from '../../db/schema.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
const testDb = makeTestDb();
|
||||
|
||||
@@ -155,4 +156,77 @@ describe('POST /recommend integration', () => {
|
||||
expect(row.promptVersion).toBeNull();
|
||||
expect(row.llmModel).toBeNull();
|
||||
});
|
||||
|
||||
it('eligibility filter: only passes consented agent outputs to ml/serving', async () => {
|
||||
const NOW = new Date().toISOString();
|
||||
const FUTURE = new Date(Date.now() + 60_000).toISOString();
|
||||
|
||||
// Grant data:core only — not data:todoist
|
||||
await testDb.insert(userConsents).values([
|
||||
{ userId: 'user-1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null },
|
||||
]).onConflictDoUpdate({
|
||||
target: [userConsents.userId, userConsents.consentKey],
|
||||
set: { revokedAt: null },
|
||||
});
|
||||
|
||||
// Two agent outputs: time-of-day (needs data:core only) and overdue-task (needs data:todoist too)
|
||||
await testDb.insert(agentOutputs).values([
|
||||
{
|
||||
id: nanoid(), userId: 'user-1', agentId: 'time-of-day',
|
||||
promptText: 'It is morning.',
|
||||
computedAt: NOW, expiresAt: FUTURE, agentVersion: '1.0.0',
|
||||
},
|
||||
{
|
||||
id: nanoid(), userId: 'user-1', agentId: 'overdue-task',
|
||||
promptText: 'You have overdue tasks.',
|
||||
computedAt: NOW, expiresAt: FUTURE, agentVersion: '1.0.0',
|
||||
},
|
||||
]);
|
||||
|
||||
// Manifest: time-of-day requires ['data:core'], overdue-task requires ['data:core','data:todoist']
|
||||
const registry = {
|
||||
agents: [
|
||||
{ id: 'time-of-day', required_consents: ['data:core'], silenced_in_contexts: [], version: '1.0.0', description: '', pref_schema: {}, context_schema: [], output_contract: {}, ttl_sec: 300, inferred_params: [] },
|
||||
{ id: 'overdue-task', required_consents: ['data:core', 'data:todoist'], silenced_in_contexts: [], version: '1.0.0', description: '', pref_schema: {}, context_schema: [], output_contract: {}, ttl_sec: 300, inferred_params: [] },
|
||||
],
|
||||
};
|
||||
|
||||
let capturedAgentOutputs: { agent_id: string }[] = [];
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('todoist.com')) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => ({ results: [] }) } as any);
|
||||
}
|
||||
if (u.includes('/agents/registry')) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => registry } as any);
|
||||
}
|
||||
if (u.includes('/recommend')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
json: async (req?: Request) => {
|
||||
// The body has already been sent; capture via the mock call args instead
|
||||
return { tip: { id: 'tip-x', content: 'Stay focused.' }, model: 'tip-generator' };
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 500 } as any);
|
||||
});
|
||||
|
||||
// Intercept the /recommend body to inspect what agent_outputs were sent
|
||||
const origFetch = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
const wrappedFetch = vi.fn().mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (String(url).includes('/recommend') && init?.body) {
|
||||
const body = JSON.parse(init.body as string);
|
||||
capturedAgentOutputs = body.agent_outputs ?? [];
|
||||
}
|
||||
return origFetch(url, init);
|
||||
});
|
||||
globalThis.fetch = wrappedFetch;
|
||||
|
||||
const { status } = await post(`${baseUrl}/api/recommend`);
|
||||
expect(status).toBe(200);
|
||||
|
||||
// Only time-of-day should have been passed; overdue-task is blocked (missing data:todoist)
|
||||
expect(capturedAgentOutputs.map((a) => a.agent_id)).toEqual(['time-of-day']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, type Request, type Response, type IRouter } from 'express';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { db } from '../db/index.js';
|
||||
import { agentOutputs, tipFeedback, tipViews } from '../db/schema.js';
|
||||
import { agentOutputs, tipFeedback, tipViews, userPreferences } from '../db/schema.js';
|
||||
import { eq, and, gt, lt } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { getProfile, type Profile } from '../profile/builder.js';
|
||||
@@ -78,6 +78,54 @@ router.get('/active-users', async (req: Request, res: Response) => {
|
||||
|
||||
// ── Core compute logic (used by route + scheduler) ───────────────────────────
|
||||
|
||||
/** Load agent prefs for a user from user_preferences, merging user+inferred.
|
||||
* User source wins: if both exist, the 'user' row is returned. */
|
||||
async function loadAgentPrefs(userId: string, agentId: string): Promise<Record<string, unknown>> {
|
||||
const scope = `agent:${agentId}`;
|
||||
const rows = await db
|
||||
.select({ key: userPreferences.key, valueJson: userPreferences.valueJson, source: userPreferences.source })
|
||||
.from(userPreferences)
|
||||
.where(and(eq(userPreferences.userId, userId), eq(userPreferences.scope, scope)));
|
||||
|
||||
// Build merged dict: 'user' source takes precedence over 'inferred'
|
||||
const merged: Record<string, { value: unknown; source: string }> = {};
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const value = JSON.parse(row.valueJson);
|
||||
const existing = merged[row.key];
|
||||
if (!existing || row.source === 'user') {
|
||||
merged[row.key] = { value, source: row.source };
|
||||
}
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(Object.entries(merged).map(([k, v]) => [k, v.value]));
|
||||
}
|
||||
|
||||
/** Persist inferred prefs to user_preferences, skipping keys the user has explicitly set. */
|
||||
async function persistInferredPrefs(
|
||||
userId: string,
|
||||
agentId: string,
|
||||
inferredPrefs: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (!Object.keys(inferredPrefs).length) return;
|
||||
const scope = `agent:${agentId}`;
|
||||
const now = new Date().toISOString();
|
||||
for (const [key, value] of Object.entries(inferredPrefs)) {
|
||||
const valueJson = JSON.stringify(value);
|
||||
await db
|
||||
.insert(userPreferences)
|
||||
.values({ userId, scope, key, valueJson, source: 'inferred', updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson, updatedAt: now },
|
||||
// Only overwrite rows already marked inferred; user overrides are untouched.
|
||||
setWhere: eq(userPreferences.source, 'inferred'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function computeAndStore(userId: string, agentId: string): Promise<void> {
|
||||
let tasks: object[] = [];
|
||||
try {
|
||||
@@ -111,10 +159,13 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
|
||||
created_at: f.createdAt,
|
||||
}));
|
||||
|
||||
// Load agent prefs (user overrides + previous inferences) to inject into the compute call.
|
||||
const agentPrefs = await loadAgentPrefs(userId, agentId);
|
||||
|
||||
const mlResp = await fetch(`${config.ML_SERVING_URL}/agents/${agentId}/compute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, tasks, profile, feedback_history: feedbackHistory }),
|
||||
body: JSON.stringify({ user_id: userId, tasks, profile, feedback_history: feedbackHistory, agent_prefs: agentPrefs }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
@@ -129,6 +180,23 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
|
||||
};
|
||||
|
||||
await storeAgentOutput(output);
|
||||
|
||||
// Run inference framework for this agent and persist results.
|
||||
// Failures are non-fatal — the compute result is already stored.
|
||||
try {
|
||||
const inferResp = await fetch(`${config.ML_SERVING_URL}/agents/${agentId}/infer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, feedback_history: feedbackHistory }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (inferResp.ok) {
|
||||
const inferResult = await inferResp.json() as { inferred_prefs: Record<string, unknown> };
|
||||
await persistInferredPrefs(userId, agentId, inferResult.inferred_prefs);
|
||||
}
|
||||
} catch {
|
||||
// inference failure is non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/agents/:agentId/compute ─────────────────────────────────────────
|
||||
|
||||
@@ -13,7 +13,7 @@ export function _resetRegistryCache() {
|
||||
_cache = null;
|
||||
}
|
||||
|
||||
async function fetchRegistry(): Promise<unknown> {
|
||||
export async function fetchRegistry(): Promise<unknown> {
|
||||
if (_cache && Date.now() - _cache.fetchedAt < CACHE_TTL_MS) return _cache.payload;
|
||||
const upstream = await fetch(`${config.ML_SERVING_URL}/agents/registry`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
|
||||
202
services/api/src/routes/profile.ts
Normal file
202
services/api/src/routes/profile.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* GET /api/profile — read-through: user globals + prefs + contexts + consents
|
||||
* PATCH /api/profile/prefs/:scope — upsert user_preferences rows (source='user')
|
||||
* PATCH /api/profile/consents — grant or revoke consent keys
|
||||
* PATCH /api/profile/contexts — activate/deactivate or create user contexts
|
||||
*
|
||||
* ADR-0014 step 4.
|
||||
*/
|
||||
import { Router, type Response, type IRouter } from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import {
|
||||
users,
|
||||
userPreferences,
|
||||
userConsents,
|
||||
userContexts,
|
||||
} from '../db/schema.js';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { requireAuth, type AuthenticatedRequest } from '../middleware/session.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// ── GET /api/profile ─────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/', requireAuth as any, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user || user.deletedAt) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const [prefs, consents, contexts] = await Promise.all([
|
||||
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)),
|
||||
db.select().from(userConsents).where(eq(userConsents.userId, userId)),
|
||||
db.select().from(userContexts).where(eq(userContexts.userId, userId)),
|
||||
]);
|
||||
|
||||
// Group prefs by scope: { 'orchestrator': { key: value_json, … }, 'agent:foo': { … } }
|
||||
const prefsByScope: Record<string, Record<string, unknown>> = {};
|
||||
for (const p of prefs) {
|
||||
if (!prefsByScope[p.scope]) prefsByScope[p.scope] = {};
|
||||
try {
|
||||
prefsByScope[p.scope][p.key] = JSON.parse(p.valueJson);
|
||||
} catch {
|
||||
prefsByScope[p.scope][p.key] = p.valueJson;
|
||||
}
|
||||
}
|
||||
|
||||
// Consents: include both active and revoked (callers can filter on revokedAt)
|
||||
// Also fold in the legacy consentGiven bit if no user_consents row exists yet.
|
||||
const consentMap: Record<string, { grantedAt: string; revokedAt: string | null }> = {};
|
||||
for (const c of consents) {
|
||||
consentMap[c.consentKey] = { grantedAt: c.grantedAt, revokedAt: c.revokedAt ?? null };
|
||||
}
|
||||
// Legacy fallback: if data:core is missing and the old bit is set, synthesise it.
|
||||
if (!consentMap['data:core'] && user.consentGiven) {
|
||||
consentMap['data:core'] = { grantedAt: user.consentAt ?? user.createdAt, revokedAt: null };
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
tone: user.tone ?? null,
|
||||
tipKinds: user.tipKindsJson ? JSON.parse(user.tipKindsJson) : null,
|
||||
},
|
||||
prefs: prefsByScope,
|
||||
consents: consentMap,
|
||||
contexts: contexts.map((c) => ({
|
||||
name: c.name,
|
||||
active: c.active,
|
||||
schedule: c.scheduleJson ? JSON.parse(c.scheduleJson) : null,
|
||||
createdAt: c.createdAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /api/profile/prefs/:scope ──────────────────────────────────────────
|
||||
// Body: { [key]: value } — each key is upserted as source='user'.
|
||||
|
||||
router.patch('/prefs/:scope', requireAuth as any, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
const { scope } = req.params;
|
||||
const body = req.body as Record<string, unknown>;
|
||||
|
||||
if (!scope || typeof scope !== 'string') {
|
||||
res.status(400).json({ error: 'scope is required' });
|
||||
return;
|
||||
}
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
res.status(400).json({ error: 'body must be a JSON object' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const valueJson = JSON.stringify(value);
|
||||
await db
|
||||
.insert(userPreferences)
|
||||
.values({ userId, scope, key, valueJson, source: 'user', updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson, source: 'user', updatedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── PATCH /api/profile/consents ───────────────────────────────────────────────
|
||||
// Body: { grant?: string[], revoke?: string[] }
|
||||
|
||||
router.patch('/consents', requireAuth as any, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
const { grant = [], revoke = [] } = req.body as { grant?: string[]; revoke?: string[] };
|
||||
|
||||
if (!Array.isArray(grant) || !Array.isArray(revoke)) {
|
||||
res.status(400).json({ error: 'grant and revoke must be arrays' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const key of grant) {
|
||||
await db
|
||||
.insert(userConsents)
|
||||
.values({ userId, consentKey: key, grantedAt: now, revokedAt: null })
|
||||
.onConflictDoUpdate({
|
||||
target: [userConsents.userId, userConsents.consentKey],
|
||||
set: { grantedAt: now, revokedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of revoke) {
|
||||
await db
|
||||
.update(userConsents)
|
||||
.set({ revokedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(userConsents.userId, userId),
|
||||
eq(userConsents.consentKey, key),
|
||||
isNull(userConsents.revokedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── PATCH /api/profile/contexts ───────────────────────────────────────────────
|
||||
// Body: { name: string, active?: boolean, schedule?: object|null }
|
||||
// Creates the row if it doesn't exist; toggles active / updates schedule.
|
||||
|
||||
router.patch('/contexts', requireAuth as any, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
const { name, active, schedule } = req.body as {
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
schedule?: unknown;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
res.status(400).json({ error: 'name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const scheduleJson = schedule !== undefined ? JSON.stringify(schedule) : undefined;
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userContexts)
|
||||
.where(and(eq(userContexts.userId, userId), eq(userContexts.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(userContexts).values({
|
||||
userId,
|
||||
name,
|
||||
active: active ?? false,
|
||||
scheduleJson: scheduleJson ?? null,
|
||||
createdAt: now,
|
||||
});
|
||||
} else {
|
||||
const set: Partial<typeof userContexts.$inferInsert> = {};
|
||||
if (active !== undefined) set.active = active;
|
||||
if (scheduleJson !== undefined) set.scheduleJson = scheduleJson;
|
||||
if (Object.keys(set).length > 0) {
|
||||
await db
|
||||
.update(userContexts)
|
||||
.set(set)
|
||||
.where(and(eq(userContexts.userId, userId), eq(userContexts.name, name)));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -12,6 +12,7 @@ import { todoistSource, dueAgeDays } from '../signals/todoist.js';
|
||||
export { dueAgeDays };
|
||||
import { SignalAggregator } from '../signals/aggregator.js';
|
||||
import { getActiveAgentOutputs } from './agent-outputs.js';
|
||||
import { getEligibleAgentIds } from '../profile/eligibility.js';
|
||||
|
||||
const router: ExpressRouter = Router();
|
||||
|
||||
@@ -58,11 +59,13 @@ async function fetchOrchestratorTip(
|
||||
dayOfWeek: number,
|
||||
traceparent?: string,
|
||||
): Promise<OrchestratorResult | null> {
|
||||
const agentRows = await getActiveAgentOutputs(userId);
|
||||
const agentOutputs = agentRows.map((r) => ({
|
||||
agent_id: r.agentId,
|
||||
prompt_text: r.promptText,
|
||||
}));
|
||||
const [allAgentRows, eligibleIds] = await Promise.all([
|
||||
getActiveAgentOutputs(userId),
|
||||
getEligibleAgentIds(userId),
|
||||
]);
|
||||
const agentOutputs = allAgentRows
|
||||
.filter((r) => eligibleIds.has(r.agentId))
|
||||
.map((r) => ({ agent_id: r.agentId, prompt_text: r.promptText }));
|
||||
|
||||
const tasks = signals.slice(0, 10).map((s) => ({
|
||||
content: s.content,
|
||||
|
||||
Reference in New Issue
Block a user