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,14 +1,26 @@
from __future__ import annotations
from collections import Counter
from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest
from .inference.history import UserHistory
from .manifest import AgentManifest, InferredParam
_DOW_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
def _infer_preferred_hour(history: UserHistory) -> int:
"""Mode hour of day across all 'done' feedback events; falls back to 9."""
done_hours = [e.hour for e in history.events if e.action == "done"]
if not done_hours:
return 9
return Counter(done_hours).most_common(1)[0][0]
MANIFEST = AgentManifest(
id="time-of-day",
version="1.0.0",
version="1.1.0", # bumped: inferred_params added (ADR-0014 §3, #112)
description="Frames the current moment relative to the user's productive peak and quiet hours.",
pref_schema={
"type": "object",
@@ -30,6 +42,15 @@ MANIFEST = AgentManifest(
required_consents=["data:core", "agent:time-of-day"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=900,
inferred_params=[
InferredParam(
key="preferred_hour",
ttl_sec=3_600, # recompute hourly
cold_start_default=None,
min_history=10, # need at least 10 feedback events to be meaningful
infer=_infer_preferred_hour,
),
],
)
@@ -42,31 +63,63 @@ class TimeOfDayAgent(BaseAgent):
def compute(self, inp: AgentInput) -> AgentOutput:
hour = inp.now.hour
dow = inp.now.weekday() # 0=Monday … 6=Sunday
preferred = inp.profile.get("preferred_hour")
is_weekend = dow >= 5
# agent_prefs (inferred or user-set) take precedence over ML profile features.
preferred_raw = inp.agent_prefs.get("preferred_hour", inp.profile.get("preferred_hour"))
preferred = int(preferred_raw) if preferred_raw is not None else None
quiet_start: str | None = inp.agent_prefs.get("quiet_start")
quiet_end: str | None = inp.agent_prefs.get("quiet_end")
in_quiet = self._in_quiet_window(hour, quiet_start, quiet_end)
parts = [f"It is {hour:02d}:00 on {_DOW_NAMES[dow]} ({self._label(hour)})."]
if is_weekend:
parts.append("Weekend context — prefer personal or reflective tips over work tasks.")
if in_quiet:
parts.append(
f"User is in their quiet window ({quiet_start}{quiet_end}) — "
"avoid urgent or demanding tips."
)
if preferred is not None:
ph = int(preferred)
delta = min(abs(hour - ph), 24 - abs(hour - ph)) # circular distance
delta = min(abs(hour - preferred), 24 - abs(hour - preferred))
if delta == 0:
parts.append(
f"This is the user's peak productivity hour ({ph:02d}:00) — "
f"a high-impact tip is appropriate."
f"This is the user's peak productivity hour ({preferred:02d}:00) — "
"a high-impact tip is appropriate."
)
elif delta <= 2:
parts.append(f"Approaching the user's peak productivity window ({ph:02d}:00).")
parts.append(f"Approaching the user's peak productivity window ({preferred:02d}:00).")
else:
parts.append("No preferred-hour data yet.")
prompt = " ".join(parts)
snapshot = {"hour": hour, "day_of_week": dow, "preferred_hour": preferred}
snapshot = {
"hour": hour,
"day_of_week": dow,
"preferred_hour": preferred,
"quiet_start": quiet_start,
"quiet_end": quiet_end,
}
return self._make_output(inp, prompt, snapshot)
@staticmethod
def _in_quiet_window(hour: int, start: str | None, end: str | None) -> bool:
if not start or not end:
return False
try:
sh = int(start.split(":")[0])
eh = int(end.split(":")[0])
except (ValueError, IndexError):
return False
if sh <= eh:
return sh <= hour < eh
# wraps midnight e.g. 22:0007:00
return hour >= sh or hour < eh
@staticmethod
def _label(hour: int) -> str:
if 5 <= hour < 12: