Files
miku-discord/bot/utils/autonomous_persistence.py
koko210Serve 0e4aebf353 fix(P1): 6 priority-1 bug fixes for autonomous engine and mood system
#4  Sleep/mood desync — set_server_mood() now clears is_sleeping when
    mood changes away from 'asleep', preventing ghost-sleep state.

#5  Race condition in _check_and_act — added per-guild asyncio.Lock so
    overlapping ticks + message-triggered calls cannot fire concurrently.

#6  Class-level attrs on ServerConfig — sleepy_responses_left,
    angry_wakeup_timer, and forced_angry_until are now proper dataclass
    fields with defaults, so asdict()/from_dict() round-trip correctly.
    Also strips unknown keys in from_dict() to survive schema changes.

#7  Persistence decay_factor crash — initialise decay_factor = 1.0
    before the loop so empty-server or zero-downtime paths don't
    raise NameError.

#8  Double record_action — removed the redundant call in
    autonomous_tick_v2(); only _check_and_act records the action now.

#9  Engine mood desync — on_mood_change() is now called inside
    set_server_mood() (single source of truth) and removed from 4
    call-sites in api.py, moods.py, and server_manager wakeup task.
2026-02-23 13:31:15 +02:00

131 lines
4.9 KiB
Python

"""
Persistence layer for V2 autonomous system.
Saves and restores critical context data across bot restarts.
"""
import json
import time
from pathlib import Path
from typing import Dict, Optional
from datetime import datetime, timezone
from utils.logger import get_logger
logger = get_logger('autonomous')
CONTEXT_FILE = Path("memory/autonomous_context.json")
def save_autonomous_context(server_contexts: dict, server_last_action: dict):
"""
Save critical context data to disk.
Only saves data that makes sense to persist (not ephemeral stats).
"""
now = time.time()
data = {
"saved_at": now,
"saved_at_readable": datetime.now(timezone.utc).isoformat(),
"servers": {}
}
for guild_id, ctx in server_contexts.items():
data["servers"][str(guild_id)] = {
# Critical timing data
"time_since_last_action": ctx.time_since_last_action,
"time_since_last_interaction": ctx.time_since_last_interaction,
"messages_since_last_appearance": ctx.messages_since_last_appearance,
# Decay-able activity data (will be aged on restore)
"conversation_momentum": ctx.conversation_momentum,
"unique_users_active": ctx.unique_users_active,
# Last action timestamp (absolute time)
"last_action_timestamp": server_last_action.get(guild_id, 0),
# Mood state (already persisted in servers_config.json, but include for completeness)
"current_mood": ctx.current_mood,
"mood_energy_level": ctx.mood_energy_level
}
try:
CONTEXT_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CONTEXT_FILE, 'w') as f:
json.dump(data, f, indent=2)
logger.info(f"[V2] Saved autonomous context for {len(server_contexts)} servers")
except Exception as e:
logger.error(f"[V2] Failed to save autonomous context: {e}")
def load_autonomous_context() -> tuple[Dict[int, dict], Dict[int, float]]:
"""
Load and restore context data from disk.
Returns (server_context_data, server_last_action).
Applies staleness/decay rules based on downtime:
- conversation_momentum decays over time
- Timestamps are adjusted for elapsed time
"""
if not CONTEXT_FILE.exists():
logger.info("[V2] No saved context found, starting fresh")
return {}, {}
try:
with open(CONTEXT_FILE, 'r') as f:
data = json.load(f)
saved_at = data.get("saved_at", 0)
downtime = time.time() - saved_at
downtime_minutes = downtime / 60
logger.info(f"[V2] Loading context from {downtime_minutes:.1f} minutes ago")
context_data = {}
last_action = {}
decay_factor = 1.0 # Default: no decay (in case loop doesn't execute)
for guild_id_str, server_data in data.get("servers", {}).items():
guild_id = int(guild_id_str)
# Apply decay/staleness rules
momentum = server_data.get("conversation_momentum", 0.0)
# Momentum decays: half-life of 10 minutes
if downtime > 0:
decay_factor = 0.5 ** (downtime_minutes / 10)
momentum = momentum * decay_factor
# Restore data with adjustments
context_data[guild_id] = {
"time_since_last_action": server_data.get("time_since_last_action", 0) + downtime,
"time_since_last_interaction": server_data.get("time_since_last_interaction", 0) + downtime,
"messages_since_last_appearance": server_data.get("messages_since_last_appearance", 0),
"conversation_momentum": momentum,
"unique_users_active": 0, # Reset (stale data)
"current_mood": server_data.get("current_mood", "neutral"),
"mood_energy_level": server_data.get("mood_energy_level", 0.5)
}
# Restore last action timestamp
last_action_timestamp = server_data.get("last_action_timestamp", 0)
if last_action_timestamp > 0:
last_action[guild_id] = last_action_timestamp
logger.info(f"[V2] Restored context for {len(context_data)} servers")
logger.debug(f" └─ Momentum decay factor: {decay_factor:.3f} (from {downtime_minutes:.1f}min downtime)")
return context_data, last_action
except Exception as e:
logger.error(f"[V2] Failed to load autonomous context: {e}")
return {}, {}
def apply_context_to_signals(context_data: dict, context_signals):
"""
Apply loaded context data to a ContextSignals object.
Call this after creating a fresh ContextSignals instance.
"""
for key, value in context_data.items():
if hasattr(context_signals, key):
setattr(context_signals, key, value)