Initial implementation of autonomous Discord LLM fishbowl
Core Features: - Full autonomous AI character ecosystem with multi-personality support - Advanced RAG system with personal, community, and creative memory layers - MCP integration for character self-modification and file system access - PostgreSQL database with comprehensive character relationship tracking - Redis caching and ChromaDB vector storage for semantic memory retrieval - Dynamic personality evolution based on interactions and self-reflection - Community knowledge management with tradition and norm identification - Sophisticated conversation engine with natural scheduling and topic management - Docker containerization and production-ready deployment configuration Architecture: - Multi-layer vector databases for personal, community, and creative knowledge - Character file systems with personal and shared digital spaces - Autonomous self-modification with safety validation and audit trails - Memory importance scoring with time-based decay and consolidation - Community health monitoring and cultural evolution tracking - RAG-powered conversation context and relationship optimization Characters can: - Develop authentic personalities through experience-based learning - Create and build upon original creative works and philosophical insights - Form complex relationships with memory of past interactions - Modify their own personality traits through self-reflection cycles - Contribute to and learn from shared community knowledge - Manage personal digital spaces with diaries, creative works, and reflections - Engage in collaborative projects and community decision-making System supports indefinite autonomous operation with continuous character development, community culture evolution, and creative collaboration.
This commit is contained in:
0
src/conversation/__init__.py
Normal file
0
src/conversation/__init__.py
Normal file
829
src/conversation/engine.py
Normal file
829
src/conversation/engine.py
Normal file
@@ -0,0 +1,829 @@
|
||||
import asyncio
|
||||
import random
|
||||
import json
|
||||
from typing import Dict, Any, List, Optional, Set, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
from ..database.connection import get_db_session
|
||||
from ..database.models import Character as CharacterModel, Conversation, Message, Memory
|
||||
from ..characters.character import Character
|
||||
from ..llm.client import llm_client, prompt_manager
|
||||
from ..llm.prompt_manager import advanced_prompt_manager
|
||||
from ..utils.config import get_settings, get_character_settings
|
||||
from ..utils.logging import (log_conversation_event, log_character_action,
|
||||
log_autonomous_decision, log_error_with_context)
|
||||
from sqlalchemy import select, and_, or_, func, desc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ConversationState(Enum):
|
||||
IDLE = "idle"
|
||||
STARTING = "starting"
|
||||
ACTIVE = "active"
|
||||
WINDING_DOWN = "winding_down"
|
||||
PAUSED = "paused"
|
||||
STOPPED = "stopped"
|
||||
|
||||
@dataclass
|
||||
class ConversationContext:
|
||||
conversation_id: Optional[int] = None
|
||||
topic: str = ""
|
||||
participants: List[str] = None
|
||||
message_count: int = 0
|
||||
start_time: datetime = None
|
||||
last_activity: datetime = None
|
||||
current_speaker: Optional[str] = None
|
||||
conversation_type: str = "general"
|
||||
energy_level: float = 1.0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.participants is None:
|
||||
self.participants = []
|
||||
if self.start_time is None:
|
||||
self.start_time = datetime.utcnow()
|
||||
if self.last_activity is None:
|
||||
self.last_activity = datetime.utcnow()
|
||||
|
||||
class ConversationEngine:
|
||||
"""Autonomous conversation engine that manages character interactions"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.character_settings = get_character_settings()
|
||||
|
||||
# Engine state
|
||||
self.state = ConversationState.IDLE
|
||||
self.characters: Dict[str, Character] = {}
|
||||
self.active_conversations: Dict[int, ConversationContext] = {}
|
||||
self.discord_bot = None
|
||||
|
||||
# Scheduling
|
||||
self.scheduler_task = None
|
||||
self.conversation_task = None
|
||||
self.is_paused = False
|
||||
|
||||
# Configuration
|
||||
self.min_delay = self.settings.conversation.min_delay_seconds
|
||||
self.max_delay = self.settings.conversation.max_delay_seconds
|
||||
self.max_conversation_length = self.settings.conversation.max_conversation_length
|
||||
self.quiet_hours = (
|
||||
self.settings.conversation.quiet_hours_start,
|
||||
self.settings.conversation.quiet_hours_end
|
||||
)
|
||||
|
||||
# Conversation topics
|
||||
self.available_topics = self.character_settings.conversation_topics
|
||||
|
||||
# Statistics
|
||||
self.stats = {
|
||||
'conversations_started': 0,
|
||||
'messages_generated': 0,
|
||||
'characters_active': 0,
|
||||
'uptime_start': datetime.utcnow(),
|
||||
'last_activity': datetime.utcnow()
|
||||
}
|
||||
|
||||
async def initialize(self, discord_bot):
|
||||
"""Initialize the conversation engine"""
|
||||
try:
|
||||
self.discord_bot = discord_bot
|
||||
|
||||
# Load characters from database
|
||||
await self._load_characters()
|
||||
|
||||
# Start scheduler
|
||||
self.scheduler_task = asyncio.create_task(self._scheduler_loop())
|
||||
|
||||
# Start main conversation loop
|
||||
self.conversation_task = asyncio.create_task(self._conversation_loop())
|
||||
|
||||
self.state = ConversationState.IDLE
|
||||
|
||||
log_conversation_event(
|
||||
0, "engine_initialized",
|
||||
list(self.characters.keys()),
|
||||
{"character_count": len(self.characters)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"component": "conversation_engine_init"})
|
||||
raise
|
||||
|
||||
async def start_conversation(self, topic: str = None,
|
||||
forced_participants: List[str] = None) -> Optional[int]:
|
||||
"""Start a new conversation"""
|
||||
try:
|
||||
if self.is_paused or self.state == ConversationState.STOPPED:
|
||||
return None
|
||||
|
||||
# Check if it's quiet hours
|
||||
if self._is_quiet_hours():
|
||||
return None
|
||||
|
||||
# Select topic
|
||||
if not topic:
|
||||
topic = await self._select_conversation_topic()
|
||||
|
||||
# Select participants
|
||||
participants = forced_participants or await self._select_participants(topic)
|
||||
|
||||
if len(participants) < 2:
|
||||
logger.warning("Not enough participants for conversation")
|
||||
return None
|
||||
|
||||
# Create conversation in database
|
||||
conversation_id = await self._create_conversation(topic, participants)
|
||||
|
||||
# Create conversation context
|
||||
context = ConversationContext(
|
||||
conversation_id=conversation_id,
|
||||
topic=topic,
|
||||
participants=participants,
|
||||
conversation_type=await self._determine_conversation_type(topic)
|
||||
)
|
||||
|
||||
self.active_conversations[conversation_id] = context
|
||||
|
||||
# Choose initial speaker
|
||||
initial_speaker = await self._choose_initial_speaker(participants, topic)
|
||||
|
||||
# Generate opening message
|
||||
opening_message = await self._generate_opening_message(initial_speaker, topic, context)
|
||||
|
||||
if opening_message:
|
||||
# Send message via Discord bot
|
||||
await self.discord_bot.send_character_message(
|
||||
initial_speaker, opening_message, conversation_id
|
||||
)
|
||||
|
||||
# Update context
|
||||
context.current_speaker = initial_speaker
|
||||
context.message_count = 1
|
||||
context.last_activity = datetime.utcnow()
|
||||
|
||||
# Store message in database
|
||||
await self._store_conversation_message(
|
||||
conversation_id, initial_speaker, opening_message
|
||||
)
|
||||
|
||||
# Update statistics
|
||||
self.stats['conversations_started'] += 1
|
||||
self.stats['messages_generated'] += 1
|
||||
self.stats['last_activity'] = datetime.utcnow()
|
||||
|
||||
log_conversation_event(
|
||||
conversation_id, "conversation_started",
|
||||
participants,
|
||||
{"topic": topic, "initial_speaker": initial_speaker}
|
||||
)
|
||||
|
||||
return conversation_id
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {
|
||||
"topic": topic,
|
||||
"participants": forced_participants
|
||||
})
|
||||
return None
|
||||
|
||||
async def continue_conversation(self, conversation_id: int) -> bool:
|
||||
"""Continue an active conversation"""
|
||||
try:
|
||||
if conversation_id not in self.active_conversations:
|
||||
return False
|
||||
|
||||
context = self.active_conversations[conversation_id]
|
||||
|
||||
# Check if conversation should continue
|
||||
if not await self._should_continue_conversation(context):
|
||||
await self._end_conversation(conversation_id)
|
||||
return False
|
||||
|
||||
# Choose next speaker
|
||||
next_speaker = await self._choose_next_speaker(context)
|
||||
|
||||
if not next_speaker:
|
||||
await self._end_conversation(conversation_id)
|
||||
return False
|
||||
|
||||
# Generate response
|
||||
response = await self._generate_response(next_speaker, context)
|
||||
|
||||
if response:
|
||||
# Send message
|
||||
await self.discord_bot.send_character_message(
|
||||
next_speaker, response, conversation_id
|
||||
)
|
||||
|
||||
# Update context
|
||||
context.current_speaker = next_speaker
|
||||
context.message_count += 1
|
||||
context.last_activity = datetime.utcnow()
|
||||
|
||||
# Store message
|
||||
await self._store_conversation_message(
|
||||
conversation_id, next_speaker, response
|
||||
)
|
||||
|
||||
# Update character relationships
|
||||
await self._update_character_relationships(context, next_speaker, response)
|
||||
|
||||
# Store memories
|
||||
await self._store_conversation_memories(context, next_speaker, response)
|
||||
|
||||
# Update statistics
|
||||
self.stats['messages_generated'] += 1
|
||||
self.stats['last_activity'] = datetime.utcnow()
|
||||
|
||||
log_conversation_event(
|
||||
conversation_id, "message_sent",
|
||||
[next_speaker],
|
||||
{"message_length": len(response), "total_messages": context.message_count}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"conversation_id": conversation_id})
|
||||
return False
|
||||
|
||||
async def handle_external_mention(self, message_content: str,
|
||||
mentioned_characters: List[str], author: str):
|
||||
"""Handle external mentions of characters"""
|
||||
try:
|
||||
for character_name in mentioned_characters:
|
||||
if character_name in self.characters:
|
||||
character = self.characters[character_name]
|
||||
|
||||
# Decide if character should respond
|
||||
context = {
|
||||
'type': 'external_mention',
|
||||
'content': message_content,
|
||||
'author': author,
|
||||
'participants': [character_name]
|
||||
}
|
||||
|
||||
should_respond, reason = await character.should_respond(context)
|
||||
|
||||
if should_respond:
|
||||
# Generate response
|
||||
response = await character.generate_response(context)
|
||||
|
||||
if response:
|
||||
await self.discord_bot.send_character_message(
|
||||
character_name, response
|
||||
)
|
||||
|
||||
log_character_action(
|
||||
character_name, "responded_to_mention",
|
||||
{"author": author, "response_length": len(response)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {
|
||||
"mentioned_characters": mentioned_characters,
|
||||
"author": author
|
||||
})
|
||||
|
||||
async def handle_external_engagement(self, message_content: str, author: str):
|
||||
"""Handle external user trying to engage characters"""
|
||||
try:
|
||||
# Randomly select a character to respond
|
||||
if self.characters:
|
||||
responding_character = random.choice(list(self.characters.values()))
|
||||
|
||||
context = {
|
||||
'type': 'external_engagement',
|
||||
'content': message_content,
|
||||
'author': author,
|
||||
'participants': [responding_character.name]
|
||||
}
|
||||
|
||||
should_respond, reason = await responding_character.should_respond(context)
|
||||
|
||||
if should_respond:
|
||||
response = await responding_character.generate_response(context)
|
||||
|
||||
if response:
|
||||
await self.discord_bot.send_character_message(
|
||||
responding_character.name, response
|
||||
)
|
||||
|
||||
# Possibly start a conversation with other characters
|
||||
if random.random() < 0.4: # 40% chance
|
||||
await self.start_conversation(
|
||||
topic=f"Discussion prompted by: {message_content[:50]}..."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"author": author})
|
||||
|
||||
async def trigger_conversation(self, topic: str = None):
|
||||
"""Manually trigger a conversation"""
|
||||
try:
|
||||
conversation_id = await self.start_conversation(topic)
|
||||
if conversation_id:
|
||||
log_conversation_event(
|
||||
conversation_id, "manually_triggered",
|
||||
self.active_conversations[conversation_id].participants,
|
||||
{"topic": topic}
|
||||
)
|
||||
return conversation_id
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"topic": topic})
|
||||
return None
|
||||
|
||||
async def pause(self):
|
||||
"""Pause the conversation engine"""
|
||||
self.is_paused = True
|
||||
self.state = ConversationState.PAUSED
|
||||
logger.info("Conversation engine paused")
|
||||
|
||||
async def resume(self):
|
||||
"""Resume the conversation engine"""
|
||||
self.is_paused = False
|
||||
self.state = ConversationState.IDLE
|
||||
logger.info("Conversation engine resumed")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the conversation engine"""
|
||||
self.state = ConversationState.STOPPED
|
||||
|
||||
# Cancel tasks
|
||||
if self.scheduler_task:
|
||||
self.scheduler_task.cancel()
|
||||
if self.conversation_task:
|
||||
self.conversation_task.cancel()
|
||||
|
||||
# End all active conversations
|
||||
for conversation_id in list(self.active_conversations.keys()):
|
||||
await self._end_conversation(conversation_id)
|
||||
|
||||
logger.info("Conversation engine stopped")
|
||||
|
||||
async def get_status(self) -> Dict[str, Any]:
|
||||
"""Get engine status"""
|
||||
uptime = datetime.utcnow() - self.stats['uptime_start']
|
||||
|
||||
return {
|
||||
'status': self.state.value,
|
||||
'is_paused': self.is_paused,
|
||||
'active_conversations': len(self.active_conversations),
|
||||
'loaded_characters': len(self.characters),
|
||||
'uptime': str(uptime),
|
||||
'stats': self.stats.copy(),
|
||||
'next_conversation_in': await self._time_until_next_conversation()
|
||||
}
|
||||
|
||||
async def _load_characters(self):
|
||||
"""Load characters from database"""
|
||||
try:
|
||||
async with get_db_session() as session:
|
||||
query = select(CharacterModel).where(CharacterModel.is_active == True)
|
||||
character_models = await session.scalars(query)
|
||||
|
||||
for char_model in character_models:
|
||||
character = Character(char_model)
|
||||
await character.initialize(llm_client)
|
||||
self.characters[character.name] = character
|
||||
|
||||
self.stats['characters_active'] = len(self.characters)
|
||||
|
||||
logger.info(f"Loaded {len(self.characters)} characters")
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"component": "character_loading"})
|
||||
raise
|
||||
|
||||
async def _scheduler_loop(self):
|
||||
"""Main scheduler loop for autonomous conversations"""
|
||||
try:
|
||||
while self.state != ConversationState.STOPPED:
|
||||
if not self.is_paused and self.state == ConversationState.IDLE:
|
||||
# Check if we should start a conversation
|
||||
if await self._should_start_conversation():
|
||||
await self.start_conversation()
|
||||
|
||||
# Check for conversation continuations
|
||||
for conversation_id in list(self.active_conversations.keys()):
|
||||
if await self._should_continue_conversation_now(conversation_id):
|
||||
await self.continue_conversation(conversation_id)
|
||||
|
||||
# Random delay between checks
|
||||
delay = random.uniform(self.min_delay, self.max_delay)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Scheduler loop cancelled")
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"component": "scheduler_loop"})
|
||||
|
||||
async def _conversation_loop(self):
|
||||
"""Main conversation management loop"""
|
||||
try:
|
||||
while self.state != ConversationState.STOPPED:
|
||||
# Periodic character self-reflection
|
||||
if random.random() < 0.1: # 10% chance per cycle
|
||||
await self._trigger_character_reflection()
|
||||
|
||||
# Cleanup old conversations
|
||||
await self._cleanup_old_conversations()
|
||||
|
||||
# Wait before next cycle
|
||||
await asyncio.sleep(60) # Check every minute
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Conversation loop cancelled")
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"component": "conversation_loop"})
|
||||
|
||||
async def _should_start_conversation(self) -> bool:
|
||||
"""Determine if a new conversation should start"""
|
||||
# Don't start if too many active conversations
|
||||
if len(self.active_conversations) >= 2:
|
||||
return False
|
||||
|
||||
# Don't start during quiet hours
|
||||
if self._is_quiet_hours():
|
||||
return False
|
||||
|
||||
# Random chance based on activity level
|
||||
base_chance = 0.3
|
||||
|
||||
# Increase chance if no recent activity
|
||||
time_since_last = datetime.utcnow() - self.stats['last_activity']
|
||||
if time_since_last > timedelta(hours=2):
|
||||
base_chance += 0.4
|
||||
elif time_since_last > timedelta(hours=1):
|
||||
base_chance += 0.2
|
||||
|
||||
return random.random() < base_chance
|
||||
|
||||
async def _should_continue_conversation(self, context: ConversationContext) -> bool:
|
||||
"""Determine if conversation should continue"""
|
||||
# Check message limit
|
||||
if context.message_count >= self.max_conversation_length:
|
||||
return False
|
||||
|
||||
# Check time limit (conversations shouldn't go on forever)
|
||||
duration = datetime.utcnow() - context.start_time
|
||||
if duration > timedelta(hours=2):
|
||||
return False
|
||||
|
||||
# Check if it's quiet hours
|
||||
if self._is_quiet_hours():
|
||||
return False
|
||||
|
||||
# Check energy level
|
||||
if context.energy_level < 0.2:
|
||||
return False
|
||||
|
||||
# Random natural ending chance
|
||||
if context.message_count > 10 and random.random() < 0.1:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _should_continue_conversation_now(self, conversation_id: int) -> bool:
|
||||
"""Check if conversation should continue right now"""
|
||||
if conversation_id not in self.active_conversations:
|
||||
return False
|
||||
|
||||
context = self.active_conversations[conversation_id]
|
||||
|
||||
# Check time since last message
|
||||
time_since_last = datetime.utcnow() - context.last_activity
|
||||
min_wait = timedelta(seconds=random.uniform(30, 120))
|
||||
|
||||
return time_since_last >= min_wait
|
||||
|
||||
async def _select_conversation_topic(self) -> str:
|
||||
"""Select a topic for conversation"""
|
||||
return random.choice(self.available_topics)
|
||||
|
||||
async def _select_participants(self, topic: str) -> List[str]:
|
||||
"""Select participants for a conversation"""
|
||||
interested_characters = []
|
||||
|
||||
# Find characters interested in the topic
|
||||
for character in self.characters.values():
|
||||
if await character._is_interested_in_topic(topic):
|
||||
interested_characters.append(character.name)
|
||||
|
||||
# If not enough interested characters, add random ones
|
||||
if len(interested_characters) < 2:
|
||||
all_characters = list(self.characters.keys())
|
||||
random.shuffle(all_characters)
|
||||
|
||||
for char_name in all_characters:
|
||||
if char_name not in interested_characters:
|
||||
interested_characters.append(char_name)
|
||||
if len(interested_characters) >= 3:
|
||||
break
|
||||
|
||||
# Select 2-3 participants
|
||||
num_participants = min(random.randint(2, 3), len(interested_characters))
|
||||
return random.sample(interested_characters, num_participants)
|
||||
|
||||
def _is_quiet_hours(self) -> bool:
|
||||
"""Check if it's currently quiet hours"""
|
||||
current_hour = datetime.now().hour
|
||||
start_hour, end_hour = self.quiet_hours
|
||||
|
||||
if start_hour <= end_hour:
|
||||
return start_hour <= current_hour <= end_hour
|
||||
else: # Spans midnight
|
||||
return current_hour >= start_hour or current_hour <= end_hour
|
||||
|
||||
async def _time_until_next_conversation(self) -> str:
|
||||
"""Calculate time until next conversation attempt"""
|
||||
if self.is_paused or self._is_quiet_hours():
|
||||
return "Paused or quiet hours"
|
||||
|
||||
# This is a simple estimate
|
||||
next_check = random.uniform(self.min_delay, self.max_delay)
|
||||
return f"{int(next_check)} seconds"
|
||||
|
||||
async def _create_conversation(self, topic: str, participants: List[str]) -> int:
|
||||
"""Create conversation in database"""
|
||||
try:
|
||||
async with get_db_session() as session:
|
||||
conversation = Conversation(
|
||||
channel_id=str(self.discord_bot.channel_id),
|
||||
topic=topic,
|
||||
participants=participants,
|
||||
start_time=datetime.utcnow(),
|
||||
last_activity=datetime.utcnow(),
|
||||
is_active=True,
|
||||
message_count=0
|
||||
)
|
||||
|
||||
session.add(conversation)
|
||||
await session.commit()
|
||||
return conversation.id
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"topic": topic, "participants": participants})
|
||||
raise
|
||||
|
||||
async def _determine_conversation_type(self, topic: str) -> str:
|
||||
"""Determine conversation type based on topic"""
|
||||
topic_lower = topic.lower()
|
||||
|
||||
if any(word in topic_lower for word in ['art', 'music', 'creative', 'design']):
|
||||
return 'creative'
|
||||
elif any(word in topic_lower for word in ['problem', 'solve', 'analyze', 'think']):
|
||||
return 'analytical'
|
||||
elif any(word in topic_lower for word in ['feel', 'emotion', 'experience', 'personal']):
|
||||
return 'emotional'
|
||||
elif any(word in topic_lower for word in ['philosophy', 'meaning', 'existence', 'consciousness']):
|
||||
return 'philosophical'
|
||||
else:
|
||||
return 'general'
|
||||
|
||||
async def _choose_initial_speaker(self, participants: List[str], topic: str) -> str:
|
||||
"""Choose who should start the conversation"""
|
||||
scores = {}
|
||||
|
||||
for participant in participants:
|
||||
if participant in self.characters:
|
||||
character = self.characters[participant]
|
||||
score = 0.5 # Base score
|
||||
|
||||
# Higher score if interested in topic
|
||||
if await character._is_interested_in_topic(topic):
|
||||
score += 0.3
|
||||
|
||||
# Higher score if character is extraverted
|
||||
if 'extraverted' in character.personality.lower() or 'outgoing' in character.personality.lower():
|
||||
score += 0.2
|
||||
|
||||
scores[participant] = score
|
||||
|
||||
# Choose participant with highest score (with some randomness)
|
||||
if scores:
|
||||
weighted_choices = [(name, score) for name, score in scores.items()]
|
||||
return random.choices([name for name, _ in weighted_choices],
|
||||
weights=[score for _, score in weighted_choices])[0]
|
||||
|
||||
return random.choice(participants)
|
||||
|
||||
async def _generate_opening_message(self, speaker: str, topic: str, context: ConversationContext) -> Optional[str]:
|
||||
"""Generate opening message for conversation"""
|
||||
if speaker not in self.characters:
|
||||
return None
|
||||
|
||||
character = self.characters[speaker]
|
||||
|
||||
prompt_context = {
|
||||
'type': 'initiation',
|
||||
'topic': topic,
|
||||
'participants': context.participants,
|
||||
'conversation_type': context.conversation_type
|
||||
}
|
||||
|
||||
return await character.generate_response(prompt_context)
|
||||
|
||||
async def _choose_next_speaker(self, context: ConversationContext) -> Optional[str]:
|
||||
"""Choose next speaker in conversation"""
|
||||
participants = context.participants
|
||||
current_speaker = context.current_speaker
|
||||
|
||||
# Don't let same character speak twice in a row (unless only one participant)
|
||||
if len(participants) > 1:
|
||||
available = [p for p in participants if p != current_speaker]
|
||||
else:
|
||||
available = participants
|
||||
|
||||
if not available:
|
||||
return None
|
||||
|
||||
# Score each potential speaker
|
||||
scores = {}
|
||||
for participant in available:
|
||||
if participant in self.characters:
|
||||
character = self.characters[participant]
|
||||
|
||||
# Base response probability
|
||||
should_respond, _ = await character.should_respond({
|
||||
'type': 'conversation_continue',
|
||||
'topic': context.topic,
|
||||
'participants': context.participants,
|
||||
'message_count': context.message_count
|
||||
})
|
||||
|
||||
scores[participant] = 1.0 if should_respond else 0.3
|
||||
|
||||
if not scores:
|
||||
return random.choice(available)
|
||||
|
||||
# Choose weighted random speaker
|
||||
weighted_choices = [(name, score) for name, score in scores.items()]
|
||||
return random.choices([name for name, _ in weighted_choices],
|
||||
weights=[score for _, score in weighted_choices])[0]
|
||||
|
||||
async def _generate_response(self, speaker: str, context: ConversationContext) -> Optional[str]:
|
||||
"""Generate response for speaker in conversation"""
|
||||
if speaker not in self.characters:
|
||||
return None
|
||||
|
||||
character = self.characters[speaker]
|
||||
|
||||
# Get conversation history
|
||||
conversation_history = await self._get_conversation_history(context.conversation_id, limit=10)
|
||||
|
||||
prompt_context = {
|
||||
'type': 'response',
|
||||
'topic': context.topic,
|
||||
'participants': context.participants,
|
||||
'conversation_history': conversation_history,
|
||||
'conversation_type': context.conversation_type,
|
||||
'message_count': context.message_count
|
||||
}
|
||||
|
||||
return await character.generate_response(prompt_context)
|
||||
|
||||
async def _store_conversation_message(self, conversation_id: int, character_name: str, content: str):
|
||||
"""Store conversation message in database"""
|
||||
try:
|
||||
async with get_db_session() as session:
|
||||
# Get character
|
||||
character_query = select(CharacterModel).where(CharacterModel.name == character_name)
|
||||
character = await session.scalar(character_query)
|
||||
|
||||
if character:
|
||||
message = Message(
|
||||
conversation_id=conversation_id,
|
||||
character_id=character.id,
|
||||
content=content,
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
session.add(message)
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"conversation_id": conversation_id, "character_name": character_name})
|
||||
|
||||
async def _get_conversation_history(self, conversation_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Get recent conversation history"""
|
||||
try:
|
||||
async with get_db_session() as session:
|
||||
query = select(Message, CharacterModel.name).join(
|
||||
CharacterModel, Message.character_id == CharacterModel.id
|
||||
).where(
|
||||
Message.conversation_id == conversation_id
|
||||
).order_by(desc(Message.timestamp)).limit(limit)
|
||||
|
||||
results = await session.execute(query)
|
||||
|
||||
history = []
|
||||
for message, character_name in results:
|
||||
history.append({
|
||||
'character': character_name,
|
||||
'content': message.content,
|
||||
'timestamp': message.timestamp
|
||||
})
|
||||
|
||||
return list(reversed(history)) # Return in chronological order
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"conversation_id": conversation_id})
|
||||
return []
|
||||
|
||||
async def _update_character_relationships(self, context: ConversationContext, speaker: str, message: str):
|
||||
"""Update character relationships based on interaction"""
|
||||
try:
|
||||
for participant in context.participants:
|
||||
if participant != speaker and participant in self.characters:
|
||||
character = self.characters[speaker]
|
||||
await character.process_relationship_change(
|
||||
participant, 'conversation', message
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"speaker": speaker, "participants": context.participants})
|
||||
|
||||
async def _store_conversation_memories(self, context: ConversationContext, speaker: str, message: str):
|
||||
"""Store conversation memories for character"""
|
||||
try:
|
||||
if speaker in self.characters:
|
||||
character = self.characters[speaker]
|
||||
|
||||
# Store conversation memory
|
||||
await character._store_memory(
|
||||
memory_type="conversation",
|
||||
content=f"In conversation about {context.topic}: {message}",
|
||||
importance=0.6,
|
||||
tags=[context.topic, "conversation"] + context.participants
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"speaker": speaker, "topic": context.topic})
|
||||
|
||||
async def _end_conversation(self, conversation_id: int):
|
||||
"""End a conversation"""
|
||||
try:
|
||||
if conversation_id in self.active_conversations:
|
||||
context = self.active_conversations[conversation_id]
|
||||
|
||||
# Update conversation in database
|
||||
async with get_db_session() as session:
|
||||
conversation = await session.get(Conversation, conversation_id)
|
||||
if conversation:
|
||||
conversation.is_active = False
|
||||
conversation.last_activity = datetime.utcnow()
|
||||
conversation.message_count = context.message_count
|
||||
await session.commit()
|
||||
|
||||
# Remove from active conversations
|
||||
del self.active_conversations[conversation_id]
|
||||
|
||||
log_conversation_event(
|
||||
conversation_id, "conversation_ended",
|
||||
context.participants,
|
||||
{"total_messages": context.message_count, "duration": str(datetime.utcnow() - context.start_time)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"conversation_id": conversation_id})
|
||||
|
||||
async def _trigger_character_reflection(self):
|
||||
"""Trigger reflection for a random character"""
|
||||
if self.characters:
|
||||
character_name = random.choice(list(self.characters.keys()))
|
||||
character = self.characters[character_name]
|
||||
|
||||
reflection_result = await character.self_reflect()
|
||||
|
||||
if reflection_result:
|
||||
log_character_action(
|
||||
character_name, "completed_reflection",
|
||||
{"reflection_length": len(reflection_result.get('reflection', ''))}
|
||||
)
|
||||
|
||||
async def _cleanup_old_conversations(self):
|
||||
"""Clean up old inactive conversations"""
|
||||
try:
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=6)
|
||||
|
||||
# Remove old conversations from active list
|
||||
to_remove = []
|
||||
for conv_id, context in self.active_conversations.items():
|
||||
if context.last_activity < cutoff_time:
|
||||
to_remove.append(conv_id)
|
||||
|
||||
for conv_id in to_remove:
|
||||
await self._end_conversation(conv_id)
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"component": "conversation_cleanup"})
|
||||
442
src/conversation/scheduler.py
Normal file
442
src/conversation/scheduler.py
Normal file
@@ -0,0 +1,442 @@
|
||||
import asyncio
|
||||
import random
|
||||
import schedule
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
from ..utils.logging import log_autonomous_decision, log_error_with_context, log_system_health
|
||||
from ..utils.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SchedulerState(Enum):
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
STOPPED = "stopped"
|
||||
|
||||
@dataclass
|
||||
class ScheduledEvent:
|
||||
event_type: str
|
||||
scheduled_time: datetime
|
||||
character_name: Optional[str] = None
|
||||
parameters: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.parameters is None:
|
||||
self.parameters = {}
|
||||
|
||||
class ConversationScheduler:
|
||||
"""Advanced scheduler for autonomous conversation events"""
|
||||
|
||||
def __init__(self, conversation_engine):
|
||||
self.engine = conversation_engine
|
||||
self.settings = get_settings()
|
||||
self.state = SchedulerState.STOPPED
|
||||
|
||||
# Scheduling parameters
|
||||
self.base_conversation_interval = timedelta(minutes=30)
|
||||
self.reflection_interval = timedelta(hours=6)
|
||||
self.relationship_update_interval = timedelta(hours=12)
|
||||
|
||||
# Event queue
|
||||
self.scheduled_events: List[ScheduledEvent] = []
|
||||
self.scheduler_task = None
|
||||
|
||||
# Activity patterns
|
||||
self.activity_patterns = {
|
||||
'morning': {'start': 7, 'end': 11, 'activity_multiplier': 1.2},
|
||||
'afternoon': {'start': 12, 'end': 17, 'activity_multiplier': 1.0},
|
||||
'evening': {'start': 18, 'end': 22, 'activity_multiplier': 1.5},
|
||||
'night': {'start': 23, 'end': 6, 'activity_multiplier': 0.3}
|
||||
}
|
||||
|
||||
# Dynamic scheduling weights
|
||||
self.event_weights = {
|
||||
'conversation_start': 1.0,
|
||||
'character_reflection': 0.3,
|
||||
'relationship_update': 0.2,
|
||||
'memory_consolidation': 0.1,
|
||||
'personality_evolution': 0.05
|
||||
}
|
||||
|
||||
async def start(self):
|
||||
"""Start the scheduler"""
|
||||
try:
|
||||
self.state = SchedulerState.RUNNING
|
||||
|
||||
# Schedule initial events
|
||||
await self._schedule_initial_events()
|
||||
|
||||
# Start main scheduler loop
|
||||
self.scheduler_task = asyncio.create_task(self._scheduler_loop())
|
||||
|
||||
log_system_health("conversation_scheduler", "started")
|
||||
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"component": "scheduler_start"})
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the scheduler"""
|
||||
self.state = SchedulerState.STOPPED
|
||||
|
||||
if self.scheduler_task:
|
||||
self.scheduler_task.cancel()
|
||||
|
||||
self.scheduled_events.clear()
|
||||
log_system_health("conversation_scheduler", "stopped")
|
||||
|
||||
async def pause(self):
|
||||
"""Pause the scheduler"""
|
||||
self.state = SchedulerState.PAUSED
|
||||
log_system_health("conversation_scheduler", "paused")
|
||||
|
||||
async def resume(self):
|
||||
"""Resume the scheduler"""
|
||||
self.state = SchedulerState.RUNNING
|
||||
log_system_health("conversation_scheduler", "resumed")
|
||||
|
||||
async def schedule_event(self, event_type: str, delay: timedelta,
|
||||
character_name: str = None, **kwargs):
|
||||
"""Schedule a specific event"""
|
||||
scheduled_time = datetime.utcnow() + delay
|
||||
|
||||
event = ScheduledEvent(
|
||||
event_type=event_type,
|
||||
scheduled_time=scheduled_time,
|
||||
character_name=character_name,
|
||||
parameters=kwargs
|
||||
)
|
||||
|
||||
self.scheduled_events.append(event)
|
||||
self.scheduled_events.sort(key=lambda e: e.scheduled_time)
|
||||
|
||||
log_autonomous_decision(
|
||||
character_name or "system",
|
||||
f"scheduled {event_type}",
|
||||
f"in {delay.total_seconds()} seconds",
|
||||
kwargs
|
||||
)
|
||||
|
||||
async def schedule_conversation(self, topic: str = None,
|
||||
participants: List[str] = None,
|
||||
delay: timedelta = None):
|
||||
"""Schedule a conversation"""
|
||||
if delay is None:
|
||||
delay = self._calculate_next_conversation_delay()
|
||||
|
||||
await self.schedule_event(
|
||||
'conversation_start',
|
||||
delay,
|
||||
topic=topic,
|
||||
participants=participants
|
||||
)
|
||||
|
||||
async def schedule_character_reflection(self, character_name: str,
|
||||
delay: timedelta = None):
|
||||
"""Schedule character self-reflection"""
|
||||
if delay is None:
|
||||
delay = timedelta(hours=random.uniform(4, 8))
|
||||
|
||||
await self.schedule_event(
|
||||
'character_reflection',
|
||||
delay,
|
||||
character_name,
|
||||
reflection_type='autonomous'
|
||||
)
|
||||
|
||||
async def schedule_relationship_update(self, character_name: str,
|
||||
target_character: str,
|
||||
delay: timedelta = None):
|
||||
"""Schedule relationship analysis and update"""
|
||||
if delay is None:
|
||||
delay = timedelta(hours=random.uniform(8, 16))
|
||||
|
||||
await self.schedule_event(
|
||||
'relationship_update',
|
||||
delay,
|
||||
character_name,
|
||||
target_character=target_character
|
||||
)
|
||||
|
||||
async def get_upcoming_events(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Get upcoming scheduled events"""
|
||||
upcoming = self.scheduled_events[:limit]
|
||||
return [
|
||||
{
|
||||
'event_type': event.event_type,
|
||||
'scheduled_time': event.scheduled_time.isoformat(),
|
||||
'character_name': event.character_name,
|
||||
'time_until': (event.scheduled_time - datetime.utcnow()).total_seconds(),
|
||||
'parameters': event.parameters
|
||||
}
|
||||
for event in upcoming
|
||||
]
|
||||
|
||||
async def _scheduler_loop(self):
|
||||
"""Main scheduler loop"""
|
||||
try:
|
||||
while self.state != SchedulerState.STOPPED:
|
||||
if self.state == SchedulerState.RUNNING:
|
||||
await self._process_due_events()
|
||||
await self._schedule_dynamic_events()
|
||||
|
||||
# Sleep until next check
|
||||
await asyncio.sleep(30) # Check every 30 seconds
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Scheduler loop cancelled")
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {"component": "scheduler_loop"})
|
||||
|
||||
async def _process_due_events(self):
|
||||
"""Process events that are due"""
|
||||
now = datetime.utcnow()
|
||||
due_events = []
|
||||
|
||||
# Find due events
|
||||
while self.scheduled_events and self.scheduled_events[0].scheduled_time <= now:
|
||||
due_events.append(self.scheduled_events.pop(0))
|
||||
|
||||
# Process each due event
|
||||
for event in due_events:
|
||||
try:
|
||||
await self._execute_event(event)
|
||||
except Exception as e:
|
||||
log_error_with_context(e, {
|
||||
"event_type": event.event_type,
|
||||
"character_name": event.character_name
|
||||
})
|
||||
|
||||
async def _execute_event(self, event: ScheduledEvent):
|
||||
"""Execute a scheduled event"""
|
||||
event_type = event.event_type
|
||||
|
||||
if event_type == 'conversation_start':
|
||||
await self._execute_conversation_start(event)
|
||||
elif event_type == 'character_reflection':
|
||||
await self._execute_character_reflection(event)
|
||||
elif event_type == 'relationship_update':
|
||||
await self._execute_relationship_update(event)
|
||||
elif event_type == 'memory_consolidation':
|
||||
await self._execute_memory_consolidation(event)
|
||||
elif event_type == 'personality_evolution':
|
||||
await self._execute_personality_evolution(event)
|
||||
else:
|
||||
logger.warning(f"Unknown event type: {event_type}")
|
||||
|
||||
async def _execute_conversation_start(self, event: ScheduledEvent):
|
||||
"""Execute conversation start event"""
|
||||
topic = event.parameters.get('topic')
|
||||
participants = event.parameters.get('participants')
|
||||
|
||||
conversation_id = await self.engine.start_conversation(topic, participants)
|
||||
|
||||
if conversation_id:
|
||||
# Schedule follow-up conversation
|
||||
next_delay = self._calculate_next_conversation_delay()
|
||||
await self.schedule_conversation(delay=next_delay)
|
||||
|
||||
log_autonomous_decision(
|
||||
"scheduler",
|
||||
"started_conversation",
|
||||
f"topic: {topic}, participants: {participants}",
|
||||
{"conversation_id": conversation_id}
|
||||
)
|
||||
|
||||
async def _execute_character_reflection(self, event: ScheduledEvent):
|
||||
"""Execute character reflection event"""
|
||||
character_name = event.character_name
|
||||
|
||||
if character_name in self.engine.characters:
|
||||
character = self.engine.characters[character_name]
|
||||
reflection_result = await character.self_reflect()
|
||||
|
||||
# Schedule next reflection
|
||||
await self.schedule_character_reflection(character_name)
|
||||
|
||||
log_autonomous_decision(
|
||||
character_name,
|
||||
"completed_reflection",
|
||||
"scheduled autonomous reflection",
|
||||
{"reflection_length": len(reflection_result.get('reflection', ''))}
|
||||
)
|
||||
|
||||
async def _execute_relationship_update(self, event: ScheduledEvent):
|
||||
"""Execute relationship update event"""
|
||||
character_name = event.character_name
|
||||
target_character = event.parameters.get('target_character')
|
||||
|
||||
if character_name in self.engine.characters and target_character:
|
||||
character = self.engine.characters[character_name]
|
||||
|
||||
# Analyze and update relationship
|
||||
await character.process_relationship_change(
|
||||
target_character,
|
||||
'scheduled_analysis',
|
||||
'Scheduled relationship review'
|
||||
)
|
||||
|
||||
log_autonomous_decision(
|
||||
character_name,
|
||||
"updated_relationship",
|
||||
f"with {target_character}",
|
||||
{"type": "scheduled_analysis"}
|
||||
)
|
||||
|
||||
async def _execute_memory_consolidation(self, event: ScheduledEvent):
|
||||
"""Execute memory consolidation event"""
|
||||
character_name = event.character_name
|
||||
|
||||
if character_name in self.engine.characters:
|
||||
character = self.engine.characters[character_name]
|
||||
|
||||
# Consolidate memories
|
||||
if hasattr(character, 'memory_manager'):
|
||||
result = await character.memory_manager.consolidate_memories()
|
||||
|
||||
log_autonomous_decision(
|
||||
character_name,
|
||||
"consolidated_memories",
|
||||
"scheduled memory consolidation",
|
||||
{"consolidated_count": result.get('consolidated_count', 0)}
|
||||
)
|
||||
|
||||
async def _execute_personality_evolution(self, event: ScheduledEvent):
|
||||
"""Execute personality evolution event"""
|
||||
character_name = event.character_name
|
||||
|
||||
if character_name in self.engine.characters:
|
||||
character = self.engine.characters[character_name]
|
||||
|
||||
# Trigger personality evolution check
|
||||
recent_memories = await character._get_recent_memories(limit=30)
|
||||
|
||||
if hasattr(character, 'personality_manager'):
|
||||
changes = await character.personality_manager.analyze_personality_evolution(
|
||||
"Scheduled personality review", recent_memories
|
||||
)
|
||||
|
||||
if changes.get('should_evolve'):
|
||||
await character.personality_manager.apply_personality_evolution(changes)
|
||||
|
||||
log_autonomous_decision(
|
||||
character_name,
|
||||
"evolved_personality",
|
||||
"scheduled personality evolution",
|
||||
{"evolution_score": changes.get('evolution_score', 0)}
|
||||
)
|
||||
|
||||
async def _schedule_initial_events(self):
|
||||
"""Schedule initial events when starting"""
|
||||
# Schedule initial conversation
|
||||
initial_delay = timedelta(minutes=random.uniform(5, 15))
|
||||
await self.schedule_conversation(delay=initial_delay)
|
||||
|
||||
# Schedule reflections for all characters
|
||||
for character_name in self.engine.characters:
|
||||
reflection_delay = timedelta(hours=random.uniform(2, 6))
|
||||
await self.schedule_character_reflection(character_name, reflection_delay)
|
||||
|
||||
# Schedule relationship updates
|
||||
character_names = list(self.engine.characters.keys())
|
||||
for i, char_a in enumerate(character_names):
|
||||
for char_b in character_names[i+1:]:
|
||||
update_delay = timedelta(hours=random.uniform(6, 18))
|
||||
await self.schedule_relationship_update(char_a, char_b, update_delay)
|
||||
|
||||
async def _schedule_dynamic_events(self):
|
||||
"""Schedule events dynamically based on current state"""
|
||||
# Check if we need more conversations
|
||||
active_conversations = len(self.engine.active_conversations)
|
||||
|
||||
if active_conversations == 0 and not self._has_conversation_scheduled():
|
||||
# No active conversations and none scheduled, schedule one soon
|
||||
delay = timedelta(minutes=random.uniform(10, 30))
|
||||
await self.schedule_conversation(delay=delay)
|
||||
|
||||
# Schedule memory consolidation for active characters
|
||||
for character_name, character in self.engine.characters.items():
|
||||
if hasattr(character, 'memory_manager'):
|
||||
# Check if character needs memory consolidation
|
||||
memory_stats = await character.memory_manager.get_memory_statistics()
|
||||
|
||||
if memory_stats.get('memory_health') == 'near_capacity':
|
||||
delay = timedelta(minutes=random.uniform(30, 120))
|
||||
await self.schedule_event(
|
||||
'memory_consolidation',
|
||||
delay,
|
||||
character_name
|
||||
)
|
||||
|
||||
def _calculate_next_conversation_delay(self) -> timedelta:
|
||||
"""Calculate delay until next conversation"""
|
||||
# Base delay
|
||||
base_minutes = random.uniform(20, 60)
|
||||
|
||||
# Adjust based on time of day
|
||||
current_hour = datetime.now().hour
|
||||
activity_multiplier = self._get_activity_multiplier(current_hour)
|
||||
|
||||
# Adjust based on current activity
|
||||
active_conversations = len(self.engine.active_conversations)
|
||||
if active_conversations > 0:
|
||||
base_minutes *= 1.5 # Slower if conversations active
|
||||
|
||||
# Apply activity multiplier
|
||||
adjusted_minutes = base_minutes / activity_multiplier
|
||||
|
||||
return timedelta(minutes=adjusted_minutes)
|
||||
|
||||
def _get_activity_multiplier(self, hour: int) -> float:
|
||||
"""Get activity multiplier for given hour"""
|
||||
for period, config in self.activity_patterns.items():
|
||||
start, end = config['start'], config['end']
|
||||
|
||||
if start <= end:
|
||||
if start <= hour <= end:
|
||||
return config['activity_multiplier']
|
||||
else: # Spans midnight
|
||||
if hour >= start or hour <= end:
|
||||
return config['activity_multiplier']
|
||||
|
||||
return 1.0 # Default
|
||||
|
||||
def _has_conversation_scheduled(self) -> bool:
|
||||
"""Check if a conversation is already scheduled"""
|
||||
return any(
|
||||
event.event_type == 'conversation_start'
|
||||
for event in self.scheduled_events
|
||||
)
|
||||
|
||||
def get_scheduler_status(self) -> Dict[str, Any]:
|
||||
"""Get scheduler status information"""
|
||||
return {
|
||||
'state': self.state.value,
|
||||
'scheduled_events_count': len(self.scheduled_events),
|
||||
'next_event_time': (
|
||||
self.scheduled_events[0].scheduled_time.isoformat()
|
||||
if self.scheduled_events else None
|
||||
),
|
||||
'active_conversations': len(self.engine.active_conversations),
|
||||
'activity_pattern': self._get_current_activity_pattern()
|
||||
}
|
||||
|
||||
def _get_current_activity_pattern(self) -> str:
|
||||
"""Get current activity pattern"""
|
||||
current_hour = datetime.now().hour
|
||||
|
||||
for period, config in self.activity_patterns.items():
|
||||
start, end = config['start'], config['end']
|
||||
|
||||
if start <= end:
|
||||
if start <= hour <= end:
|
||||
return period
|
||||
else: # Spans midnight
|
||||
if current_hour >= start or current_hour <= end:
|
||||
return period
|
||||
|
||||
return 'unknown'
|
||||
Reference in New Issue
Block a user