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:
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user