"""Deciding whether Prova messages first. Port of ``work/ProactiveWorker.kt``; blueprint 10.3. The decision is the interesting part, not the sending. A companion that pings at 3 am, or three times mid-conversation, or keeps talking into a silence it never got an answer to, stops feeling like a person very quickly. Every gate below exists to prevent one of those. Each run returns exactly one ``Decision`` naming the gate that stopped it and the numbers the decision was made on — Chintak's lesson: check-ins were arriving at 5 am and nothing said why. Gates, in order (Chintak's actual order, which the blueprint's list compresses): 1. ``notifications_off`` — proactive is switched off for every mode 2. ``quiet_hours`` — weekday 00:00–07:00, weekend 01:00–07:00 by default 3. ``daily_cap`` — 3 a day by default, 1–5 4. ``no_history`` — never open with "you've been quiet" to someone who never spoke 5. ``too_recent`` — silence shorter than the bar. The bar is 45 min inside a routine window (lunch, leaving the office), 60 min at the weekend, and the configured gap (5 h) otherwise 6. ``unanswered_backoff`` — the newest message is a check-in he never answered 7. ``failure_backoff`` — a generation failed less than 6 h ago """ import logging from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any from ..persona.routine import WeeklyRoutine, active_window, clock, is_weekend, minute_of_day from ..persona.types import Mode log = logging.getLogger(__name__) DEFAULTS = { "gfEnabled": True, "normalEnabled": False, "minGapMin": 5 * 60, "maxPerDay": 3, "quietStart": 0, "quietEnd": 7 * 60, "weekendQuietStart": 60, "weekendQuietEnd": 7 * 60, } WEEKEND_MIN_GAP_MINUTES = 60 ROUTINE_MIN_SILENCE_MINUTES = 45 BACKOFF = timedelta(hours=6) MIN_GAP_FLOOR_MINUTES = 5 MAX_PER_DAY_RANGE = (1, 5) @dataclass(frozen=True) class Decision: send: bool gate: str detail: str mode: Mode = Mode.GF #: "2026-09-19:lunch" when a routine window opened the gate; recorded only on success. stamp: str | None = None def proactive_settings(settings: dict[str, Any]) -> dict[str, Any]: merged = {**DEFAULTS, **(settings.get("proactive") or {})} low, high = MAX_PER_DAY_RANGE merged["maxPerDay"] = max(low, min(high, int(merged["maxPerDay"]))) merged["minGapMin"] = max(MIN_GAP_FLOOR_MINUTES, int(merged["minGapMin"])) return merged def in_quiet_hours(minute: int, start: int, end: int) -> bool: """Handles windows that wrap midnight (23:00–07:00) as well as ones that do not.""" if start == end: return False if start < end: return start <= minute < end return minute >= start or minute < end def silence_minutes(last_at: datetime, now: datetime) -> int: return max(0, int((now - last_at).total_seconds() // 60)) async def decide( store: Any, user_id: str, settings: dict[str, Any], now: datetime, *, test_mode: bool = False ) -> Decision: """Every gate, in order. ``test_mode`` is PROACTIVE_TEST_MODE: it ignores them all.""" config = proactive_settings(settings) mode = Mode.GF if config["gfEnabled"] else Mode.NORMAL if test_mode: return Decision(True, "test_mode", "PROACTIVE_TEST_MODE ignores every gate", mode) if not config["gfEnabled"] and not config["normalEnabled"]: return Decision(False, "notifications_off", "proactive is off in both modes", mode) weekend = is_weekend(now) minute = minute_of_day(now) start = config["weekendQuietStart"] if weekend else config["quietStart"] end = config["weekendQuietEnd"] if weekend else config["quietEnd"] if in_quiet_hours(minute, start, end): label = "weekend" if weekend else "weekday" return Decision( False, "quiet_hours", f"{clock(minute)} is inside the {label} window {clock(start)}-{clock(end)}", mode, ) midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) sent_today = await store.proactive_count(user_id, midnight) if sent_today >= config["maxPerDay"]: return Decision( False, "daily_cap", f"{sent_today} of {config['maxPerDay']} already sent today", mode ) last = await store.last_message(user_id) if last is None: return Decision(False, "no_history", "no conversation to continue", mode) last_at = last["created_at"] if last_at.tzinfo is None: last_at = last_at.replace(tzinfo=now.tzinfo) silent = silence_minutes(last_at, now) routine = WeeklyRoutine.from_settings((settings.get("routine") or {}).get("week")) off_today = settings.get("offTodayDate") == now.date().isoformat() window = active_window(routine, now, off_today) stamp = f"{now.date().isoformat()}:{window.id}" if window else None if stamp and await store.has_proactive_stamp(user_id, stamp): window, stamp = None, None if window is not None: required, reason = ROUTINE_MIN_SILENCE_MINUTES, f"routine window '{window.id}'" elif weekend: required, reason = WEEKEND_MIN_GAP_MINUTES, "weekend gap" else: required, reason = config["minGapMin"], "configured weekday gap" if silent < required: return Decision( False, "too_recent", f"silent for {silent} min, {reason} needs {required} min", mode ) # A check-in he never answered: do not stack another on top of it. if last["role"] == "assistant" and last.get("is_proactive"): if now - last_at < BACKOFF: return Decision( False, "unanswered_backoff", f"last check-in {silent} min ago is still unanswered", mode, ) failed_at = await store.last_proactive_failure(user_id) if failed_at is not None: if failed_at.tzinfo is None: failed_at = failed_at.replace(tzinfo=now.tzinfo) if now - failed_at < BACKOFF: return Decision(False, "failure_backoff", "a check-in failed less than 6 h ago", mode) return Decision(True, "sent", f"{reason}, silent for {silent} min", mode, stamp)