feat(agents): quiet window + peak hours + tz prefs for time-of-day agent (#112)
Adds four InferredParams (all TTL=24h, min_history=50 except preferred_hour=10):
- quiet_start / quiet_end: longest contiguous below-baseline hour run (HH:MM)
- peak_hours: top-quartile done-event hours, sorted ascending
- tz: cold-start only ("UTC"); populated from auth provider, no inference function
compute() updated:
- in_quiet check (quiet window) takes precedence over peak hours
- in_peak emits "peak productivity hour" language when current hour is in peak_hours
- approaching peak (within 2h) surfaces for orchestrator timing
- tz surfaced in snippet header when not UTC
- snapshot adds peak_hours, in_quiet, in_peak, tz
- Agent bumped to v1.2.0
- 21 new tests: night-owl, early-bird, shift-worker, quiet/peak snippet rendering
- Fixed test_snapshot_keys in test_agents.py to include new snapshot fields
Closes #112
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""Per-agent inference tests: momentum (#114), overdue-task (#115), recent-patterns (#116),
|
||||
and focus-area (#113) preferred_areas wiring."""
|
||||
time-of-day (#112), and focus-area (#113) preferred_areas wiring."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys, os
|
||||
@@ -13,6 +13,7 @@ from ml.agents.inference.framework import run_inference
|
||||
from ml.agents.momentum import MomentumAgent, MANIFEST as MOMENTUM_MANIFEST
|
||||
from ml.agents.overdue_task import OverdueTaskAgent, MANIFEST as OVERDUE_MANIFEST
|
||||
from ml.agents.recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_MANIFEST
|
||||
from ml.agents.time_of_day import TimeOfDayAgent, MANIFEST as TOD_MANIFEST
|
||||
from ml.agents.focus_area import FocusAreaAgent
|
||||
from ml.agents.base import AgentInput
|
||||
|
||||
@@ -482,6 +483,150 @@ class TestRecentPatternsDailyCycle:
|
||||
assert RECENT_MANIFEST.version == "1.2.0"
|
||||
|
||||
|
||||
# ── time-of-day: quiet_start/end + peak_hours inference (#112) ───────────────
|
||||
|
||||
def _tod_event(action: str, hour: int, days_ago: float = 1.0) -> FeedbackEvent:
|
||||
"""Feedback event at a specific hour N days ago."""
|
||||
from datetime import timedelta
|
||||
dt = (_NOW - timedelta(days=days_ago)).replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
return FeedbackEvent(action=action, dwell_ms=60_000, created_at=dt.isoformat())
|
||||
|
||||
|
||||
def _tod_history(*events: FeedbackEvent) -> UserHistory:
|
||||
return UserHistory(user_id="u1", events=list(events))
|
||||
|
||||
|
||||
class TestTimeOfDayQuietWindow:
|
||||
def test_cold_start_below_min_history(self):
|
||||
history = _tod_history(*[_tod_event("done", 10) for _ in range(10)])
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert result["quiet_start"] == "22:00"
|
||||
assert result["quiet_end"] == "07:00"
|
||||
|
||||
def _night_owl_history(self) -> UserHistory:
|
||||
"""Active 20:00–23:00, quiet 02:00–14:00."""
|
||||
events = []
|
||||
for d in range(10):
|
||||
for h in [20, 21, 22, 23, 0, 1]:
|
||||
events.append(_tod_event("done", h, days_ago=d + 0.5))
|
||||
# Sparse during day
|
||||
events.append(_tod_event("done", 15, days_ago=d + 0.5))
|
||||
return _tod_history(*events)
|
||||
|
||||
def _early_bird_history(self) -> UserHistory:
|
||||
"""Active 06:00–10:00, quiet 21:00–05:00."""
|
||||
events = []
|
||||
for d in range(10):
|
||||
for h in [6, 7, 8, 9, 10]:
|
||||
events.append(_tod_event("done", h, days_ago=d + 0.5))
|
||||
events.append(_tod_event("done", 14, days_ago=d + 0.5))
|
||||
return _tod_history(*events)
|
||||
|
||||
def test_early_bird_quiet_in_evening(self):
|
||||
history = self._early_bird_history()
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
# Quiet window should be in the evening/night range
|
||||
start_h = int(result["quiet_start"].split(":")[0])
|
||||
end_h = int(result["quiet_end"].split(":")[0])
|
||||
# Quiet window spans from some evening hour into morning
|
||||
assert start_h >= 18 or end_h <= 10 # covers night
|
||||
|
||||
def test_quiet_window_wraps_midnight(self):
|
||||
# Night owl: heavy activity in evening, quiet 02:00–14:00
|
||||
history = self._night_owl_history()
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
start_h = int(result["quiet_start"].split(":")[0])
|
||||
end_h = int(result["quiet_end"].split(":")[0])
|
||||
# The quiet window should span across midnight or be in daylight
|
||||
# (start > end means wraps midnight)
|
||||
is_wrapping = start_h > end_h
|
||||
is_daytime = 2 <= start_h <= 14
|
||||
assert is_wrapping or is_daytime
|
||||
|
||||
def test_format_is_hhmm(self):
|
||||
history = self._early_bird_history()
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
import re
|
||||
assert re.match(r"^\d{2}:00$", result["quiet_start"])
|
||||
assert re.match(r"^\d{2}:00$", result["quiet_end"])
|
||||
|
||||
|
||||
class TestTimeOfDayPeakHours:
|
||||
def _evening_person_history(self, n: int = 60) -> UserHistory:
|
||||
"""Heavy done events at 19:00 and 20:00, light elsewhere."""
|
||||
events = []
|
||||
for i in range(n):
|
||||
events.append(_tod_event("done", 19, days_ago=i * 0.5))
|
||||
events.append(_tod_event("done", 20, days_ago=i * 0.5))
|
||||
events.append(_tod_event("done", 10, days_ago=i * 0.5)) # low volume
|
||||
return _tod_history(*events)
|
||||
|
||||
def test_cold_start_returns_default(self):
|
||||
history = _tod_history(*[_tod_event("done", 10) for _ in range(5)])
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert result["peak_hours"] == [9, 14, 20]
|
||||
|
||||
def test_evening_person_peak_hours_in_evening(self):
|
||||
history = self._evening_person_history()
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert 19 in result["peak_hours"] or 20 in result["peak_hours"]
|
||||
|
||||
def test_peak_hours_sorted(self):
|
||||
history = self._evening_person_history()
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert result["peak_hours"] == sorted(result["peak_hours"])
|
||||
|
||||
def test_shift_worker_peaks_at_unusual_hours(self):
|
||||
"""Shift worker active at 02:00 and 03:00."""
|
||||
events = [_tod_event("done", h, days_ago=i * 0.5)
|
||||
for i in range(30) for h in [2, 3]]
|
||||
events += [_tod_event("done", 14, days_ago=i * 0.5) for i in range(5)]
|
||||
history = _tod_history(*events)
|
||||
result = run_inference(TOD_MANIFEST, history)
|
||||
assert 2 in result["peak_hours"] or 3 in result["peak_hours"]
|
||||
|
||||
|
||||
class TestTimeOfDaySnippet:
|
||||
agent = TimeOfDayAgent()
|
||||
|
||||
def _inp_at(self, hour: int, **prefs) -> AgentInput:
|
||||
from datetime import timedelta
|
||||
now = _NOW.replace(hour=hour)
|
||||
return _inp(now=now, agent_prefs=prefs)
|
||||
|
||||
def test_in_peak_hour_says_peak(self):
|
||||
out = self.agent.compute(self._inp_at(20, peak_hours=[20]))
|
||||
assert "peak productivity hour" in out.prompt_text
|
||||
|
||||
def test_approaching_peak_says_approaching(self):
|
||||
out = self.agent.compute(self._inp_at(18, peak_hours=[20]))
|
||||
assert "approaching" in out.prompt_text.lower()
|
||||
|
||||
def test_quiet_window_overrides_peak(self):
|
||||
# Even if hour is in peak_hours, quiet window wins
|
||||
out = self.agent.compute(
|
||||
self._inp_at(23, quiet_start="22:00", quiet_end="07:00", peak_hours=[23])
|
||||
)
|
||||
assert "quiet window" in out.prompt_text
|
||||
|
||||
def test_tz_shown_when_not_utc(self):
|
||||
out = self.agent.compute(self._inp_at(10, tz="Europe/Moscow"))
|
||||
assert "Europe/Moscow" in out.prompt_text
|
||||
|
||||
def test_snapshot_includes_peak_and_quiet(self):
|
||||
out = self.agent.compute(self._inp_at(10, peak_hours=[10], quiet_start="22:00", quiet_end="07:00"))
|
||||
assert "peak_hours" in out.signals_snapshot
|
||||
assert "in_quiet" in out.signals_snapshot
|
||||
assert "in_peak" in out.signals_snapshot
|
||||
|
||||
def test_version_bumped(self):
|
||||
assert TOD_MANIFEST.version == "1.2.0"
|
||||
|
||||
def test_manifest_has_new_params(self):
|
||||
keys = {p.key for p in TOD_MANIFEST.inferred_params}
|
||||
assert {"quiet_start", "quiet_end", "peak_hours", "tz"}.issubset(keys)
|
||||
|
||||
|
||||
# ── focus-area: preferred_areas wiring ───────────────────────────────────────
|
||||
|
||||
class TestFocusAreaPreferredAreas:
|
||||
|
||||
Reference in New Issue
Block a user