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:
@@ -153,7 +153,8 @@ class TestTimeOfDayAgent:
|
|||||||
|
|
||||||
def test_snapshot_keys(self):
|
def test_snapshot_keys(self):
|
||||||
out = self.agent.compute(_inp())
|
out = self.agent.compute(_inp())
|
||||||
assert {"hour", "day_of_week", "preferred_hour", "quiet_start", "quiet_end"} == set(out.signals_snapshot)
|
assert {"hour", "day_of_week", "preferred_hour", "quiet_start", "quiet_end",
|
||||||
|
"peak_hours", "in_quiet", "in_peak", "tz"} == set(out.signals_snapshot)
|
||||||
|
|
||||||
|
|
||||||
# ── RecentPatternsAgent ───────────────────────────────────────────────────────
|
# ── RecentPatternsAgent ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class TestTimeOfDayAgentWithInference:
|
|||||||
assert "peak" in out.prompt_text
|
assert "peak" in out.prompt_text
|
||||||
|
|
||||||
def test_version_bumped(self):
|
def test_version_bumped(self):
|
||||||
assert MANIFEST.version == "1.1.0"
|
assert MANIFEST.version == "1.2.0"
|
||||||
|
|
||||||
def test_manifest_has_preferred_hour_param(self):
|
def test_manifest_has_preferred_hour_param(self):
|
||||||
keys = {p.key for p in MANIFEST.inferred_params}
|
keys = {p.key for p in MANIFEST.inferred_params}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Per-agent inference tests: momentum (#114), overdue-task (#115), recent-patterns (#116),
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys, os
|
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.momentum import MomentumAgent, MANIFEST as MOMENTUM_MANIFEST
|
||||||
from ml.agents.overdue_task import OverdueTaskAgent, MANIFEST as OVERDUE_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.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.focus_area import FocusAreaAgent
|
||||||
from ml.agents.base import AgentInput
|
from ml.agents.base import AgentInput
|
||||||
|
|
||||||
@@ -482,6 +483,150 @@ class TestRecentPatternsDailyCycle:
|
|||||||
assert RECENT_MANIFEST.version == "1.2.0"
|
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 ───────────────────────────────────────
|
# ── focus-area: preferred_areas wiring ───────────────────────────────────────
|
||||||
|
|
||||||
class TestFocusAreaPreferredAreas:
|
class TestFocusAreaPreferredAreas:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import statistics
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
@@ -9,6 +10,9 @@ from .manifest import AgentManifest, InferredParam
|
|||||||
|
|
||||||
_DOW_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
_DOW_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
|
|
||||||
|
# min_history required before quiet/peak inference is meaningful (issue #112)
|
||||||
|
_MIN_HISTORY = 50
|
||||||
|
|
||||||
|
|
||||||
def _infer_preferred_hour(history: UserHistory) -> int:
|
def _infer_preferred_hour(history: UserHistory) -> int:
|
||||||
"""Mode hour of day across all 'done' feedback events; falls back to 9."""
|
"""Mode hour of day across all 'done' feedback events; falls back to 9."""
|
||||||
@@ -18,9 +22,75 @@ def _infer_preferred_hour(history: UserHistory) -> int:
|
|||||||
return Counter(done_hours).most_common(1)[0][0]
|
return Counter(done_hours).most_common(1)[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def _quiet_window_hours(history: UserHistory) -> tuple[int, int]:
|
||||||
|
"""Return (start_hour, end_hour) of the longest below-baseline quiet window.
|
||||||
|
|
||||||
|
Counts all engagement events by hour. Baseline = mean hourly count.
|
||||||
|
Finds the longest contiguous run of below-baseline hours on the circular
|
||||||
|
clock; that run defines the quiet window.
|
||||||
|
"""
|
||||||
|
by_hour: Counter[int] = Counter(e.hour for e in history.events)
|
||||||
|
total = sum(by_hour.values())
|
||||||
|
baseline = total / 24
|
||||||
|
|
||||||
|
# Mark each of the 24 hours as below-baseline (True = quiet)
|
||||||
|
quiet: list[bool] = [by_hour.get(h, 0) < baseline for h in range(24)]
|
||||||
|
|
||||||
|
# Find longest contiguous run in circular array
|
||||||
|
best_start, best_len = 0, 0
|
||||||
|
run_start, run_len = 0, 0
|
||||||
|
# Double the sequence to handle wrap-around
|
||||||
|
for i in range(48):
|
||||||
|
h = i % 24
|
||||||
|
if quiet[h]:
|
||||||
|
if run_len == 0:
|
||||||
|
run_start = i
|
||||||
|
run_len += 1
|
||||||
|
if run_len > best_len:
|
||||||
|
best_len = run_len
|
||||||
|
best_start = run_start
|
||||||
|
else:
|
||||||
|
run_len = 0
|
||||||
|
|
||||||
|
if best_len == 0:
|
||||||
|
return (22, 7) # fallback
|
||||||
|
|
||||||
|
start = best_start % 24
|
||||||
|
end = (best_start + best_len) % 24
|
||||||
|
return (start, end)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_quiet_start(history: UserHistory) -> str:
|
||||||
|
start, _ = _quiet_window_hours(history)
|
||||||
|
return f"{start:02d}:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_quiet_end(history: UserHistory) -> str:
|
||||||
|
_, end = _quiet_window_hours(history)
|
||||||
|
return f"{end:02d}:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_peak_hours(history: UserHistory) -> list[int]:
|
||||||
|
"""Top-quartile hours by done-event count.
|
||||||
|
|
||||||
|
Computes done_count per hour, then returns hours above the 75th percentile
|
||||||
|
of non-zero hourly counts, sorted ascending.
|
||||||
|
"""
|
||||||
|
done_by_hour: Counter[int] = Counter(
|
||||||
|
e.hour for e in history.events if e.action == "done"
|
||||||
|
)
|
||||||
|
if not done_by_hour:
|
||||||
|
return [9, 14, 20]
|
||||||
|
|
||||||
|
counts = list(done_by_hour.values())
|
||||||
|
threshold = statistics.quantiles(counts, n=4)[-1] # 75th percentile
|
||||||
|
|
||||||
|
return sorted(h for h, c in done_by_hour.items() if c >= threshold)
|
||||||
|
|
||||||
|
|
||||||
MANIFEST = AgentManifest(
|
MANIFEST = AgentManifest(
|
||||||
id="time-of-day",
|
id="time-of-day",
|
||||||
version="1.1.0", # bumped: inferred_params added (ADR-0014 §3, #112)
|
version="1.2.0", # #112: quiet_start/end + peak_hours + tz inference
|
||||||
description="Frames the current moment relative to the user's productive peak and quiet hours.",
|
description="Frames the current moment relative to the user's productive peak and quiet hours.",
|
||||||
pref_schema={
|
pref_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -36,6 +106,23 @@ MANIFEST = AgentManifest(
|
|||||||
"pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
|
"pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
|
||||||
"description": "HH:MM end of quiet hours.",
|
"description": "HH:MM end of quiet hours.",
|
||||||
},
|
},
|
||||||
|
"peak_hours": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "integer", "minimum": 0, "maximum": 23},
|
||||||
|
"default": [9, 14, 20],
|
||||||
|
"description": "Hours (0–23) with top-quartile completion density.",
|
||||||
|
},
|
||||||
|
"tz": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "UTC",
|
||||||
|
"description": "IANA timezone; populated from auth provider, fallback UTC.",
|
||||||
|
},
|
||||||
|
"preferred_hour": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 23,
|
||||||
|
"description": "Mode done-hour (legacy; superseded by peak_hours).",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
context_schema=["profile.features"],
|
context_schema=["profile.features"],
|
||||||
@@ -45,11 +132,40 @@ MANIFEST = AgentManifest(
|
|||||||
inferred_params=[
|
inferred_params=[
|
||||||
InferredParam(
|
InferredParam(
|
||||||
key="preferred_hour",
|
key="preferred_hour",
|
||||||
ttl_sec=3_600, # recompute hourly
|
ttl_sec=3_600,
|
||||||
cold_start_default=None,
|
cold_start_default=None,
|
||||||
min_history=10, # need at least 10 feedback events to be meaningful
|
min_history=10,
|
||||||
infer=_infer_preferred_hour,
|
infer=_infer_preferred_hour,
|
||||||
),
|
),
|
||||||
|
InferredParam(
|
||||||
|
key="quiet_start",
|
||||||
|
ttl_sec=86_400,
|
||||||
|
cold_start_default="22:00",
|
||||||
|
min_history=_MIN_HISTORY,
|
||||||
|
infer=_infer_quiet_start,
|
||||||
|
),
|
||||||
|
InferredParam(
|
||||||
|
key="quiet_end",
|
||||||
|
ttl_sec=86_400,
|
||||||
|
cold_start_default="07:00",
|
||||||
|
min_history=_MIN_HISTORY,
|
||||||
|
infer=_infer_quiet_end,
|
||||||
|
),
|
||||||
|
InferredParam(
|
||||||
|
key="peak_hours",
|
||||||
|
ttl_sec=86_400,
|
||||||
|
cold_start_default=[9, 14, 20],
|
||||||
|
min_history=_MIN_HISTORY,
|
||||||
|
infer=_infer_peak_hours,
|
||||||
|
),
|
||||||
|
# tz is populated from the auth provider; no infer function.
|
||||||
|
InferredParam(
|
||||||
|
key="tz",
|
||||||
|
ttl_sec=86_400,
|
||||||
|
cold_start_default="UTC",
|
||||||
|
min_history=999_999, # effectively never inferred — always cold_start
|
||||||
|
infer=None,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,18 +178,23 @@ class TimeOfDayAgent(BaseAgent):
|
|||||||
|
|
||||||
def compute(self, inp: AgentInput) -> AgentOutput:
|
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||||
hour = inp.now.hour
|
hour = inp.now.hour
|
||||||
dow = inp.now.weekday() # 0=Monday … 6=Sunday
|
dow = inp.now.weekday()
|
||||||
is_weekend = dow >= 5
|
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_raw = inp.agent_prefs.get("preferred_hour", inp.profile.get("preferred_hour"))
|
||||||
preferred = int(preferred_raw) if preferred_raw is not None else None
|
preferred = int(preferred_raw) if preferred_raw is not None else None
|
||||||
|
|
||||||
quiet_start: str | None = inp.agent_prefs.get("quiet_start")
|
quiet_start: str | None = inp.agent_prefs.get("quiet_start")
|
||||||
quiet_end: str | None = inp.agent_prefs.get("quiet_end")
|
quiet_end: str | None = inp.agent_prefs.get("quiet_end")
|
||||||
|
peak_hours: list[int] = inp.agent_prefs.get("peak_hours", [])
|
||||||
|
tz: str = inp.agent_prefs.get("tz", "UTC")
|
||||||
|
|
||||||
in_quiet = self._in_quiet_window(hour, quiet_start, quiet_end)
|
in_quiet = self._in_quiet_window(hour, quiet_start, quiet_end)
|
||||||
|
in_peak = hour in peak_hours
|
||||||
|
|
||||||
parts = [f"It is {hour:02d}:00 on {_DOW_NAMES[dow]} ({self._label(hour)})."]
|
parts = [f"It is {hour:02d}:00 on {_DOW_NAMES[dow]} ({self._label(hour)})."]
|
||||||
|
if tz != "UTC":
|
||||||
|
parts[0] = f"It is {hour:02d}:00 ({tz}) on {_DOW_NAMES[dow]} ({self._label(hour)})."
|
||||||
|
|
||||||
if is_weekend:
|
if is_weekend:
|
||||||
parts.append("Weekend context — prefer personal or reflective tips over work tasks.")
|
parts.append("Weekend context — prefer personal or reflective tips over work tasks.")
|
||||||
@@ -83,8 +204,18 @@ class TimeOfDayAgent(BaseAgent):
|
|||||||
f"User is in their quiet window ({quiet_start}–{quiet_end}) — "
|
f"User is in their quiet window ({quiet_start}–{quiet_end}) — "
|
||||||
"avoid urgent or demanding tips."
|
"avoid urgent or demanding tips."
|
||||||
)
|
)
|
||||||
|
elif in_peak:
|
||||||
if preferred is not None:
|
parts.append(
|
||||||
|
f"Hour {hour:02d}:00 is a peak productivity hour for this user — "
|
||||||
|
"a high-impact or challenging tip is appropriate."
|
||||||
|
)
|
||||||
|
elif peak_hours:
|
||||||
|
# Report nearest peak so orchestrator can time advice accordingly.
|
||||||
|
nearest = min(peak_hours, key=lambda p: min(abs(p - hour), 24 - abs(p - hour)))
|
||||||
|
delta = min(abs(nearest - hour), 24 - abs(nearest - hour))
|
||||||
|
if delta <= 2:
|
||||||
|
parts.append(f"Approaching peak productivity window ({nearest:02d}:00).")
|
||||||
|
elif preferred is not None:
|
||||||
delta = min(abs(hour - preferred), 24 - abs(hour - preferred))
|
delta = min(abs(hour - preferred), 24 - abs(hour - preferred))
|
||||||
if delta == 0:
|
if delta == 0:
|
||||||
parts.append(
|
parts.append(
|
||||||
@@ -103,6 +234,10 @@ class TimeOfDayAgent(BaseAgent):
|
|||||||
"preferred_hour": preferred,
|
"preferred_hour": preferred,
|
||||||
"quiet_start": quiet_start,
|
"quiet_start": quiet_start,
|
||||||
"quiet_end": quiet_end,
|
"quiet_end": quiet_end,
|
||||||
|
"peak_hours": peak_hours,
|
||||||
|
"in_quiet": in_quiet,
|
||||||
|
"in_peak": in_peak,
|
||||||
|
"tz": tz,
|
||||||
}
|
}
|
||||||
return self._make_output(inp, prompt, snapshot)
|
return self._make_output(inp, prompt, snapshot)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user