Ctrl K
ComunidadGuías…

rex/followup_engine.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Follow-up engine for Rex AI Assistant.

Integrates cues into conversations to make Rex feel more personal by
following up on calendar events, reminders, and other activities.

The follow-up engine:
- Injects cue prompts into conversations
- Tracks which cues have been asked per session
- Rate-limits cue injection (default 1 per session)
- Optionally generates cues from calendar events

Usage:
    from rex.followup_engine import get_followup_engine

    engine = get_followup_engine()
    engine.start_session(user_id="default")

    prompt = engine.get_followup_prompt(user_id="default")
    if prompt:
        # include prompt naturally early in conversation
        ...

    # after delivering the prompt
    engine.mark_current_cue_asked(user_id="default")
"""

from __future__ import annotations

import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any

logger = logging.getLogger(__name__)


def _utc_now() -> datetime:
    """Return the current UTC datetime."""
    return datetime.now(UTC)


def _ensure_utc(dt: datetime) -> datetime:
    """Ensure a datetime is timezone-aware and normalized to UTC."""
    if dt.tzinfo is None:
        return dt.replace(tzinfo=UTC)
    return dt.astimezone(UTC)


def _clamp(value: int, minimum: int, maximum: int) -> int:
    return max(minimum, min(maximum, value))


def load_config() -> dict[str, Any]:
    """Load Rex configuration.

    This function is a thin wrapper around rex.config_manager.load_config
    that allows tests to monkeypatch it easily.

    Returns:
        The full Rex configuration dict.
    """
    try:
        from rex.config_manager import load_config as _load_config_impl

        return _load_config_impl()
    except Exception:
        return {}


def _get_config() -> dict[str, Any]:
    """Load follow-up configuration with safe defaults."""
    try:
        cfg = load_config()
        followups = cfg.get("conversation", {}).get("followups", {})
        if isinstance(followups, dict):
            return followups
        return {}
    except Exception:
        return {}


@dataclass(frozen=True)
class FollowupConfig:
    """Compatibility config object (can also be constructed from rex_config.json)."""

    enabled: bool = True
    max_per_session: int = 1
    lookback_hours: int = 72
    expire_hours: int = 168


class FollowupEngine:
    """Engine for managing follow-up cue injection into conversations.

    Supports two usage styles:
    1) Modern session-based flow:
        start_session() -> get_followup_prompt() -> mark_current_cue_asked()
    2) Legacy flow:
        collect_followups() / format_followups()

    The engine holds in-memory session state per user_id.
    """

    def __init__(
        self,
        *,
        cue_store: Any = None,
        calendar_service: Any = None,
        config: FollowupConfig | None = None,
        now_fn: Callable[[], datetime] | None = None,
    ) -> None:
        # Lazy imports to avoid import cycles at module import time.
        if cue_store is None:
            from rex.cue_store import get_cue_store

            cue_store = get_cue_store()

        self._cue_store = cue_store
        self._calendar_service = calendar_service
        self._now_fn = now_fn or _utc_now

        # Session state
        self._session_cues_asked: dict[str, int] = {}
        self._current_cue_id: dict[str, str | None] = {}
        self._session_asked_ids: dict[str, set[str]] = {}

        # Optional fixed config override (otherwise config is read dynamically)
        self._fixed_config = config

    @classmethod
    def from_settings(
        cls,
        settings: object,
        *,
        cue_store: Any = None,
        calendar_service: Any = None,
    ) -> FollowupEngine:
        """Compatibility constructor for older settings objects."""
        enabled = bool(getattr(settings, "followups_enabled", True))
        max_per_session = int(getattr(settings, "followups_max_per_session", 1))
        lookback_hours = int(getattr(settings, "followups_lookback_hours", 72))
        expire_hours = int(getattr(settings, "followups_expire_hours", 168))

        cfg = FollowupConfig(
            enabled=enabled,
            max_per_session=max_per_session,
            lookback_hours=lookback_hours,
            expire_hours=expire_hours,
        )
        return cls(cue_store=cue_store, calendar_service=calendar_service, config=cfg)

    def _effective_config(self) -> FollowupConfig:
        if self._fixed_config is not None:
            cfg = self._fixed_config
        else:
            raw = _get_config()
            cfg = FollowupConfig(
                enabled=bool(raw.get("enabled", True)),
                max_per_session=int(raw.get("max_per_session", 1)),
                lookback_hours=int(raw.get("lookback_hours", 72)),
                expire_hours=int(raw.get("expire_hours", 168)),
            )

        return FollowupConfig(
            enabled=bool(cfg.enabled),
            max_per_session=_clamp(int(cfg.max_per_session), 1, 5),
            lookback_hours=_clamp(int(cfg.lookback_hours), 1, 168),
            expire_hours=_clamp(int(cfg.expire_hours), 1, 720),
        )

    def start_session(self, user_id: str) -> None:
        """Start a new conversation session for a user."""
        self._session_cues_asked[user_id] = 0
        self._current_cue_id[user_id] = None
        self._session_asked_ids[user_id] = set()

        cfg = self._effective_config()
        if cfg.enabled:
            self._generate_calendar_cues(user_id=user_id, config=cfg)

    def _get_calendar_service(self) -> Any:
        if self._calendar_service is not None:
            return self._calendar_service
        try:
            from rex.calendar_service import get_calendar_service

            return get_calendar_service()
        except Exception:
            return None

    def _generate_calendar_cues(self, *, user_id: str, config: FollowupConfig) -> None:
        """Generate follow-up cues from recent calendar events.

        Supports:
        - calendar.generate_followup_cues(user_id=..., lookback_hours=..., expire_hours=...)
        - fallback: calendar.get_events(start, end)
        """
        calendar = self._get_calendar_service()
        if calendar is None:
            return

        try:
            if hasattr(calendar, "generate_followup_cues"):
                calendar.generate_followup_cues(
                    user_id=user_id,
                    lookback_hours=config.lookback_hours,
                    expire_hours=config.expire_hours,
                )
                return
        except Exception as exc:
            logger.debug("Could not generate calendar cues via generate_followup_cues: %s", exc)

        # Fallback path: build cues from events list
        try:
            now = self._now_fn()
            start = now - timedelta(hours=config.lookback_hours)
            if not hasattr(calendar, "get_events"):
                return

            try:
                events = calendar.get_events(start, now, user_id=user_id)
            except TypeError:
                # Calendar objects that do not accept a user scope cannot
                # prove ownership of their events: fail closed rather than
                # attributing shared events to this user.
                logger.debug(
                    "Calendar service does not accept user-scoped queries; "
                    "skipping cue generation"
                )
                return
            for event in events:
                end_time = getattr(event, "end_time", None)
                title = getattr(event, "title", None)
                event_id = getattr(event, "event_id", None)

                if (
                    not isinstance(end_time, datetime)
                    or not isinstance(title, str)
                    or not isinstance(event_id, str)
                ):
                    continue

                end_time_utc = _ensure_utc(end_time)
                if end_time_utc > _ensure_utc(now):
                    continue

                prompt = f"How did your {title} go?"
                expires_at = end_time_utc + timedelta(hours=config.expire_hours)

                # CueStore API: add_cue(user_id, source_type, source_id, title, prompt, ...)
                self._cue_store.add_cue(
                    user_id=user_id,
                    source_type="calendar",
                    source_id=event_id,
                    title=title,
                    prompt=prompt,
                    eligible_after=end_time_utc,
                    expires_at=expires_at,
                    metadata={"event_title": title},
                )
        except Exception as exc:
            logger.debug("Could not generate calendar cues via fallback path: %s", exc)

    def get_followup_prompt(
        self,
        user_id: str,
        *,
        now: datetime | None = None,
    ) -> str | None:
        """Get a follow-up prompt for the user if available.

        The cue is NOT marked as asked until mark_current_cue_asked() is called.
        """
        cfg = self._effective_config()
        if not cfg.enabled:
            return None

        asked_count = self._session_cues_asked.get(user_id, 0)
        if asked_count >= cfg.max_per_session:
            return None

        try:
            check_time = _ensure_utc(now or self._now_fn())
            pending = self._cue_store.list_pending_cues(
                user_id=user_id,
                now=check_time,
                limit=1,
                window_hours=cfg.lookback_hours,
            )
            if not pending:
                return None

            cue = pending[0]
            self._current_cue_id[user_id] = cue.cue_id
            return cue.prompt  # type: ignore[no-any-return]
        except Exception as exc:
            logger.warning("Failed to get followup prompt: %s", exc)
            return None

    def get_followup_context(
        self,
        user_id: str,
        *,
        now: datetime | None = None,
    ) -> dict[str, Any] | None:
        """Return follow-up context including prompt and metadata."""
        cfg = self._effective_config()
        if not cfg.enabled:
            return None

        asked_count = self._session_cues_asked.get(user_id, 0)
        if asked_count >= cfg.max_per_session:
            return None

        try:
            check_time = _ensure_utc(now or self._now_fn())
            pending = self._cue_store.list_pending_cues(
                user_id=user_id,
                now=check_time,
                limit=1,
                window_hours=cfg.lookback_hours,
            )
            if not pending:
                return None

            cue = pending[0]
            self._current_cue_id[user_id] = cue.cue_id
            return {
                "prompt": cue.prompt,
                "cue_id": cue.cue_id,
                "title": cue.title,
                "source_type": cue.source_type,
                "source_id": cue.source_id,
                "metadata": cue.metadata,
            }
        except Exception as exc:
            logger.warning("Failed to get followup context: %s", exc)
            return None

    def mark_current_cue_asked(self, user_id: str) -> bool:
        """Mark the current cue as asked for this user session."""
        cue_id = self._current_cue_id.get(user_id)
        if cue_id is None:
            return False

        try:
            if self._cue_store.mark_asked(cue_id):
                self._session_cues_asked[user_id] = self._session_cues_asked.get(user_id, 0) + 1
                self._current_cue_id[user_id] = None
                self._session_asked_ids.setdefault(user_id, set()).add(cue_id)
                return True
            return False
        except Exception as exc:
            logger.warning("Failed to mark cue as asked: %s", exc)
            return False

    def has_pending_cue(self, user_id: str) -> bool:
        """Check if there's at least one pending cue for the user."""
        cfg = self._effective_config()
        if not cfg.enabled:
            return False

        try:
            pending = self._cue_store.list_pending_cues(
                user_id=user_id,
                limit=1,
                window_hours=cfg.lookback_hours,
            )
            return len(pending) > 0
        except Exception:
            return False

    def get_session_cues_asked(self, user_id: str) -> int:
        """Get the number of cues asked in the current session."""
        return self._session_cues_asked.get(user_id, 0)

    def inject_followup_into_prompt(
        self,
        user_id: str,
        base_prompt: str,
        *,
        now: datetime | None = None,
    ) -> str:
        """Inject a follow-up cue into a prompt."""
        followup = self.get_followup_prompt(user_id, now=now)
        if followup is None:
            return base_prompt

        instruction = (
            "\n\nEarly in this conversation, naturally ask the user: "
            f'"{followup}" '
            "Make it feel like friendly small talk, not a checklist."
        )
        return base_prompt + instruction

    # Legacy compatibility API

    def collect_followups(self, user_id: str = "default") -> list[str]:
        """Collect follow-up prompts and mark them asked immediately.

        This matches the older behavior where collecting also consumes cues.
        """
        cfg = self._effective_config()
        if not cfg.enabled:
            return []

        asked_count = self._session_cues_asked.get(user_id, 0)
        if asked_count >= cfg.max_per_session:
            return []

        remaining = cfg.max_per_session - asked_count
        now = _ensure_utc(self._now_fn())

        prompts: list[str] = []
        asked_ids = self._session_asked_ids.setdefault(user_id, set())

        try:
            pending = self._cue_store.list_pending_cues(
                user_id=user_id,
                now=now,
                limit=max(1, remaining),
                window_hours=cfg.lookback_hours,
            )
            for cue in pending:
                if cue.cue_id in asked_ids:
                    continue
                if self._cue_store.mark_asked(cue.cue_id):
                    asked_ids.add(cue.cue_id)
                    self._session_cues_asked[user_id] = self._session_cues_asked.get(user_id, 0) + 1
                    prompts.append(cue.prompt)
                if len(prompts) >= remaining:
                    break
        except Exception as exc:
            logger.warning("Failed to collect followups: %s", exc)

        return prompts

    def format_followups(self, user_id: str = "default") -> str | None:
        """Return formatted follow-up block or None."""
        prompts = self.collect_followups(user_id=user_id)
        if not prompts:
            return None
        lines = ["[Follow-up cues]"]
        lines.extend(f"- {prompt}" for prompt in prompts)
        lines.append("[/Follow-up cues]")
        return "\n".join(lines)


