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:
@@ -153,7 +153,7 @@ class TestTimeOfDayAgent:
|
||||
|
||||
def test_snapshot_keys(self):
|
||||
out = self.agent.compute(_inp())
|
||||
assert {"hour", "day_of_week", "preferred_hour"} == set(out.signals_snapshot)
|
||||
assert {"hour", "day_of_week", "preferred_hour", "quiet_start", "quiet_end"} == set(out.signals_snapshot)
|
||||
|
||||
|
||||
# ── RecentPatternsAgent ───────────────────────────────────────────────────────
|
||||
|
||||
120
ml/agents/tests/test_inference.py
Normal file
120
ml/agents/tests/test_inference.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Tests for the inference framework and time-of-day #112 proof."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ml.agents.inference.history import FeedbackEvent, UserHistory
|
||||
from ml.agents.inference.framework import run_inference
|
||||
from ml.agents.time_of_day import TimeOfDayAgent, MANIFEST as TOD_MANIFEST, MANIFEST
|
||||
from ml.agents.base import AgentInput
|
||||
|
||||
|
||||
_NOW = datetime(2026, 5, 1, 14, 0, 0, tzinfo=timezone.utc) # Thursday 14:00
|
||||
|
||||
|
||||
def _inp(**kwargs) -> AgentInput:
|
||||
defaults = dict(user_id="u1", tasks=[], profile={}, now=_NOW, agent_prefs={})
|
||||
defaults.update(kwargs)
|
||||
return AgentInput(**defaults)
|
||||
|
||||
|
||||
def _event(action: str, hour: int) -> FeedbackEvent:
|
||||
ts = f"2026-05-01T{hour:02d}:00:00+00:00"
|
||||
return FeedbackEvent(action=action, dwell_ms=60_000 if action == "done" else 500, created_at=ts)
|
||||
|
||||
|
||||
class TestRunInference:
|
||||
def test_cold_start_when_below_min_history(self):
|
||||
history = UserHistory(user_id="u1", events=[_event("done", 9)] * 5) # only 5 < 10
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert result["preferred_hour"] is None # cold_start_default
|
||||
|
||||
def test_infers_preferred_hour_as_mode(self):
|
||||
# 7 events at 09:00, 3 at 17:00 → preferred_hour should be 9
|
||||
events = [_event("done", 9)] * 7 + [_event("done", 17)] * 3
|
||||
history = UserHistory(user_id="u1", events=events)
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert result["preferred_hour"] == 9
|
||||
|
||||
def test_infers_preferred_hour_from_majority_hour(self):
|
||||
events = [_event("done", 20)] * 6 + [_event("done", 8)] * 4
|
||||
history = UserHistory(user_id="u1", events=events)
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert result["preferred_hour"] == 20
|
||||
|
||||
def test_no_inferred_params_returns_empty(self):
|
||||
from ml.agents.manifest import AgentManifest
|
||||
bare = AgentManifest(
|
||||
id="bare", version="1.0.0", description="", pref_schema={},
|
||||
context_schema=[], required_consents=[], output_contract={}, ttl_sec=300,
|
||||
)
|
||||
history = UserHistory(user_id="u1", events=[_event("done", 9)] * 20)
|
||||
result = run_inference(bare, history)
|
||||
assert result == {}
|
||||
|
||||
def test_cold_start_fallback_on_infer_error(self):
|
||||
"""infer() raising should fall back to cold_start_default, not crash."""
|
||||
from ml.agents.manifest import InferredParam, AgentManifest
|
||||
|
||||
def _bad_infer(h):
|
||||
raise RuntimeError("oops")
|
||||
|
||||
m = AgentManifest(
|
||||
id="boom", version="1.0.0", description="", pref_schema={},
|
||||
context_schema=[], required_consents=[], output_contract={}, ttl_sec=300,
|
||||
inferred_params=[InferredParam(key="x", ttl_sec=60, cold_start_default=42, min_history=1, infer=_bad_infer)],
|
||||
)
|
||||
history = UserHistory(user_id="u1", events=[_event("done", 9)] * 5)
|
||||
result = run_inference(m, history)
|
||||
assert result["x"] == 42
|
||||
|
||||
|
||||
class TestTimeOfDayAgentWithInference:
|
||||
agent = TimeOfDayAgent()
|
||||
|
||||
def test_uses_preferred_hour_from_agent_prefs(self):
|
||||
inp = _inp(agent_prefs={"preferred_hour": 9}, now=datetime(2026, 5, 1, 9, 0, 0, tzinfo=timezone.utc))
|
||||
out = self.agent.compute(inp)
|
||||
assert "peak productivity hour" in out.prompt_text.lower() or "peak" in out.prompt_text
|
||||
|
||||
def test_quiet_window_noon_suppressed(self):
|
||||
inp = _inp(
|
||||
agent_prefs={"quiet_start": "22:00", "quiet_end": "07:00"},
|
||||
now=datetime(2026, 5, 1, 23, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
out = self.agent.compute(inp)
|
||||
assert "quiet window" in out.prompt_text
|
||||
|
||||
def test_quiet_window_not_in_window(self):
|
||||
inp = _inp(
|
||||
agent_prefs={"quiet_start": "22:00", "quiet_end": "07:00"},
|
||||
now=datetime(2026, 5, 1, 14, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
out = self.agent.compute(inp)
|
||||
assert "quiet window" not in out.prompt_text
|
||||
|
||||
def test_agent_prefs_override_profile(self):
|
||||
# agent_prefs.preferred_hour wins over profile.preferred_hour
|
||||
inp = _inp(
|
||||
profile={"preferred_hour": 8},
|
||||
agent_prefs={"preferred_hour": 14},
|
||||
now=datetime(2026, 5, 1, 14, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
out = self.agent.compute(inp)
|
||||
assert "peak productivity hour (14:00)" in out.prompt_text
|
||||
|
||||
def test_no_prefs_falls_back_to_profile(self):
|
||||
inp = _inp(profile={"preferred_hour": 10}, now=datetime(2026, 5, 1, 10, 0, 0, tzinfo=timezone.utc))
|
||||
out = self.agent.compute(inp)
|
||||
assert "peak" in out.prompt_text
|
||||
|
||||
def test_version_bumped(self):
|
||||
assert MANIFEST.version == "1.1.0"
|
||||
|
||||
def test_manifest_has_preferred_hour_param(self):
|
||||
keys = {p.key for p in MANIFEST.inferred_params}
|
||||
assert "preferred_hour" in keys
|
||||
Reference in New Issue
Block a user