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:
2026-05-05 11:14:25 +00:00
parent 305eeae38b
commit ad6747c242
19 changed files with 1196 additions and 24 deletions

View File

@@ -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 ─────────────────────────────────────────