# Global instance
_followup_engine: FollowupEngine | None = None


def get_followup_engine() -> FollowupEngine:
    """Get the global follow-up engine instance."""
    global _followup_engine
    if _followup_engine is None:
        _followup_engine = FollowupEngine()
    return _followup_engine


def set_followup_engine(engine: FollowupEngine | None) -> None:
    """Set the global follow-up engine instance (for testing)."""
    global _followup_engine
    _followup_engine = engine


# ---------------------------------------------------------------------------
# Session-level initialization helper (US-018)
# ---------------------------------------------------------------------------

_logger = logging.getLogger(__name__)


def _followups_enabled(settings: object) -> bool:
    """Return ``True`` when follow-ups are enabled in *settings*."""
    legacy = getattr(settings, "followups_enabled", None)
    if isinstance(legacy, bool):
        return legacy
    conv = getattr(settings, "conversation", None)
    if isinstance(conv, dict):
        followups = conv.get("followups")
        if isinstance(followups, dict):
            enabled = followups.get("enabled")
            if isinstance(enabled, bool):
                return enabled
    fu = getattr(settings, "followups", None)
    if isinstance(fu, dict):
        enabled = fu.get("enabled")
        if isinstance(enabled, bool):
            return enabled
    return False


def init_followup_engine(settings: object, user_id: str) -> tuple[object | None, str | None]:
    """Initialize the follow-up engine for a session.

    Returns ``(engine, pending_followup)`` where *engine* is the
    :class:`FollowupEngine` instance (or ``None`` when disabled/unavailable)
    and *pending_followup* is a pre-fetched cue string or ``None``.

    Extracted from ``rex.assistant.Assistant._init_followup_engine`` (US-018).
    """
    if not _followups_enabled(settings):
        return None, None

    # Preferred path: singleton/getter engine API
    try:
        engine = get_followup_engine()
        if hasattr(engine, "start_session"):
            engine.start_session(user_id)
        pending: str | None = None
        if hasattr(engine, "get_followup_prompt"):
            pending = engine.get_followup_prompt(user_id)
        if pending:
            _logger.debug("Pending followup for session: %s", pending)
        return engine, pending
    except Exception as exc:
        _logger.debug("Follow-up engine getter not available: %s", exc)

    # Fallback path: construct engine directly from settings (older API)
    try:
        from rex.assistant_errors import IntegrationNotConfiguredError
        from rex.calendar_service import get_calendar_service

        try:
            calendar_service = get_calendar_service()
        except IntegrationNotConfiguredError:
            _logger.info("Calendar: not configured")
            calendar_service = None
        except Exception as exc:
            _logger.warning("Failed to initialize calendar service: %s", exc)
            calendar_service = None

        engine = FollowupEngine.from_settings(settings, calendar_service=calendar_service)
        if hasattr(engine, "start_session"):
            engine.start_session(user_id)
        pending = None
        if hasattr(engine, "get_followup_prompt"):
            pending = engine.get_followup_prompt(user_id)
        if pending:
            _logger.debug("Pending followup for session: %s", pending)
        return engine, pending
    except Exception as exc:
        _logger.debug("Follow-up engine not available: %s", exc)
        return None, None


__all__ = [
    "FollowupConfig",
    "FollowupEngine",
    "get_followup_engine",
    "init_followup_engine",
    "set_followup_engine",
    "load_config",
]