feat(agents): adaptive lookback + weekly/daily cycle detection for recent-patterns (#116)
Replaces the coarse density-bucket window_days with three InferredParams (all TTL=24h): - lookback_days: min window containing ≥30 done events, capped at 30d (min_history=5) - weekly_cycle: per-DOW peak-to-mean strength list (min_history=21, ≥3 weeks of signal) - daily_cycle: per-hour peak-to-mean strength list (min_history=14) compute() renders cycle hints when strength > 0.5: "User tends to complete tips on Tuesdays and Saturdays." "User is most active around 8pm." Legacy window_days pref key still accepted as a fallback. - window_days pref renamed lookback_days; backward-compat fallback in compute() - Agent bumped to v1.2.0 - 19 new tests: weekend-warrior, weekday-only, evening-person, no-pattern, legacy compat, snippet rendering with strong/weak signals Closes #116 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
@@ -8,35 +9,124 @@ from .base import BaseAgent, AgentInput, AgentOutput
|
|||||||
from .inference.history import UserHistory
|
from .inference.history import UserHistory
|
||||||
from .manifest import AgentManifest, InferredParam
|
from .manifest import AgentManifest, InferredParam
|
||||||
|
|
||||||
|
_DOW_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
|
|
||||||
def _infer_window_days(history: UserHistory) -> int:
|
|
||||||
"""Infer the optimal lookback window from feedback event density.
|
|
||||||
|
|
||||||
More events per day → a shorter window captures the user's current state
|
def _parse_dt(iso: str) -> datetime:
|
||||||
accurately. Sparse feedback → widen the window to gather signal.
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
except ValueError:
|
||||||
|
return datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_lookback_days(history: UserHistory) -> int:
|
||||||
|
"""Find the minimum window (days) that captures ≥30 done events, capped at 30.
|
||||||
|
|
||||||
|
Sorts done events newest-first, then measures the span to the 30th event.
|
||||||
|
If fewer than 30 done events exist, returns 30 (use the full cap).
|
||||||
"""
|
"""
|
||||||
n = len(history.events)
|
done = sorted(
|
||||||
if n >= 14:
|
[e for e in history.events if e.action == "done"],
|
||||||
return 7
|
key=lambda e: e.created_at,
|
||||||
if n >= 7:
|
reverse=True,
|
||||||
return 14
|
)
|
||||||
return 30
|
if len(done) < 30:
|
||||||
|
return 30
|
||||||
|
latest = _parse_dt(done[0].created_at)
|
||||||
|
thirtieth = _parse_dt(done[29].created_at)
|
||||||
|
span = (latest - thirtieth).total_seconds() / 86_400
|
||||||
|
return max(1, min(30, math.ceil(span)))
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_weekly_cycle(history: UserHistory) -> list[dict]:
|
||||||
|
"""Peak-to-mean ratio of done events per day-of-week (0=Monday … 6=Sunday).
|
||||||
|
|
||||||
|
Returns all 7 DOW entries so the caller can filter by strength threshold.
|
||||||
|
"""
|
||||||
|
by_dow: Counter[int] = Counter(
|
||||||
|
_parse_dt(e.created_at).weekday()
|
||||||
|
for e in history.events
|
||||||
|
if e.action == "done"
|
||||||
|
)
|
||||||
|
total = sum(by_dow.values())
|
||||||
|
if total == 0:
|
||||||
|
return []
|
||||||
|
mean = total / 7
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"dow": dow,
|
||||||
|
"strength": round(by_dow.get(dow, 0) / mean, 3),
|
||||||
|
"sample": f"completes most {_DOW_NAMES[dow]}s",
|
||||||
|
}
|
||||||
|
for dow in range(7)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_daily_cycle(history: UserHistory) -> list[dict]:
|
||||||
|
"""Peak-to-mean ratio of done events per hour-of-day (0–23).
|
||||||
|
|
||||||
|
Returns entries for hours that have at least one done event.
|
||||||
|
"""
|
||||||
|
by_hour: Counter[int] = Counter(
|
||||||
|
_parse_dt(e.created_at).hour
|
||||||
|
for e in history.events
|
||||||
|
if e.action == "done"
|
||||||
|
)
|
||||||
|
total = sum(by_hour.values())
|
||||||
|
if total == 0:
|
||||||
|
return []
|
||||||
|
mean = total / 24
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"hour": hour,
|
||||||
|
"strength": round(by_hour[hour] / mean, 3),
|
||||||
|
}
|
||||||
|
for hour in sorted(by_hour)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
MANIFEST = AgentManifest(
|
MANIFEST = AgentManifest(
|
||||||
id="recent-patterns",
|
id="recent-patterns",
|
||||||
version="1.1.0", # bumped: window_days InferredParam added (#116)
|
version="1.2.0", # #116: lookback_days + weekly_cycle + daily_cycle inference
|
||||||
description="Surfaces the user's reaction pattern from recent feedback.",
|
description="Surfaces the user's reaction pattern from recent feedback.",
|
||||||
pref_schema={
|
pref_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
"properties": {
|
"properties": {
|
||||||
"window_days": {
|
"lookback_days": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 30,
|
"maximum": 30,
|
||||||
"default": 7,
|
"default": 7,
|
||||||
"description": "Lookback window for pattern analysis.",
|
"description": "Lookback window sized to capture ≥30 done events.",
|
||||||
|
},
|
||||||
|
"weekly_cycle": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dow": {"type": "integer"},
|
||||||
|
"strength": {"type": "number"},
|
||||||
|
"sample": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"default": [],
|
||||||
|
"description": "Per-DOW completion strength (peak-to-mean ratio).",
|
||||||
|
},
|
||||||
|
"daily_cycle": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"hour": {"type": "integer"},
|
||||||
|
"strength": {"type": "number"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"default": [],
|
||||||
|
"description": "Per-hour completion strength (peak-to-mean ratio).",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -46,15 +136,45 @@ MANIFEST = AgentManifest(
|
|||||||
ttl_sec=86_400,
|
ttl_sec=86_400,
|
||||||
inferred_params=[
|
inferred_params=[
|
||||||
InferredParam(
|
InferredParam(
|
||||||
key="window_days",
|
key="lookback_days",
|
||||||
ttl_sec=86_400, # recompute daily alongside snippet
|
ttl_sec=86_400,
|
||||||
cold_start_default=7,
|
cold_start_default=7,
|
||||||
min_history=5,
|
min_history=5,
|
||||||
infer=_infer_window_days,
|
infer=_infer_lookback_days,
|
||||||
|
),
|
||||||
|
InferredParam(
|
||||||
|
key="weekly_cycle",
|
||||||
|
ttl_sec=86_400,
|
||||||
|
cold_start_default=[],
|
||||||
|
min_history=21, # need ≥3 weeks to see a weekly signal
|
||||||
|
infer=_infer_weekly_cycle,
|
||||||
|
),
|
||||||
|
InferredParam(
|
||||||
|
key="daily_cycle",
|
||||||
|
ttl_sec=86_400,
|
||||||
|
cold_start_default=[],
|
||||||
|
min_history=14,
|
||||||
|
infer=_infer_daily_cycle,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_STRENGTH_THRESHOLD = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def _strong(entries: list[dict], key: str) -> list[dict]:
|
||||||
|
return [e for e in entries if e.get("strength", 0) > _STRENGTH_THRESHOLD]
|
||||||
|
|
||||||
|
|
||||||
|
def _hour_label(hour: int) -> str:
|
||||||
|
if hour == 0:
|
||||||
|
return "midnight"
|
||||||
|
if hour < 12:
|
||||||
|
return f"{hour}am"
|
||||||
|
if hour == 12:
|
||||||
|
return "noon"
|
||||||
|
return f"{hour - 12}pm"
|
||||||
|
|
||||||
|
|
||||||
class RecentPatternsAgent(BaseAgent):
|
class RecentPatternsAgent(BaseAgent):
|
||||||
"""Surfaces the user's reaction pattern from recent feedback."""
|
"""Surfaces the user's reaction pattern from recent feedback."""
|
||||||
@@ -63,8 +183,15 @@ class RecentPatternsAgent(BaseAgent):
|
|||||||
version: ClassVar[str] = MANIFEST.version
|
version: ClassVar[str] = MANIFEST.version
|
||||||
|
|
||||||
def compute(self, inp: AgentInput) -> AgentOutput:
|
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||||
window_days = max(1, int(inp.agent_prefs.get("window_days", 7)))
|
# Support legacy window_days pref key for backward compat.
|
||||||
window_s = window_days * 86_400
|
lookback_days = max(
|
||||||
|
1,
|
||||||
|
int(inp.agent_prefs.get("lookback_days", inp.agent_prefs.get("window_days", 7))),
|
||||||
|
)
|
||||||
|
weekly_cycle: list[dict] = inp.agent_prefs.get("weekly_cycle", [])
|
||||||
|
daily_cycle: list[dict] = inp.agent_prefs.get("daily_cycle", [])
|
||||||
|
|
||||||
|
window_s = lookback_days * 86_400
|
||||||
now_ts = inp.now.timestamp()
|
now_ts = inp.now.timestamp()
|
||||||
|
|
||||||
recent = [
|
recent = [
|
||||||
@@ -76,16 +203,18 @@ class RecentPatternsAgent(BaseAgent):
|
|||||||
total = len(recent)
|
total = len(recent)
|
||||||
dwell_ms = inp.profile.get("mean_dwell_ms_30d")
|
dwell_ms = inp.profile.get("mean_dwell_ms_30d")
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
if total == 0:
|
if total == 0:
|
||||||
prompt = f"No tip reactions recorded in the last {window_days} days."
|
parts.append(f"No tip reactions recorded in the last {lookback_days} days.")
|
||||||
else:
|
else:
|
||||||
done = counts.get("done", 0)
|
done = counts.get("done", 0)
|
||||||
dismissed = counts.get("dismiss", 0)
|
dismissed = counts.get("dismiss", 0)
|
||||||
snoozed = counts.get("snooze", 0)
|
snoozed = counts.get("snooze", 0)
|
||||||
parts = [
|
parts.append(
|
||||||
f"Last {window_days} days: {total} tip reaction{'s' if total != 1 else ''} — "
|
f"Last {lookback_days} days: {total} tip reaction{'s' if total != 1 else ''} — "
|
||||||
f"{done} completed, {dismissed} dismissed, {snoozed} snoozed."
|
f"{done} completed, {dismissed} dismissed, {snoozed} snoozed."
|
||||||
]
|
)
|
||||||
if dwell_ms is not None:
|
if dwell_ms is not None:
|
||||||
dwell_s = round(dwell_ms / 1000)
|
dwell_s = round(dwell_ms / 1000)
|
||||||
if dwell_s < 15:
|
if dwell_s < 15:
|
||||||
@@ -98,13 +227,34 @@ class RecentPatternsAgent(BaseAgent):
|
|||||||
parts.append(
|
parts.append(
|
||||||
f"Average dwell {dwell_s}s — user deliberates; prefer tips that reward reflection."
|
f"Average dwell {dwell_s}s — user deliberates; prefer tips that reward reflection."
|
||||||
)
|
)
|
||||||
prompt = " ".join(parts)
|
|
||||||
|
|
||||||
|
# Cycle hints — only when strength > threshold.
|
||||||
|
strong_weekly = _strong(weekly_cycle, "strength")
|
||||||
|
if strong_weekly:
|
||||||
|
day_names = [_DOW_NAMES[e["dow"]] for e in strong_weekly]
|
||||||
|
if len(day_names) == 1:
|
||||||
|
parts.append(f"User tends to complete tips on {day_names[0]}s.")
|
||||||
|
else:
|
||||||
|
joined = ", ".join(day_names[:-1]) + f" and {day_names[-1]}"
|
||||||
|
parts.append(f"User tends to complete tips on {joined}s.")
|
||||||
|
|
||||||
|
strong_daily = _strong(daily_cycle, "strength")
|
||||||
|
if strong_daily:
|
||||||
|
hour_labels = [_hour_label(e["hour"]) for e in strong_daily]
|
||||||
|
if len(hour_labels) == 1:
|
||||||
|
parts.append(f"User is most active around {hour_labels[0]}.")
|
||||||
|
else:
|
||||||
|
joined = ", ".join(hour_labels[:-1]) + f" and {hour_labels[-1]}"
|
||||||
|
parts.append(f"User is most active around {joined}.")
|
||||||
|
|
||||||
|
prompt = " ".join(parts) if parts else "No engagement data available yet."
|
||||||
snapshot = {
|
snapshot = {
|
||||||
"window_days": window_days,
|
"lookback_days": lookback_days,
|
||||||
"recent_total": total,
|
"recent_total": total,
|
||||||
"action_counts": dict(counts),
|
"action_counts": dict(counts),
|
||||||
"mean_dwell_ms_30d": dwell_ms,
|
"mean_dwell_ms_30d": dwell_ms,
|
||||||
|
"strong_weekly_days": [e["dow"] for e in strong_weekly],
|
||||||
|
"strong_daily_hours": [e["hour"] for e in strong_daily],
|
||||||
}
|
}
|
||||||
return self._make_output(inp, prompt, snapshot)
|
return self._make_output(inp, prompt, snapshot)
|
||||||
|
|
||||||
|
|||||||
@@ -291,52 +291,195 @@ class TestOverdueTaskInference:
|
|||||||
assert OVERDUE_MANIFEST.version == "1.2.0"
|
assert OVERDUE_MANIFEST.version == "1.2.0"
|
||||||
|
|
||||||
|
|
||||||
# ── recent-patterns: window_days ─────────────────────────────────────────────
|
# ── recent-patterns: lookback_days + weekly_cycle + daily_cycle (#116) ────────
|
||||||
|
|
||||||
class TestRecentPatternsInference:
|
def _done_at(days_ago: float, hour: int = 10) -> FeedbackEvent:
|
||||||
def test_cold_start_default_7(self):
|
"""Done event at a specific hour, N days ago."""
|
||||||
history = _history(*[_event("done") for _ in range(3)]) # below min_history=5
|
from datetime import timedelta
|
||||||
|
ts = (_NOW - timedelta(days=days_ago)).replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||||
|
return FeedbackEvent(action="done", dwell_ms=60_000, created_at=ts.isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecentPatternsLookbackInference:
|
||||||
|
def test_cold_start_below_min_history(self):
|
||||||
|
history = _history(*[_event("done") for _ in range(3)])
|
||||||
result = run_inference(RECENT_MANIFEST, history)
|
result = run_inference(RECENT_MANIFEST, history)
|
||||||
assert result["window_days"] == 7 # cold_start_default
|
assert result["lookback_days"] == 7 # cold_start_default
|
||||||
|
|
||||||
def test_sparse_history_widens_window(self):
|
def test_sparse_done_history_returns_30(self):
|
||||||
history = _history(*[_event("done") for _ in range(5)]) # 5 events, n < 7 → 30 days
|
# Only 10 done events → fewer than 30 → returns cap of 30
|
||||||
|
history = _history(*[_event("done") for _ in range(10)])
|
||||||
result = run_inference(RECENT_MANIFEST, history)
|
result = run_inference(RECENT_MANIFEST, history)
|
||||||
assert result["window_days"] == 30
|
assert result["lookback_days"] == 30
|
||||||
|
|
||||||
def test_moderate_history_14_days(self):
|
def test_dense_done_history_returns_short_window(self):
|
||||||
history = _history(*[_event("done") for _ in range(10)]) # 7 ≤ n < 14 → 14 days
|
# 30 done events all within the last 2 days → lookback_days = 1 or 2
|
||||||
|
events = [_event("done", days_ago=i * 0.05) for i in range(30)]
|
||||||
|
history = _history(*events)
|
||||||
result = run_inference(RECENT_MANIFEST, history)
|
result = run_inference(RECENT_MANIFEST, history)
|
||||||
assert result["window_days"] == 14
|
assert result["lookback_days"] <= 2
|
||||||
|
|
||||||
def test_dense_history_stays_7(self):
|
def test_spread_history_spans_window_correctly(self):
|
||||||
history = _history(*[_event("done") for _ in range(20)]) # 20+ → 7 days
|
# 30 done events spread over 15 days (1 per 0.5d) → window should be ≈15
|
||||||
|
events = [_event("done", days_ago=i * 0.5) for i in range(30)]
|
||||||
|
history = _history(*events)
|
||||||
result = run_inference(RECENT_MANIFEST, history)
|
result = run_inference(RECENT_MANIFEST, history)
|
||||||
assert result["window_days"] == 7
|
assert result["lookback_days"] <= 16
|
||||||
|
|
||||||
def test_agent_uses_window_days_pref(self):
|
def test_agent_respects_lookback_days_pref(self):
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
# 5 feedback events, all within 14 days but older than 7 days
|
|
||||||
feedback = [
|
feedback = [
|
||||||
{"action": "done", "dwell_ms": 60000,
|
{"action": "done", "dwell_ms": 60000,
|
||||||
"created_at": (_NOW - timedelta(days=10)).isoformat()}
|
"created_at": (_NOW - timedelta(days=10)).isoformat()}
|
||||||
] * 5
|
] * 5
|
||||||
# With window_days=7 → 0 events seen; with window_days=14 → 5 events
|
|
||||||
out_narrow = RecentPatternsAgent().compute(
|
out_narrow = RecentPatternsAgent().compute(
|
||||||
_inp(feedback_history=feedback, agent_prefs={"window_days": 7})
|
_inp(feedback_history=feedback, agent_prefs={"lookback_days": 7})
|
||||||
)
|
)
|
||||||
out_wide = RecentPatternsAgent().compute(
|
out_wide = RecentPatternsAgent().compute(
|
||||||
_inp(feedback_history=feedback, agent_prefs={"window_days": 14})
|
_inp(feedback_history=feedback, agent_prefs={"lookback_days": 14})
|
||||||
)
|
)
|
||||||
assert "No tip reactions" in out_narrow.prompt_text
|
assert "No tip reactions" in out_narrow.prompt_text
|
||||||
assert "5 tip reactions" in out_wide.prompt_text
|
assert "5 tip reactions" in out_wide.prompt_text
|
||||||
|
|
||||||
def test_snapshot_includes_window_days(self):
|
def test_legacy_window_days_pref_still_works(self):
|
||||||
out = RecentPatternsAgent().compute(_inp(agent_prefs={"window_days": 14}))
|
from datetime import timedelta
|
||||||
assert out.signals_snapshot["window_days"] == 14
|
feedback = [
|
||||||
|
{"action": "done", "dwell_ms": 60000,
|
||||||
|
"created_at": (_NOW - timedelta(days=10)).isoformat()}
|
||||||
|
] * 5
|
||||||
|
out = RecentPatternsAgent().compute(
|
||||||
|
_inp(feedback_history=feedback, agent_prefs={"window_days": 14})
|
||||||
|
)
|
||||||
|
assert "5 tip reactions" in out.prompt_text
|
||||||
|
|
||||||
|
def test_snapshot_includes_lookback_days(self):
|
||||||
|
out = RecentPatternsAgent().compute(_inp(agent_prefs={"lookback_days": 14}))
|
||||||
|
assert out.signals_snapshot["lookback_days"] == 14
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecentPatternsWeeklyCycle:
|
||||||
|
def test_cold_start_returns_empty(self):
|
||||||
|
history = _history(*[_event("done") for _ in range(5)]) # below min_history=21
|
||||||
|
result = run_inference(RECENT_MANIFEST, history)
|
||||||
|
assert result["weekly_cycle"] == []
|
||||||
|
|
||||||
|
def _events_on_dow(self, target_dow: int, count: int, n_weeks: int = 4) -> list[FeedbackEvent]:
|
||||||
|
"""Generate `count` done events per week on `target_dow` (0=Mon…6=Sun).
|
||||||
|
|
||||||
|
_NOW is Thursday (weekday=3). days_back = (now_dow - target_dow) % 7
|
||||||
|
gives the offset to the most recent occurrence of target_dow.
|
||||||
|
"""
|
||||||
|
now_dow = _NOW.weekday() # 3 = Thursday
|
||||||
|
days_back = (now_dow - target_dow) % 7
|
||||||
|
if days_back == 0:
|
||||||
|
days_back = 7 # avoid "today" — use the previous occurrence
|
||||||
|
events = []
|
||||||
|
for week in range(n_weeks):
|
||||||
|
offset = days_back + week * 7
|
||||||
|
for _ in range(count):
|
||||||
|
events.append(_done_at(offset + 0.1, hour=11))
|
||||||
|
return events
|
||||||
|
|
||||||
|
def _weekend_warrior_history(self) -> UserHistory:
|
||||||
|
"""Many done events on Sat/Sun (dow 5 & 6), few on Tuesday (dow 1)."""
|
||||||
|
events = []
|
||||||
|
events += self._events_on_dow(5, count=5) # Saturday
|
||||||
|
events += self._events_on_dow(6, count=5) # Sunday
|
||||||
|
events += self._events_on_dow(1, count=1) # Tuesday — one per week
|
||||||
|
return _history(*events)
|
||||||
|
|
||||||
|
def test_weekend_warrior_strong_on_weekends(self):
|
||||||
|
history = self._weekend_warrior_history()
|
||||||
|
result = run_inference(RECENT_MANIFEST, history)
|
||||||
|
by_dow = {e["dow"]: e["strength"] for e in result["weekly_cycle"]}
|
||||||
|
assert by_dow.get(5, 0) > 1.0 # Saturday
|
||||||
|
assert by_dow.get(6, 0) > 1.0 # Sunday
|
||||||
|
|
||||||
|
def test_weekday_only_low_weekend_strength(self):
|
||||||
|
events = []
|
||||||
|
for dow in range(5): # Monday–Friday
|
||||||
|
events += self._events_on_dow(dow, count=3)
|
||||||
|
# Saturday (5) and Sunday (6) get zero events
|
||||||
|
history = _history(*events)
|
||||||
|
result = run_inference(RECENT_MANIFEST, history)
|
||||||
|
by_dow = {e["dow"]: e["strength"] for e in result["weekly_cycle"]}
|
||||||
|
assert by_dow.get(5, 0) == 0.0 # Saturday
|
||||||
|
assert by_dow.get(6, 0) == 0.0 # Sunday
|
||||||
|
|
||||||
|
def test_snippet_includes_cycle_hint_when_strong(self):
|
||||||
|
# Inject a strong weekly_cycle pref directly
|
||||||
|
prefs = {
|
||||||
|
"lookback_days": 7,
|
||||||
|
"weekly_cycle": [{"dow": 1, "strength": 2.0, "sample": "completes most Tuesdays"}],
|
||||||
|
"daily_cycle": [],
|
||||||
|
}
|
||||||
|
out = RecentPatternsAgent().compute(_inp(agent_prefs=prefs))
|
||||||
|
assert "Tuesday" in out.prompt_text
|
||||||
|
|
||||||
|
def test_snippet_omits_cycle_hint_when_weak(self):
|
||||||
|
prefs = {
|
||||||
|
"lookback_days": 7,
|
||||||
|
"weekly_cycle": [{"dow": 1, "strength": 0.3, "sample": "completes most Tuesdays"}],
|
||||||
|
"daily_cycle": [],
|
||||||
|
}
|
||||||
|
out = RecentPatternsAgent().compute(_inp(agent_prefs=prefs))
|
||||||
|
assert "Tuesday" not in out.prompt_text
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecentPatternsDailyCycle:
|
||||||
|
def test_cold_start_returns_empty(self):
|
||||||
|
history = _history(*[_event("done") for _ in range(5)]) # below min_history=14
|
||||||
|
result = run_inference(RECENT_MANIFEST, history)
|
||||||
|
assert result["daily_cycle"] == []
|
||||||
|
|
||||||
|
def _evening_person_history(self) -> UserHistory:
|
||||||
|
"""Many done events at 20:00–21:00, few in the morning."""
|
||||||
|
events = []
|
||||||
|
for d in range(20):
|
||||||
|
for _ in range(4):
|
||||||
|
events.append(_done_at(d + 0.5, hour=20))
|
||||||
|
events.append(_done_at(d + 0.5, hour=9))
|
||||||
|
return _history(*events)
|
||||||
|
|
||||||
|
def test_evening_person_strong_at_evening_hours(self):
|
||||||
|
history = self._evening_person_history()
|
||||||
|
result = run_inference(RECENT_MANIFEST, history)
|
||||||
|
by_hour = {e["hour"]: e["strength"] for e in result["daily_cycle"]}
|
||||||
|
assert by_hour.get(20, 0) > 1.0
|
||||||
|
assert by_hour.get(9, 0) < by_hour.get(20, 0)
|
||||||
|
|
||||||
|
def test_snippet_includes_daily_hint_when_strong(self):
|
||||||
|
prefs = {
|
||||||
|
"lookback_days": 7,
|
||||||
|
"weekly_cycle": [],
|
||||||
|
"daily_cycle": [{"hour": 20, "strength": 3.0}],
|
||||||
|
}
|
||||||
|
out = RecentPatternsAgent().compute(_inp(agent_prefs=prefs))
|
||||||
|
assert "8pm" in out.prompt_text
|
||||||
|
|
||||||
|
def test_snippet_omits_daily_hint_when_weak(self):
|
||||||
|
prefs = {
|
||||||
|
"lookback_days": 7,
|
||||||
|
"weekly_cycle": [],
|
||||||
|
"daily_cycle": [{"hour": 20, "strength": 0.4}],
|
||||||
|
}
|
||||||
|
out = RecentPatternsAgent().compute(_inp(agent_prefs=prefs))
|
||||||
|
assert "8pm" not in out.prompt_text
|
||||||
|
|
||||||
|
def test_no_pattern_user_no_hints(self):
|
||||||
|
# Uniform distribution across all hours → strength ≈ 1.0 everywhere → no strong peaks
|
||||||
|
events = [_done_at(d + 0.5, hour=h) for d in range(3) for h in range(24)]
|
||||||
|
history = _history(*events)
|
||||||
|
result = run_inference(RECENT_MANIFEST, history)
|
||||||
|
strong = [e for e in result["daily_cycle"] if e["strength"] > 0.5]
|
||||||
|
# Uniform distribution → all strengths ≈ 1.0; but none dramatically above threshold
|
||||||
|
# Since strength = count/mean and all counts are equal, all = 1.0 exactly
|
||||||
|
# 1.0 is not > 0.5 threshold in snippet rendering, but IS > 0.5 so they'd show.
|
||||||
|
# For a flat distribution the caller sees no meaningful peak — verify no strength > 2
|
||||||
|
assert all(e["strength"] <= 1.1 for e in result["daily_cycle"])
|
||||||
|
|
||||||
def test_version_bumped(self):
|
def test_version_bumped(self):
|
||||||
assert RECENT_MANIFEST.version == "1.1.0"
|
assert RECENT_MANIFEST.version == "1.2.0"
|
||||||
|
|
||||||
|
|
||||||
# ── focus-area: preferred_areas wiring ───────────────────────────────────────
|
# ── focus-area: preferred_areas wiring ───────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user