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")