prova-core / brain /services /proactive.py
chintakapp's picture
core 2.0
210ef30
Raw History Blame Contribute Delete
6.29 kB
"""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)