Files
miku-discord/bot/utils/autonomous_engine.py

577 lines
26 KiB
Python
Raw Normal View History

2025-12-07 17:15:09 +02:00
# autonomous_engine.py
"""
Truly autonomous decision-making engine for Miku.
Makes decisions based on context signals without constant LLM polling.
"""
import math
2025-12-07 17:15:09 +02:00
import time
import random
from datetime import datetime, timedelta
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from collections import deque
import discord
from .autonomous_persistence import save_autonomous_context, load_autonomous_context, apply_context_to_signals
from utils.logger import get_logger
logger = get_logger('autonomous')
2025-12-07 17:15:09 +02:00
@dataclass
class ContextSignals:
"""Lightweight context tracking without storing message content"""
# Activity metrics
messages_last_5min: int = 0
messages_last_hour: int = 0
unique_users_active: int = 0
conversation_momentum: float = 0.0 # 0-1 score based on message frequency
# User presence
users_joined_recently: int = 0
users_status_changed: int = 0
users_started_activity: List[tuple] = field(default_factory=list) # (activity_name, timestamp) tuples
# Miku's state
time_since_last_action: float = 0.0 # seconds
time_since_last_interaction: float = 0.0 # seconds since someone talked to her
messages_since_last_appearance: int = 0
# Time context
hour_of_day: int = 0
is_weekend: bool = False
# Emotional influence
current_mood: str = "neutral"
mood_energy_level: float = 0.5 # 0-1, affects likelihood of action
@dataclass
class ActionThresholds:
"""Dynamic thresholds that change based on mood and context"""
# How long to wait before considering action (seconds)
min_silence_for_general: float = 1800 # 30 min
min_silence_for_engagement: float = 3600 # 1 hour
# Activity level needed to join conversation (0-1)
conversation_join_threshold: float = 0.6
# How many messages before feeling "left out"
messages_before_fomo: int = 25
# Mood-based multipliers
mood_action_multiplier: float = 1.0 # Higher = more likely to act
class AutonomousEngine:
"""
Decision engine that determines WHEN Miku should act,
then delegates to existing autonomous functions for WHAT to do.
"""
def __init__(self):
self.server_contexts: Dict[int, ContextSignals] = {}
self.server_message_times: Dict[int, deque] = {} # Track message timestamps
self.server_last_action: Dict[int, float] = {}
self.bot_startup_time: float = time.time() # Track when bot started
# Mood personality profiles
self.mood_profiles = {
"bubbly": {"energy": 0.9, "sociability": 0.95, "impulsiveness": 0.8},
"sleepy": {"energy": 0.2, "sociability": 0.3, "impulsiveness": 0.1},
"curious": {"energy": 0.7, "sociability": 0.6, "impulsiveness": 0.7},
"shy": {"energy": 0.4, "sociability": 0.2, "impulsiveness": 0.2},
"serious": {"energy": 0.6, "sociability": 0.5, "impulsiveness": 0.3},
"excited": {"energy": 0.95, "sociability": 0.9, "impulsiveness": 0.9},
"silly": {"energy": 0.8, "sociability": 0.85, "impulsiveness": 0.95},
"melancholy": {"energy": 0.3, "sociability": 0.4, "impulsiveness": 0.2},
"flirty": {"energy": 0.75, "sociability": 0.85, "impulsiveness": 0.7},
"romantic": {"energy": 0.6, "sociability": 0.7, "impulsiveness": 0.5},
"irritated": {"energy": 0.5, "sociability": 0.3, "impulsiveness": 0.6},
"angry": {"energy": 0.7, "sociability": 0.2, "impulsiveness": 0.8},
"neutral": {"energy": 0.5, "sociability": 0.5, "impulsiveness": 0.5},
"asleep": {"energy": 0.0, "sociability": 0.0, "impulsiveness": 0.0},
}
# Load persisted context on initialization
self._load_persisted_context()
def _load_persisted_context(self):
"""Load saved context data on bot startup"""
context_data, last_action = load_autonomous_context()
# Restore last action timestamps
self.server_last_action = last_action
# Restore context signals
for guild_id, data in context_data.items():
self.server_contexts[guild_id] = ContextSignals()
self.server_message_times[guild_id] = deque(maxlen=100)
apply_context_to_signals(data, self.server_contexts[guild_id])
def save_context(self):
"""Save current context to disk"""
save_autonomous_context(self.server_contexts, self.server_last_action)
def track_message(self, guild_id: int, author_is_bot: bool = False):
"""Track a message without storing content"""
if guild_id not in self.server_contexts:
self.server_contexts[guild_id] = ContextSignals()
self.server_message_times[guild_id] = deque(maxlen=100)
if author_is_bot:
return # Don't count bot messages
now = time.time()
self.server_message_times[guild_id].append(now)
ctx = self.server_contexts[guild_id]
ctx.messages_since_last_appearance += 1
# Cap at 100 to prevent massive buildup during sleep/inactivity
# This prevents inappropriate FOMO triggers after long periods
if ctx.messages_since_last_appearance > 100:
ctx.messages_since_last_appearance = 100
# Update time-based metrics
self._update_activity_metrics(guild_id)
def track_user_event(self, guild_id: int, event_type: str, data: dict = None):
"""Track user presence events (joins, status changes, etc.)"""
if guild_id not in self.server_contexts:
self.server_contexts[guild_id] = ContextSignals()
self.server_message_times[guild_id] = deque(maxlen=100)
ctx = self.server_contexts[guild_id]
if event_type == "user_joined":
ctx.users_joined_recently += 1
elif event_type == "status_changed":
ctx.users_status_changed += 1
elif event_type == "activity_started" and data:
activity_name = data.get("activity_name")
if activity_name:
now = time.time()
# Remove duplicate activities (same name)
ctx.users_started_activity = [
(name, ts) for name, ts in ctx.users_started_activity
if name != activity_name
]
# Add new activity with timestamp
ctx.users_started_activity.append((activity_name, now))
# Keep only last 5 activities
if len(ctx.users_started_activity) > 5:
ctx.users_started_activity.pop(0)
def _clean_old_activities(self, guild_id: int, max_age_seconds: float = 3600):
"""Remove activities older than max_age (default 1 hour)"""
if guild_id not in self.server_contexts:
return
ctx = self.server_contexts[guild_id]
now = time.time()
# Filter out old activities
ctx.users_started_activity = [
(name, ts) for name, ts in ctx.users_started_activity
if now - ts < max_age_seconds
]
def update_mood(self, guild_id: int, mood: str):
"""Update mood and recalculate energy level"""
if guild_id not in self.server_contexts:
self.server_contexts[guild_id] = ContextSignals()
self.server_message_times[guild_id] = deque(maxlen=100)
ctx = self.server_contexts[guild_id]
ctx.current_mood = mood
# Get mood personality profile
profile = self.mood_profiles.get(mood, self.mood_profiles["neutral"])
ctx.mood_energy_level = profile["energy"]
def _update_activity_metrics(self, guild_id: int):
"""Update activity metrics based on message timestamps"""
ctx = self.server_contexts[guild_id]
times = self.server_message_times[guild_id]
now = time.time()
# Count messages in time windows
ctx.messages_last_5min = sum(1 for t in times if now - t < 300)
ctx.messages_last_hour = sum(1 for t in times if now - t < 3600)
# Calculate conversation momentum (0-1 scale)
# Smooth curve: grows quickly at first, then tapers off toward 1.0
# 1 msg → 0.10, 5 msgs → 0.41, 10 msgs → 0.63, 20 msgs → 0.82, 40 msgs → 0.95
if ctx.messages_last_5min == 0:
ctx.conversation_momentum = 0.0
2025-12-07 17:15:09 +02:00
else:
ctx.conversation_momentum = min(1.0, math.log1p(ctx.messages_last_5min) / math.log1p(30))
2025-12-07 17:15:09 +02:00
# Time since last action
if guild_id in self.server_last_action:
ctx.time_since_last_action = now - self.server_last_action[guild_id]
else:
ctx.time_since_last_action = float('inf')
# Time context
ctx.hour_of_day = datetime.now().hour
ctx.is_weekend = datetime.now().weekday() >= 5
def should_take_action(self, guild_id: int, debug: bool = False, triggered_by_message: bool = False) -> Optional[str]:
"""
Determine if Miku should take action and what type.
Returns action type or None.
This is the CORE decision logic - no LLM needed!
Args:
guild_id: Server ID
debug: If True, print detailed decision reasoning
triggered_by_message: If True, this check was triggered immediately after someone sent a message
"""
if guild_id not in self.server_contexts:
return None
ctx = self.server_contexts[guild_id]
# STARTUP COOLDOWN: Don't act for first 2 minutes after bot startup
# This prevents rapid-fire messages when bot restarts
time_since_startup = time.time() - self.bot_startup_time
if time_since_startup < 120: # 2 minutes
if debug:
logger.debug(f"[V2 Debug] Startup cooldown active ({time_since_startup:.0f}s / 120s)")
2025-12-07 17:15:09 +02:00
return None
# Never act when asleep
if ctx.current_mood == "asleep":
if debug:
logger.debug(f"[V2 Debug] Mood is 'asleep' - no action taken")
2025-12-07 17:15:09 +02:00
return None
# Get mood personality
profile = self.mood_profiles.get(ctx.current_mood, self.mood_profiles["neutral"])
# Update metrics
self._update_activity_metrics(guild_id)
if debug:
logger.debug(f"\n[V2 Debug] Decision Check for Guild {guild_id}")
logger.debug(f" Triggered by message: {triggered_by_message}")
logger.debug(f" Mood: {ctx.current_mood} (energy={profile['energy']:.2f}, sociability={profile['sociability']:.2f}, impulsiveness={profile['impulsiveness']:.2f})")
logger.debug(f" Momentum: {ctx.conversation_momentum:.2f}")
logger.debug(f" Messages (5min/1hr): {ctx.messages_last_5min}/{ctx.messages_last_hour}")
logger.debug(f" Messages since appearance: {ctx.messages_since_last_appearance}")
logger.debug(f" Time since last action: {ctx.time_since_last_action:.0f}s")
logger.debug(f" Active activities: {len(ctx.users_started_activity)}")
2025-12-07 17:15:09 +02:00
# --- Decision Logic ---
# CRITICAL: If triggered by a message, we should ONLY do join_conversation
# This ensures Miku responds to what's being said, not random autonomous actions
# Exception: Reactions are handled separately and are allowed
2025-12-07 17:15:09 +02:00
# 1. CONVERSATION JOIN (high priority when momentum is high)
if self._should_join_conversation(ctx, profile, debug):
if debug:
logger.debug(f"[V2 Debug] DECISION: join_conversation")
2025-12-07 17:15:09 +02:00
return "join_conversation"
# 2. USER ENGAGEMENT (someone interesting appeared)
if self._should_engage_user(ctx, profile, debug):
if triggered_by_message:
# Convert to join_conversation when message-triggered
if debug:
logger.debug(f"[V2 Debug] DECISION: join_conversation (engage_user converted due to message trigger)")
return "join_conversation"
2025-12-07 17:15:09 +02:00
if debug:
logger.debug(f"[V2 Debug] DECISION: engage_user")
2025-12-07 17:15:09 +02:00
return "engage_user"
# 3. FOMO RESPONSE (lots of activity without her)
# When FOMO triggers, join the conversation instead of saying something random
if self._should_respond_to_fomo(ctx, profile, debug):
if debug:
logger.debug(f"[V2 Debug] DECISION: join_conversation (FOMO)")
2025-12-07 17:15:09 +02:00
return "join_conversation" # Jump in and respond to what's being said
# 4. BORED/LONELY (quiet for too long, depending on mood)
# CRITICAL FIX: If this check was triggered by a message, convert "general" to "join_conversation"
# This ensures Miku responds to the message instead of saying something random
if self._should_break_silence(ctx, profile, debug):
if triggered_by_message:
if debug:
logger.debug(f"[V2 Debug] DECISION: join_conversation (break silence, but message just sent)")
2025-12-07 17:15:09 +02:00
return "join_conversation" # Respond to the message instead of random general statement
else:
if debug:
logger.debug(f"[V2 Debug] DECISION: general (break silence)")
2025-12-07 17:15:09 +02:00
return "general"
# 5. SHARE TWEET (low activity, wants to share something)
# Skip this entirely when triggered by message - would be inappropriate to ignore user's message
if not triggered_by_message and self._should_share_content(ctx, profile, debug):
2025-12-07 17:15:09 +02:00
if debug:
logger.debug(f"[V2 Debug] DECISION: share_tweet")
2025-12-07 17:15:09 +02:00
return "share_tweet"
# 6. CHANGE PROFILE PICTURE (very rare, once per day)
# Skip this entirely when triggered by message
if not triggered_by_message and self._should_change_profile_picture(ctx, profile, debug):
2025-12-07 17:15:09 +02:00
if debug:
logger.debug(f"[V2 Debug] DECISION: change_profile_picture")
2025-12-07 17:15:09 +02:00
return "change_profile_picture"
if debug:
logger.debug(f"[V2 Debug] DECISION: None (no conditions met)")
2025-12-07 17:15:09 +02:00
return None
def _should_join_conversation(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""Decide if Miku should join an active conversation"""
# High conversation momentum + sociable mood + hasn't spoken recently
base_threshold = 0.6
mood_adjusted = base_threshold * (2.0 - profile["sociability"]) # Lower threshold if sociable
conditions = {
"momentum_check": ctx.conversation_momentum > mood_adjusted,
"messages_check": ctx.messages_since_last_appearance >= 5,
"cooldown_check": ctx.time_since_last_action > 300,
"impulsiveness_roll": random.random() < profile["impulsiveness"]
}
result = all(conditions.values())
if debug:
logger.debug(f" [Join Conv] momentum={ctx.conversation_momentum:.2f} > {mood_adjusted:.2f}? {conditions['momentum_check']}")
logger.debug(f" [Join Conv] messages={ctx.messages_since_last_appearance} >= 5? {conditions['messages_check']}")
logger.debug(f" [Join Conv] cooldown={ctx.time_since_last_action:.0f}s > 300s? {conditions['cooldown_check']}")
logger.debug(f" [Join Conv] impulsive roll? {conditions['impulsiveness_roll']} | Result: {result}")
2025-12-07 17:15:09 +02:00
return result
def _should_engage_user(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""Decide if Miku should engage with a user (status change/activity)"""
# Someone started a new activity or status changed + enough time passed
has_activities = len(ctx.users_started_activity) > 0
cooldown_ok = ctx.time_since_last_action > 1800
roll = random.random()
threshold = profile["sociability"] * profile["impulsiveness"]
roll_ok = roll < threshold
result = has_activities and cooldown_ok and roll_ok
if debug and has_activities:
activities = [name for name, ts in ctx.users_started_activity]
logger.debug(f" [Engage] activities={activities}, cooldown={ctx.time_since_last_action:.0f}s > 1800s? {cooldown_ok}")
logger.debug(f" [Engage] roll={roll:.2f} < {threshold:.2f}? {roll_ok} | Result: {result}")
2025-12-07 17:15:09 +02:00
return result
def _should_respond_to_fomo(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""Decide if Miku feels left out (FOMO)"""
# Lots of messages but she hasn't participated
fomo_threshold = 25 * (2.0 - profile["sociability"]) # Social moods have lower threshold
msgs_check = ctx.messages_since_last_appearance > fomo_threshold
momentum_check = ctx.conversation_momentum > 0.3
cooldown_check = ctx.time_since_last_action > 900
result = msgs_check and momentum_check and cooldown_check
if debug:
logger.debug(f" [FOMO] messages={ctx.messages_since_last_appearance} > {fomo_threshold:.0f}? {msgs_check}")
logger.debug(f" [FOMO] momentum={ctx.conversation_momentum:.2f} > 0.3? {momentum_check}")
logger.debug(f" [FOMO] cooldown={ctx.time_since_last_action:.0f}s > 900s? {cooldown_check} | Result: {result}")
2025-12-07 17:15:09 +02:00
return result
def _should_break_silence(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""Decide if Miku should break a long silence"""
# Low activity + long time + mood-dependent
min_silence = 1800 * (2.0 - profile["energy"]) # High energy = shorter wait
quiet_check = ctx.messages_last_hour < 5
silence_check = ctx.time_since_last_action > min_silence
energy_roll = random.random()
energy_ok = energy_roll < profile["energy"]
result = quiet_check and silence_check and energy_ok
if debug:
logger.debug(f" [Silence] msgs_last_hour={ctx.messages_last_hour} < 5? {quiet_check}")
logger.debug(f" [Silence] time={ctx.time_since_last_action:.0f}s > {min_silence:.0f}s? {silence_check}")
logger.debug(f" [Silence] energy roll={energy_roll:.2f} < {profile['energy']:.2f}? {energy_ok} | Result: {result}")
2025-12-07 17:15:09 +02:00
return result
def _should_share_content(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""Decide if Miku should share a tweet/content"""
# RELAXED CONDITIONS: Made tweet sharing more frequent
# Old: quiet_check required < 10 messages, now < 20
# Old: cooldown was 3600s (1 hour), now 2400s (40 minutes)
# Old: energy threshold was 50%, now 70%
quiet_check = ctx.messages_last_hour < 20 # Increased from 10
cooldown_check = ctx.time_since_last_action > 2400 # Reduced from 3600
2025-12-07 17:15:09 +02:00
energy_roll = random.random()
energy_threshold = profile["energy"] * 0.7 # Increased from 0.5
2025-12-07 17:15:09 +02:00
energy_ok = energy_roll < energy_threshold
# Added more moods that can share content
mood_ok = ctx.current_mood in ["curious", "excited", "bubbly", "neutral", "silly", "flirty"]
2025-12-07 17:15:09 +02:00
result = quiet_check and cooldown_check and energy_ok and mood_ok
if debug:
logger.debug(f" [Share] msgs_last_hour={ctx.messages_last_hour} < 20? {quiet_check}")
logger.debug(f" [Share] cooldown={ctx.time_since_last_action:.0f}s > 2400s? {cooldown_check}")
logger.debug(f" [Share] energy roll={energy_roll:.2f} < {energy_threshold:.2f}? {energy_ok}")
logger.debug(f" [Share] mood '{ctx.current_mood}' appropriate? {mood_ok} | Result: {result}")
2025-12-07 17:15:09 +02:00
return result
def _should_change_profile_picture(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""
Decide if Miku should change her profile picture.
This is a rare, once-per-day action.
"""
# Check if we've changed recently (track globally, not per-server)
from datetime import datetime, timedelta
import os
import json
metadata_path = "memory/profile_pictures/metadata.json"
# Load last change time
try:
if os.path.exists(metadata_path):
with open(metadata_path, 'r') as f:
metadata = json.load(f)
last_change = metadata.get("changed_at")
if last_change:
last_change_dt = datetime.fromisoformat(last_change)
hours_since_change = (datetime.now() - last_change_dt).total_seconds() / 3600
if hours_since_change < 20: # At least 20 hours between changes
if debug:
logger.debug(f" [PFP] Last change {hours_since_change:.1f}h ago, waiting...")
2025-12-07 17:15:09 +02:00
return False
except Exception as e:
if debug:
logger.debug(f" [PFP] Error checking last change: {e}")
2025-12-07 17:15:09 +02:00
# Only consider changing during certain hours (10 AM - 10 PM)
hour = ctx.hour_of_day
time_check = 10 <= hour <= 22
# Require low activity + long cooldown
quiet_check = ctx.messages_last_hour < 5
cooldown_check = ctx.time_since_last_action > 5400 # 1.5 hours
# Mood influences decision (more likely when bubbly, curious, excited)
mood_boost = ctx.current_mood in ["bubbly", "curious", "excited", "silly"]
# Very low base chance (roughly once per day)
base_chance = 0.02 if mood_boost else 0.01
roll = random.random()
roll_ok = roll < base_chance
result = time_check and quiet_check and cooldown_check and roll_ok
if debug:
logger.debug(f" [PFP] hour={hour}, time_ok={time_check}")
logger.debug(f" [PFP] msgs_last_hour={ctx.messages_last_hour} < 5? {quiet_check}")
logger.debug(f" [PFP] cooldown={ctx.time_since_last_action:.0f}s > 5400s? {cooldown_check}")
logger.debug(f" [PFP] mood_boost={mood_boost}, roll={roll:.4f} < {base_chance:.4f}? {roll_ok}")
logger.debug(f" [PFP] Result: {result}")
2025-12-07 17:15:09 +02:00
return result
def should_react_to_message(self, guild_id: int, message_age_seconds: float = 0) -> bool:
"""
Decide if Miku should react to a message with an emoji.
Called when new messages arrive OR by periodic scheduler.
Args:
guild_id: Server ID
message_age_seconds: How old the message is (0 = brand new)
Returns:
True if should react, False otherwise
"""
if guild_id not in self.server_contexts:
return False
ctx = self.server_contexts[guild_id]
# Never react when asleep
if ctx.current_mood == "asleep":
return False
profile = self.mood_profiles.get(ctx.current_mood, self.mood_profiles["neutral"])
# Brand new message (real-time reaction)
if message_age_seconds < 10:
# Base 30% chance, modified by mood
base_chance = 0.30
mood_multiplier = (profile["impulsiveness"] + profile["sociability"]) / 2
reaction_chance = base_chance * mood_multiplier
# More likely to react to messages in active conversations
if ctx.conversation_momentum > 0.5:
reaction_chance *= 1.5 # Boost in active chats
# Less likely if just reacted recently
if ctx.time_since_last_action < 300: # 5 minutes
reaction_chance *= 0.3 # Reduce significantly
return random.random() < reaction_chance
# Older message (scheduled reaction check)
else:
# Base 20% chance for scheduled reactions
base_chance = 0.20
mood_multiplier = (profile["impulsiveness"] + profile["energy"]) / 2
reaction_chance = base_chance * mood_multiplier
# Don't react to very old messages if chat is active
if message_age_seconds > 1800 and ctx.messages_last_5min > 5: # 30 min old + active chat
return False
return random.random() < reaction_chance
def record_action(self, guild_id: int):
"""Record that Miku took an action"""
self.server_last_action[guild_id] = time.time()
if guild_id in self.server_contexts:
self.server_contexts[guild_id].messages_since_last_appearance = 0
# Clear some event counters
self.server_contexts[guild_id].users_joined_recently = 0
self.server_contexts[guild_id].users_status_changed = 0
def decay_events(self, guild_id: int):
"""
Decay event counters over time (call periodically every 15 minutes).
Uses proper exponential decay with 1-hour half-life.
Also cleans up old activities.
"""
if guild_id not in self.server_contexts:
return
ctx = self.server_contexts[guild_id]
# Decay user events (half-life of 1 hour)
# For 15-minute intervals: decay_factor = 0.5^(1/4) ≈ 0.841
decay_factor = 0.5 ** (1/4) # ≈ 0.8408964...
ctx.users_joined_recently = round(ctx.users_joined_recently * decay_factor)
ctx.users_status_changed = round(ctx.users_status_changed * decay_factor)
2025-12-07 17:15:09 +02:00
# Clean up old activities (older than 1 hour)
self._clean_old_activities(guild_id, max_age_seconds=3600)
# Global instance
autonomous_engine = AutonomousEngine()