from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import ClassVar from .base import BaseAgent, AgentInput, AgentOutput from .inference.history import UserHistory from .manifest import AgentManifest, InferredParam def _infer_engagement_trend(history: UserHistory) -> str: """Compare done-rate in the most recent 7 days vs the 7 days before that.""" events = sorted(history.events, key=lambda e: e.created_at) if not events: return "stable" try: latest = datetime.fromisoformat(events[-1].created_at.replace("Z", "+00:00")) except ValueError: return "stable" cutoff_recent = latest - timedelta(days=7) cutoff_older = latest - timedelta(days=14) recent = [e for e in events if _parse_dt(e.created_at) >= cutoff_recent] older = [e for e in events if cutoff_older <= _parse_dt(e.created_at) < cutoff_recent] if len(older) < 3: return "stable" # not enough baseline to compare recent_rate = sum(1 for e in recent if e.action == "done") / max(len(recent), 1) older_rate = sum(1 for e in older if e.action == "done") / max(len(older), 1) delta = recent_rate - older_rate if delta > 0.10: return "up" if delta < -0.10: return "down" return "stable" 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) MANIFEST = AgentManifest( id="momentum", version="1.1.0", # bumped: engagement_trend InferredParam added (#114) description="Characterises the user's recent engagement trend from profile features.", pref_schema={ "type": "object", "additionalProperties": False, "properties": { "low_engagement_threshold_pct": { "type": "integer", "minimum": 0, "maximum": 100, "default": 25, "description": "Completion rate below which momentum hints at low engagement.", }, }, }, context_schema=["profile.features"], required_consents=["data:core", "agent:momentum"], output_contract={"type": "snippet", "format": "free_text"}, ttl_sec=21_600, inferred_params=[ InferredParam( key="engagement_trend", ttl_sec=21_600, # recompute every 6 hours alongside snippet cold_start_default="stable", min_history=10, infer=_infer_engagement_trend, ), ], ) class MomentumAgent(BaseAgent): """Characterises the user's recent engagement trend from profile features.""" agent_id: ClassVar[str] = MANIFEST.id ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec version: ClassVar[str] = MANIFEST.version def compute(self, inp: AgentInput) -> AgentOutput: completion = inp.profile.get("completion_rate_30d") dismiss = inp.profile.get("dismiss_rate_30d") volume = inp.profile.get("tip_volume_30d") trend: str = inp.agent_prefs.get("engagement_trend", "stable") parts: list[str] = [] if completion is not None: pct = round(completion * 100) if pct >= 50: parts.append(f"The user completes {pct}% of tips (strong engagement).") elif pct >= 25: parts.append(f"The user completes {pct}% of tips (moderate engagement).") else: parts.append( f"The user completes {pct}% of tips " f"(low engagement — prefer simple, immediately actionable tips)." ) else: parts.append("No completion-rate data yet (new user).") if dismiss is not None: dpct = round(dismiss * 100) if dpct >= 40: parts.append(f"Dismiss rate is high ({dpct}%) — avoid repetitive or irrelevant tips.") elif dpct <= 10: parts.append(f"Dismiss rate is low ({dpct}%).") if volume is not None and int(volume) < 5: parts.append("Very few tips served so far — this is an early-stage user.") if trend == "up": parts.append("Engagement is trending up compared to last week — build on the momentum.") elif trend == "down": parts.append("Engagement is trending down — a motivational or easy-win tip may help.") prompt = " ".join(parts) if parts else "No engagement data available yet." snapshot = { "completion_rate_30d": completion, "dismiss_rate_30d": dismiss, "tip_volume_30d": volume, "engagement_trend": trend, } return self._make_output(inp, prompt, snapshot)