Files
oO/ml/agents/recent_patterns.py
alvis afb0e9b0cb feat(agents): per-agent inference — momentum, overdue-task, recent-patterns, focus-area (ADR-0014 step 7)
All four agents bumped to v1.1.0.

momentum (#114): infers engagement_trend ('up'|'stable'|'down') by comparing
done-rate in the last 7 days vs the prior 7 days. Agent surfaces the trend
in its snippet ("trending up — build on the momentum").

overdue-task (#115): infers lateness_tolerance_days (0/1/2) from snooze rate.
Agent now filters tasks against the tolerance so low-urgency users aren't
nagged about tasks that are only hours overdue.

recent-patterns (#116): infers window_days (7/14/30) from feedback event
density — sparse users get a wider window so the snippet isn't always empty.

focus-area (#113): no inferred params (project-level feedback linkage needed,
tracked under #78). preferred_areas pref was declared but ignored; agent now
honours it as a tiebreaker and mentions it in the snippet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:21:10 +00:00

122 lines
4.1 KiB
Python

from __future__ import annotations
from collections import Counter
from datetime import datetime, timezone
from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput
from .inference.history import UserHistory
from .manifest import AgentManifest, InferredParam
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.
"""
n = len(history.events)
if n >= 14:
return 7
if n >= 7:
return 14
return 30
MANIFEST = AgentManifest(
id="recent-patterns",
version="1.1.0", # bumped: window_days InferredParam added (#116)
description="Surfaces the user's reaction pattern from recent feedback.",
pref_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"window_days": {
"type": "integer",
"minimum": 1,
"maximum": 30,
"default": 7,
"description": "Lookback window for pattern analysis.",
},
},
},
context_schema=["tip_feedback", "profile.features"],
required_consents=["data:core", "agent:recent-patterns"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=86_400,
inferred_params=[
InferredParam(
key="window_days",
ttl_sec=86_400, # recompute daily alongside snippet
cold_start_default=7,
min_history=5,
infer=_infer_window_days,
),
],
)
class RecentPatternsAgent(BaseAgent):
"""Surfaces the user's reaction pattern from recent feedback."""
agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
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
now_ts = inp.now.timestamp()
recent = [
f for f in inp.feedback_history
if self._age_s(f.get("created_at", ""), now_ts) <= window_s
]
counts: Counter[str] = Counter(f.get("action") for f in recent)
total = len(recent)
dwell_ms = inp.profile.get("mean_dwell_ms_30d")
if total == 0:
prompt = f"No tip reactions recorded in the last {window_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 ''}"
f"{done} completed, {dismissed} dismissed, {snoozed} snoozed."
]
if dwell_ms is not None:
dwell_s = round(dwell_ms / 1000)
if dwell_s < 15:
parts.append(
"Average dwell is very short — user may be acting on auto-pilot; vary tip content."
)
elif dwell_s < 60:
parts.append(f"Average dwell {dwell_s}s — tips are being read.")
else:
parts.append(
f"Average dwell {dwell_s}s — user deliberates; prefer tips that reward reflection."
)
prompt = " ".join(parts)
snapshot = {
"window_days": window_days,
"recent_total": total,
"action_counts": dict(counts),
"mean_dwell_ms_30d": dwell_ms,
}
return self._make_output(inp, prompt, snapshot)
@staticmethod
def _age_s(iso: str, now_ts: float) -> float:
if not iso:
return float("inf")
try:
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return now_ts - dt.timestamp()
except Exception:
return float("inf")