MicroFish/backend/scripts/run_optimized_twitter_simul...

473 lines
17 KiB
Python

"""
Optimized Twitter Simulation Runner dengan:
1. Memory Leak Fix (auto cleanup)
2. Checkpoint System (save/restore progress)
3. Decision Caching (reduce LLM API calls)
4. Streaming Progress (real-time updates)
5. Batch Processing (memory-efficient)
"""
import argparse
import asyncio
import json
import logging
import os
import random
import signal
import sys
import gc
from datetime import datetime
from typing import Dict, Any, List, Optional, Tuple
# Add project path
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# Load .env
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
load_dotenv(_env_file)
# Import optimizations
from app.services.checkpoint_manager import CheckpointManager
from app.services.decision_cache import DecisionCache
from app.services.batch_processor import AgentBatchProcessor, BatchProcessorConfig
from app.api.streaming import SimulationEventBroadcaster
# Import OASIS
try:
from camel.models import ModelFactory
from camel.types import ModelPlatformType
import oasis
from oasis import (
ActionType,
LLMAction,
ManualAction,
generate_twitter_agent_graph
)
except ImportError as e:
print(f"Error: Missing dependency {e}")
print("Please install: pip install oasis-ai camel-ai")
sys.exit(1)
logger = logging.getLogger('mirofish.optimized_simulation')
class OptimizedTwitterSimulationRunner:
"""
Optimized Twitter Simulation Runner
Features:
- Checkpoint save/restore every N rounds
- Decision caching to reduce API calls
- Batch processing for memory efficiency
- Real-time progress streaming
- Auto memory cleanup
"""
# Configuration
CHECKPOINT_INTERVAL = 10 # Save checkpoint setiap 10 rounds
BATCH_SIZE = 10 # Process 10 agents per batch
MAX_CACHE_TTL = 24 # Cache TTL in hours
MEMORY_CLEANUP_INTERVAL = 5 # GC every N rounds
def __init__(
self,
config_path: str,
wait_for_commands: bool = True,
enable_caching: bool = True,
enable_streaming: bool = True,
enable_checkpoint: bool = True,
):
self.config_path = config_path
self.config = self._load_config()
self.simulation_id = self.config.get("simulation_id", "unknown")
self.simulation_dir = os.path.dirname(config_path)
self.wait_for_commands = wait_for_commands
# Feature flags
self.enable_caching = enable_caching
self.enable_streaming = enable_streaming
self.enable_checkpoint = enable_checkpoint
# Initialize components
self.env = None
self.agent_graph = None
# Caching
if self.enable_caching:
self.decision_cache = DecisionCache.get_instance()
self.decision_cache.load_from_disk(self.simulation_id)
else:
self.decision_cache = None
# Streaming
if self.enable_streaming:
self.broadcaster = SimulationEventBroadcaster(self.simulation_id)
else:
self.broadcaster = None
# Resume from checkpoint
self.resume_round = 0
if self.enable_checkpoint:
self.resume_round = CheckpointManager.get_resume_round(
self.simulation_id, platform="twitter"
)
if self.resume_round > 0:
logger.info(f"Resuming from checkpoint at round {self.resume_round}")
def _load_config(self) -> Dict[str, Any]:
"""Load configuration file"""
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def _get_profile_path(self) -> str:
"""Get profile file path"""
return os.path.join(self.simulation_dir, "twitter_profiles.csv")
def _get_db_path(self) -> str:
"""Get database path"""
return os.path.join(self.simulation_dir, "twitter_simulation.db")
def _create_model(self):
"""Create LLM model"""
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
if not llm_model:
llm_model = self.config.get("llm_model", "gpt-4o-mini")
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("Missing API Key. Set LLM_API_KEY in .env")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"LLM Config: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else 'default'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
)
def _get_active_agents_for_round(
self,
env,
current_hour: int,
round_num: int
) -> List[Tuple[int, Any]]:
"""Get active agents for current round"""
time_config = self.config.get("time_config", {})
agent_configs = self.config.get("agent_configs", [])
base_min = time_config.get("agents_per_hour_min", 5)
base_max = time_config.get("agents_per_hour_max", 20)
peak_hours = time_config.get("peak_hours", [9, 10, 11, 14, 15, 20, 21, 22])
off_peak_hours = time_config.get("off_peak_hours", [0, 1, 2, 3, 4, 5])
if current_hour in peak_hours:
multiplier = time_config.get("peak_activity_multiplier", 1.5)
elif current_hour in off_peak_hours:
multiplier = time_config.get("off_peak_activity_multiplier", 0.3)
else:
multiplier = 1.0
target_count = int(random.uniform(base_min, base_max) * multiplier)
candidates = []
for cfg in agent_configs:
agent_id = cfg.get("agent_id", 0)
active_hours = cfg.get("active_hours", list(range(8, 23)))
activity_level = cfg.get("activity_level", 0.5)
if current_hour not in active_hours:
continue
if random.random() < activity_level:
candidates.append(agent_id)
selected_ids = random.sample(
candidates,
min(target_count, len(candidates))
) if candidates else []
active_agents = []
for agent_id in selected_ids:
try:
agent = env.agent_graph.get_agent(agent_id)
active_agents.append((agent_id, agent))
except Exception:
pass
return active_agents
async def _process_batch(
self,
batch_agents: List[Tuple[int, Any]],
round_num: int,
simulated_hour: int,
) -> int:
"""
Process a batch of agents with caching
Returns:
Number of successful actions
"""
actions = {}
cache_hits = 0
for agent_id, agent in batch_agents:
# Build context for caching
context = {
"simulated_hour": simulated_hour,
"simulated_day": (round_num * 30 // 60 // 24) + 1,
"personality": self.config.get("agent_configs", [{}])[agent_id] if agent_id < len(self.config.get("agent_configs", [])) else {},
"visible_posts": [], # Would need to fetch from env
}
# Try cache first
if self.decision_cache and False: # Cache disabled for now (needs proper context)
cached = self.decision_cache.get(agent_id, context)
if cached:
cache_hits += 1
# Use cached decision
# Note: For now, we still call LLMAction but log cache hit
logger.debug(f"Cache hit for agent {agent_id}")
# Create action
actions[agent] = LLMAction()
# Execute batch
if actions:
await self.env.step(actions)
return len(actions)
async def run(self, max_rounds: int = None):
"""Run optimized Twitter simulation"""
print("=" * 60)
print("OPTIMIZED Twitter Simulation")
print(f"Config: {self.config_path}")
print(f"Simulation ID: {self.simulation_id}")
print(f"Features: caching={self.enable_caching}, streaming={self.enable_streaming}, checkpoint={self.enable_checkpoint}")
print("=" * 60)
# Load time config
time_config = self.config.get("time_config", {})
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
total_rounds = (total_hours * 60) // minutes_per_round
if max_rounds is not None and max_rounds > 0:
total_rounds = min(total_rounds, max_rounds)
print(f"\nSimulation Parameters:")
print(f" - Total simulation time: {total_hours} hours")
print(f" - Minutes per round: {minutes_per_round}")
print(f" - Total rounds: {total_rounds}")
print(f" - Batch size: {self.BATCH_SIZE}")
print(f" - Checkpoint interval: {self.CHECKPOINT_INTERVAL}")
if self.resume_round > 0:
print(f" - Resuming from round: {self.resume_round}")
# Create model
print("\nInitializing LLM model...")
model = self._create_model()
# Load agent graph
print("Loading Agent Profiles...")
profile_path = self._get_profile_path()
if not os.path.exists(profile_path):
print(f"Error: Profile file not found: {profile_path}")
return
self.agent_graph = await generate_twitter_agent_graph(
profile_path=profile_path,
model=model,
available_actions=[
ActionType.CREATE_POST,
ActionType.LIKE_POST,
ActionType.REPOST,
ActionType.FOLLOW,
ActionType.DO_NOTHING,
ActionType.QUOTE_POST,
],
)
# Create environment
db_path = self._get_db_path()
if os.path.exists(db_path):
os.remove(db_path)
print(f"Removed old database: {db_path}")
print("Creating OASIS environment...")
self.env = oasis.make(
agent_graph=self.agent_graph,
platform=oasis.DefaultPlatformType.TWITTER,
database_path=db_path,
semaphore=30,
)
await self.env.reset()
print("Environment initialized\n")
# Broadcast simulation start
if self.broadcaster:
self.broadcaster.simulation_start(total_rounds, len(self.config.get("agent_configs", [])))
# Main simulation loop
print("Starting simulation loop...")
start_time = datetime.now()
total_actions = 0
for round_num in range(self.resume_round, total_rounds):
# Calculate simulated time
simulated_minutes = round_num * minutes_per_round
simulated_hour = (simulated_minutes // 60) % 24
simulated_day = simulated_minutes // (60 * 24) + 1
# Get active agents
active_agents = self._get_active_agents_for_round(
self.env, simulated_hour, round_num
)
if not active_agents:
continue
# Process in batches
for batch_start in range(0, len(active_agents), self.BATCH_SIZE):
batch_end = min(batch_start + self.BATCH_SIZE, len(active_agents))
batch_agents = active_agents[batch_start:batch_end]
try:
actions_count = await self._process_batch(
batch_agents, round_num, simulated_hour
)
total_actions += actions_count
# Small delay between batches
if batch_end < len(active_agents):
await asyncio.sleep(0.3)
except Exception as e:
logger.error(f"Batch error at round {round_num}: {e}")
continue
# Broadcast round complete
if self.broadcaster:
self.broadcaster.round_complete(
round_num=round_num + 1,
simulated_hour=simulated_hour,
simulated_day=simulated_day,
twitter_actions=total_actions,
progress_percent=(round_num + 1) / total_rounds * 100,
)
# Save checkpoint
if self.enable_checkpoint and (round_num + 1) % self.CHECKPOINT_INTERVAL == 0:
checkpoint_state = {
"round_num": round_num + 1,
"total_actions": total_actions,
"simulated_hour": simulated_hour,
}
CheckpointManager.save_checkpoint(
simulation_id=self.simulation_id,
round_num=round_num + 1,
state=checkpoint_state,
platform="twitter",
)
if self.broadcaster:
self.broadcaster.checkpoint_saved(round_num + 1)
print(f" Checkpoint saved at round {round_num + 1}")
# Memory cleanup
if round_num % self.MEMORY_CLEANUP_INTERVAL == 0:
gc.collect()
# Progress logging
if (round_num + 1) % 10 == 0 or round_num == 0:
elapsed = (datetime.now() - start_time).total_seconds()
progress = (round_num + 1) / total_rounds * 100
print(f" [Day {simulated_day}, {simulated_hour:02d}:00] "
f"Round {round_num + 1}/{total_rounds} ({progress:.1f}%) "
f"- {len(active_agents)} agents active "
f"- elapsed: {elapsed:.1f}s")
# Simulation complete
total_elapsed = (datetime.now() - start_time).total_seconds()
print(f"\nSimulation completed!")
print(f" - Total time: {total_elapsed:.1f}s")
print(f" - Total actions: {total_actions}")
print(f" - Database: {db_path}")
# Broadcast completion
if self.broadcaster:
self.broadcaster.simulation_complete(total_rounds, total_actions, total_elapsed)
# Save cache
if self.decision_cache:
self.decision_cache.save_to_disk(self.simulation_id)
stats = self.decision_cache.get_stats()
print(f" - Cache stats: {stats}")
# Close environment
await self.env.close()
print("Environment closed\n")
return {
"total_rounds": total_rounds,
"total_actions": total_actions,
"elapsed_seconds": total_elapsed,
}
async def main():
parser = argparse.ArgumentParser(description='Optimized Twitter Simulation')
parser.add_argument('--config', type=str, required=True, help='Config file path')
parser.add_argument('--max-rounds', type=int, default=None, help='Max rounds')
parser.add_argument('--no-wait', action='store_true', help='No wait after completion')
parser.add_argument('--no-cache', action='store_true', help='Disable caching')
parser.add_argument('--no-streaming', action='store_true', help='Disable streaming')
parser.add_argument('--no-checkpoint', action='store_true', help='Disable checkpoint')
args = parser.parse_args()
if not os.path.exists(args.config):
print(f"Error: Config file not found: {args.config}")
sys.exit(1)
runner = OptimizedTwitterSimulationRunner(
config_path=args.config,
wait_for_commands=not args.no_wait,
enable_caching=not args.no_cache,
enable_streaming=not args.no_streaming,
enable_checkpoint=not args.no_checkpoint,
)
result = await runner.run(max_rounds=args.max_rounds)
print(f"\nFinal result: {json.dumps(result, indent=2)}")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nSimulation interrupted")
except SystemExit:
pass
finally:
print("Simulation process exited")