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
|
||||
|
||||
import math
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar
|
||||
@@ -8,35 +9,124 @@ from .base import BaseAgent, AgentInput, AgentOutput
|
||||
from .inference.history import UserHistory
|
||||
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
|
||||
accurately. Sparse feedback → widen the window to gather signal.
|
||||
def _parse_dt(iso: str) -> datetime:
|
||||
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)
|
||||
if n >= 14:
|
||||
return 7
|
||||
if n >= 7:
|
||||
return 14
|
||||
return 30
|
||||
done = sorted(
|
||||
[e for e in history.events if e.action == "done"],
|
||||
key=lambda e: e.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
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(
|
||||
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.",
|
||||
pref_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"window_days": {
|
||||
"lookback_days": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 30,
|
||||
"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,
|
||||
inferred_params=[
|
||||
InferredParam(
|
||||
key="window_days",
|
||||
ttl_sec=86_400, # recompute daily alongside snippet
|
||||
key="lookback_days",
|
||||
ttl_sec=86_400,
|
||||
cold_start_default=7,
|
||||
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):
|
||||
"""Surfaces the user's reaction pattern from recent feedback."""
|
||||
@@ -63,8 +183,15 @@ class RecentPatternsAgent(BaseAgent):
|
||||
version: ClassVar[str] = MANIFEST.version
|
||||
|
||||
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||
window_days = max(1, int(inp.agent_prefs.get("window_days", 7)))
|
||||
window_s = window_days * 86_400
|
||||
# Support legacy window_days pref key for backward compat.
|
||||
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()
|
||||
|
||||
recent = [
|
||||
@@ -76,16 +203,18 @@ class RecentPatternsAgent(BaseAgent):
|
||||
total = len(recent)
|
||||
dwell_ms = inp.profile.get("mean_dwell_ms_30d")
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
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:
|
||||
done = counts.get("done", 0)
|
||||
dismissed = counts.get("dismiss", 0)
|
||||
snoozed = counts.get("snooze", 0)
|
||||
parts = [
|
||||
f"Last {window_days} days: {total} tip reaction{'s' if total != 1 else ''} — "
|
||||
parts.append(
|
||||
f"Last {lookback_days} days: {total} tip reaction{'s' if total != 1 else ''} — "
|
||||
f"{done} completed, {dismissed} dismissed, {snoozed} snoozed."
|
||||
]
|
||||
)
|
||||
if dwell_ms is not None:
|
||||
dwell_s = round(dwell_ms / 1000)
|
||||
if dwell_s < 15:
|
||||
@@ -98,13 +227,34 @@ class RecentPatternsAgent(BaseAgent):
|
||||
parts.append(
|
||||
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 = {
|
||||
"window_days": window_days,
|
||||
"lookback_days": lookback_days,
|
||||
"recent_total": total,
|
||||
"action_counts": dict(counts),
|
||||
"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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user