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:
@@ -15,6 +15,11 @@ class AgentInput:
|
||||
profile: dict[str, float | None] # profile feature values keyed by feature name
|
||||
feedback_history: list[dict] = field(default_factory=list) # [{action, dwell_ms, created_at}, …]
|
||||
now: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
# Per-agent inferred/user prefs loaded from user_preferences (ADR-0014 §3).
|
||||
# Keys match the agent's pref_schema + inferred_params. 'user' source takes
|
||||
# precedence over 'inferred' source; the caller resolves priority before
|
||||
# passing this dict in.
|
||||
agent_prefs: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
9
ml/agents/inference/__init__.py
Normal file
9
ml/agents/inference/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Shared context-inference framework (ADR-0014 §3, issue #111).
|
||||
|
||||
Each agent's manifest declares InferredParams; this package owns the
|
||||
scheduling contract, history data model, and write path to user_preferences.
|
||||
"""
|
||||
from .framework import run_inference
|
||||
from .history import FeedbackEvent, UserHistory
|
||||
|
||||
__all__ = ["run_inference", "FeedbackEvent", "UserHistory"]
|
||||
59
ml/agents/inference/framework.py
Normal file
59
ml/agents/inference/framework.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""run_inference — core of the context-inference framework (ADR-0014 §3).
|
||||
|
||||
Contract:
|
||||
run_inference(manifest, history) → dict[key, value]
|
||||
|
||||
Semantics:
|
||||
- For each InferredParam in manifest.inferred_params:
|
||||
- If len(history.events) < param.min_history → emit cold_start_default.
|
||||
- Otherwise → call param.infer(history) and emit the result.
|
||||
- Returns {key: value} ready for the caller to persist to user_preferences
|
||||
with source='inferred'.
|
||||
- User overrides (source='user') are handled by the caller's upsert logic;
|
||||
this function has no DB access.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..manifest import AgentManifest
|
||||
from .history import UserHistory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_inference(manifest: AgentManifest, history: UserHistory) -> dict[str, Any]:
|
||||
"""Evaluate all InferredParams for an agent and return {key: inferred_value}."""
|
||||
result: dict[str, Any] = {}
|
||||
n = len(history.events)
|
||||
|
||||
for param in manifest.inferred_params:
|
||||
t0 = time.monotonic()
|
||||
if param.infer is None:
|
||||
result[param.key] = param.cold_start_default
|
||||
continue
|
||||
if n < param.min_history:
|
||||
value = param.cold_start_default
|
||||
source = "cold_start"
|
||||
else:
|
||||
try:
|
||||
value = param.infer(history)
|
||||
source = "inferred"
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"inference_error agent=%s param=%s error=%s — using cold_start_default",
|
||||
manifest.id, param.key, exc,
|
||||
)
|
||||
value = param.cold_start_default
|
||||
source = "error_fallback"
|
||||
|
||||
latency_ms = round((time.monotonic() - t0) * 1000, 1)
|
||||
log.info(
|
||||
"inference_param agent=%s param=%s source=%s value=%r history_len=%d latency_ms=%s",
|
||||
manifest.id, param.key, source, value, n, latency_ms,
|
||||
)
|
||||
result[param.key] = value
|
||||
|
||||
return result
|
||||
29
ml/agents/inference/history.py
Normal file
29
ml/agents/inference/history.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""UserHistory — normalised view of a user's feedback events for inference."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeedbackEvent:
|
||||
action: str # 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful'
|
||||
dwell_ms: int | None
|
||||
created_at: str # ISO 8601
|
||||
|
||||
@property
|
||||
def hour(self) -> int:
|
||||
"""Hour of day (0-23) when the feedback was recorded."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(self.created_at.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return 12
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.hour
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserHistory:
|
||||
user_id: str
|
||||
events: list[FeedbackEvent] = field(default_factory=list)
|
||||
@@ -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
|
||||
@@ -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:00–07:00
|
||||
return hour >= sh or hour < eh
|
||||
|
||||
@staticmethod
|
||||
def _label(hour: int) -> str:
|
||||
if 5 <= hour < 12:
|
||||
|
||||
@@ -3,6 +3,7 @@ oO ML Serving — multi-agent orchestrator (ADR-0013).
|
||||
|
||||
Contract:
|
||||
POST /agents/{agent_id}/compute run a sub-agent, return prompt snippet
|
||||
POST /agents/{agent_id}/infer run inference framework for a user, return inferred prefs
|
||||
POST /recommend orchestrate agent snippets → one tip via LiteLLM
|
||||
POST /generate LLM tip candidates (legacy; kept for bench/eval)
|
||||
GET /health { ok, agents: [...] }
|
||||
@@ -38,7 +39,8 @@ if _repo_root not in sys.path:
|
||||
sys.path.insert(0, _repo_root)
|
||||
|
||||
from ml.agents.base import AgentInput # noqa: E402
|
||||
from ml.agents.registry import get_agent, all_agents, all_manifests # noqa: E402
|
||||
from ml.agents.registry import get_agent, all_agents, all_manifests, get_manifest # noqa: E402
|
||||
from ml.agents.inference import run_inference, FeedbackEvent, UserHistory # noqa: E402
|
||||
|
||||
logging_config.configure()
|
||||
|
||||
@@ -123,6 +125,8 @@ class AgentComputeRequest(BaseModel):
|
||||
profile: dict[str, Optional[float]] = {}
|
||||
feedback_history: list[dict] = []
|
||||
now_iso: Optional[str] = None # ISO 8601; defaults to utcnow
|
||||
# Per-agent prefs from user_preferences (merged: user source overrides inferred).
|
||||
agent_prefs: dict = {}
|
||||
|
||||
|
||||
class AgentComputeResponse(BaseModel):
|
||||
@@ -135,6 +139,18 @@ class AgentComputeResponse(BaseModel):
|
||||
agent_version: str
|
||||
|
||||
|
||||
class AgentInferRequest(BaseModel):
|
||||
user_id: str
|
||||
feedback_history: list[dict] = [] # [{action, dwell_ms, created_at}, …]
|
||||
|
||||
|
||||
class AgentInferResponse(BaseModel):
|
||||
user_id: str
|
||||
agent_id: str
|
||||
# {key: inferred_value} — caller persists to user_preferences with source='inferred'
|
||||
inferred_prefs: dict
|
||||
|
||||
|
||||
class AgentOutputSnippet(BaseModel):
|
||||
agent_id: str
|
||||
prompt_text: str
|
||||
@@ -225,6 +241,7 @@ async def compute_agent(agent_id: str, req: AgentComputeRequest) -> AgentCompute
|
||||
profile=req.profile,
|
||||
feedback_history=req.feedback_history,
|
||||
now=now,
|
||||
agent_prefs=req.agent_prefs,
|
||||
)
|
||||
try:
|
||||
output = agent.compute(inp)
|
||||
@@ -244,6 +261,46 @@ async def compute_agent(agent_id: str, req: AgentComputeRequest) -> AgentCompute
|
||||
)
|
||||
|
||||
|
||||
@app.post("/agents/{agent_id}/infer", response_model=AgentInferResponse)
|
||||
async def infer_agent(agent_id: str, req: AgentInferRequest) -> AgentInferResponse:
|
||||
"""Run the inference framework for one agent and return inferred preference values.
|
||||
|
||||
The caller (TS agent-outputs.ts) persists results to user_preferences
|
||||
with source='inferred', skipping keys where source='user' already exists.
|
||||
"""
|
||||
try:
|
||||
manifest = get_manifest(agent_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown agent: {agent_id!r}")
|
||||
|
||||
if not manifest.inferred_params:
|
||||
return AgentInferResponse(user_id=req.user_id, agent_id=agent_id, inferred_prefs={})
|
||||
|
||||
events = [
|
||||
FeedbackEvent(
|
||||
action=e.get("action", ""),
|
||||
dwell_ms=e.get("dwell_ms"),
|
||||
created_at=e.get("created_at", ""),
|
||||
)
|
||||
for e in req.feedback_history
|
||||
]
|
||||
history = UserHistory(user_id=req.user_id, events=events)
|
||||
|
||||
t0 = __import__("time").monotonic()
|
||||
inferred = run_inference(manifest, history)
|
||||
latency_ms = round((__import__("time").monotonic() - t0) * 1000, 1)
|
||||
|
||||
log.info(
|
||||
"inference_run",
|
||||
agent_id=agent_id,
|
||||
user_id=req.user_id,
|
||||
n_params=len(inferred),
|
||||
history_len=len(events),
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
return AgentInferResponse(user_id=req.user_id, agent_id=agent_id, inferred_prefs=inferred)
|
||||
|
||||
|
||||
@app.post("/recommend", response_model=RecommendResponse)
|
||||
async def recommend(req: RecommendRequest) -> RecommendResponse:
|
||||
"""Orchestrator: combine pre-computed agent outputs into one tip via LLM.
|
||||
|
||||
52
ml/serving/tests/test_infer_endpoint.py
Normal file
52
ml/serving/tests/test_infer_endpoint.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""POST /agents/{agent_id}/infer — inference framework endpoint."""
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from main import app
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_infer_time_of_day_cold_start():
|
||||
"""Fewer than min_history events → cold_start_default for preferred_hour."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post("/agents/time-of-day/infer", json={
|
||||
"user_id": "u1",
|
||||
"feedback_history": [
|
||||
{"action": "done", "dwell_ms": 60000, "created_at": "2026-05-01T09:00:00+00:00"},
|
||||
] * 5, # 5 < min_history=10
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["agent_id"] == "time-of-day"
|
||||
assert body["inferred_prefs"]["preferred_hour"] is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_infer_time_of_day_enough_history():
|
||||
"""10+ events → preferred_hour is inferred as the mode done-hour."""
|
||||
events = [{"action": "done", "dwell_ms": 60000, "created_at": "2026-05-01T09:00:00+00:00"}] * 10
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post("/agents/time-of-day/infer", json={"user_id": "u1", "feedback_history": events})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["inferred_prefs"]["preferred_hour"] == 9
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_infer_agent_with_no_inferred_params():
|
||||
"""Agents with no inferred_params return an empty dict."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post("/agents/overdue-task/infer", json={"user_id": "u1", "feedback_history": []})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["inferred_prefs"] == {}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_infer_unknown_agent_404():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post("/agents/ghost/infer", json={"user_id": "u1", "feedback_history": []})
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user