""" OASIS simulation manager Manages parallel Twitter and Reddit dual-platform simulation Uses preset scripts + LLM-generated configuration parameters """ import os import json import shutil from typing import Dict, Any, List, Optional from dataclasses import dataclass, field from datetime import datetime from enum import Enum from ..config import Config from ..utils.logger import get_logger from .entity_reader import EntityReader, FilteredEntities from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile from .simulation_config_generator import SimulationConfigGenerator, SimulationParameters from ..utils.locale import t logger = get_logger("mirofish.simulation") class SimulationStatus(str, Enum): """Simulation status""" CREATED = "created" PREPARING = "preparing" READY = "ready" RUNNING = "running" PAUSED = "paused" STOPPED = "stopped" # Simulation manually stopped COMPLETED = "completed" # Simulation naturally completed FAILED = "failed" class PlatformType(str, Enum): """Platform type""" TWITTER = "twitter" REDDIT = "reddit" @dataclass class SimulationState: """Simulation state""" simulation_id: str project_id: str graph_id: str # Platform enable status enable_twitter: bool = True enable_reddit: bool = True # Status status: SimulationStatus = SimulationStatus.CREATED # Preparation phase data entities_count: int = 0 profiles_count: int = 0 entity_types: List[str] = field(default_factory=list) # Config generation info config_generated: bool = False config_reasoning: str = "" # Runtime data current_round: int = 0 twitter_status: str = "not_started" reddit_status: str = "not_started" # Timestamps created_at: str = field(default_factory=lambda: datetime.now().isoformat()) updated_at: str = field(default_factory=lambda: datetime.now().isoformat()) # Error info error: Optional[str] = None def to_dict(self) -> Dict[str, Any]: """Full state dictionary (internal use)""" return { "simulation_id": self.simulation_id, "project_id": self.project_id, "graph_id": self.graph_id, "enable_twitter": self.enable_twitter, "enable_reddit": self.enable_reddit, "status": self.status.value, "entities_count": self.entities_count, "profiles_count": self.profiles_count, "entity_types": self.entity_types, "config_generated": self.config_generated, "config_reasoning": self.config_reasoning, "current_round": self.current_round, "twitter_status": self.twitter_status, "reddit_status": self.reddit_status, "created_at": self.created_at, "updated_at": self.updated_at, "error": self.error, } def to_simple_dict(self) -> Dict[str, Any]: """Simplified state dictionary (for API responses)""" return { "simulation_id": self.simulation_id, "project_id": self.project_id, "graph_id": self.graph_id, "status": self.status.value, "entities_count": self.entities_count, "profiles_count": self.profiles_count, "entity_types": self.entity_types, "config_generated": self.config_generated, "error": self.error, } class SimulationManager: """ Simulation manager Core features: 1. Read entities from the graph store and filter them 2. Generate OASIS Agent Profiles 3. Use LLM to intelligently generate simulation configuration parameters 4. Prepare all files required by the preset scripts """ # Simulation data storage directory SIMULATION_DATA_DIR = os.path.join( os.path.dirname(__file__), "../../uploads/simulations" ) def __init__(self): # Ensure directory exists os.makedirs(self.SIMULATION_DATA_DIR, exist_ok=True) # In-memory simulation state cache self._simulations: Dict[str, SimulationState] = {} def _get_simulation_dir(self, simulation_id: str) -> str: """Get the simulation data directory""" sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id) os.makedirs(sim_dir, exist_ok=True) return sim_dir def _save_simulation_state(self, state: SimulationState): """Save the simulation state to a file""" sim_dir = self._get_simulation_dir(state.simulation_id) state_file = os.path.join(sim_dir, "state.json") state.updated_at = datetime.now().isoformat() with open(state_file, "w", encoding="utf-8") as f: json.dump(state.to_dict(), f, ensure_ascii=False, indent=2) self._simulations[state.simulation_id] = state def _load_simulation_state(self, simulation_id: str) -> Optional[SimulationState]: """Load the simulation state from a file""" if simulation_id in self._simulations: return self._simulations[simulation_id] sim_dir = self._get_simulation_dir(simulation_id) state_file = os.path.join(sim_dir, "state.json") if not os.path.exists(state_file): return None with open(state_file, "r", encoding="utf-8") as f: data = json.load(f) state = SimulationState( simulation_id=simulation_id, project_id=data.get("project_id", ""), graph_id=data.get("graph_id", ""), enable_twitter=data.get("enable_twitter", True), enable_reddit=data.get("enable_reddit", True), status=SimulationStatus(data.get("status", "created")), entities_count=data.get("entities_count", 0), profiles_count=data.get("profiles_count", 0), entity_types=data.get("entity_types", []), config_generated=data.get("config_generated", False), config_reasoning=data.get("config_reasoning", ""), current_round=data.get("current_round", 0), twitter_status=data.get("twitter_status", "not_started"), reddit_status=data.get("reddit_status", "not_started"), created_at=data.get("created_at", datetime.now().isoformat()), updated_at=data.get("updated_at", datetime.now().isoformat()), error=data.get("error"), ) self._simulations[simulation_id] = state return state def create_simulation( self, project_id: str, graph_id: str, enable_twitter: bool = True, enable_reddit: bool = True, ) -> SimulationState: """ Create a new simulation Args: project_id: Project ID graph_id: graph store ID enable_twitter: Whether to enable Twitter simulation enable_reddit: Whether to enable Reddit simulation Returns: SimulationState """ import uuid simulation_id = f"sim_{uuid.uuid4().hex[:12]}" state = SimulationState( simulation_id=simulation_id, project_id=project_id, graph_id=graph_id, enable_twitter=enable_twitter, enable_reddit=enable_reddit, status=SimulationStatus.CREATED, ) self._save_simulation_state(state) logger.info( f"Created simulation: {simulation_id}, project={project_id}, graph={graph_id}" ) return state def prepare_simulation( self, simulation_id: str, simulation_requirement: str, document_text: str, defined_entity_types: Optional[List[str]] = None, use_llm_for_profiles: bool = True, progress_callback: Optional[callable] = None, parallel_profile_count: int = 3, ) -> SimulationState: """ Prepare the simulation environment (fully automated) Steps: 1. Read and filter entities from the graph store 2. Generate an OASIS Agent Profile for each entity (optional LLM enhancement, supports parallelism) 3. Use LLM to intelligently generate simulation configuration parameters (time, activity, posting frequency, etc.) 4. Save the configuration file and Profile files 5. Copy preset scripts to the simulation directory Args: simulation_id: Simulation ID simulation_requirement: Simulation requirement description (used for LLM configuration generation) document_text: Original document content (used for LLM to understand context) defined_entity_types: Predefined entity types (optional) use_llm_for_profiles: Whether to use LLM to generate detailed personas progress_callback: Progress callback function (stage, progress, message) parallel_profile_count: Number of personas to generate in parallel, default 3 Returns: SimulationState """ state = self._load_simulation_state(simulation_id) if not state: raise ValueError(t("api.simulationNotFound", id=simulation_id)) try: state.status = SimulationStatus.PREPARING self._save_simulation_state(state) sim_dir = self._get_simulation_dir(simulation_id) # ========== Phase 1: Read and filter entities ========== if progress_callback: progress_callback("reading", 0, t("progress.connectingGraph")) reader = EntityReader() if progress_callback: progress_callback("reading", 30, t("progress.readingNodeData")) filtered = reader.filter_defined_entities( graph_id=state.graph_id, defined_entity_types=defined_entity_types, enrich_with_edges=True, ) state.entities_count = filtered.filtered_count state.entity_types = list(filtered.entity_types) if progress_callback: progress_callback( "reading", 100, t("progress.readingComplete", count=filtered.filtered_count), current=filtered.filtered_count, total=filtered.filtered_count, ) if filtered.filtered_count == 0: state.status = SimulationStatus.FAILED state.error = "No matching entities found; please check whether the graph is correctly built" self._save_simulation_state(state) return state # ========== Phase 2: Generate Agent Profiles ========== total_entities = len(filtered.entities) if progress_callback: progress_callback( "generating_profiles", 0, t("progress.startGenerating"), current=0, total=total_entities, ) # Pass graph_id to enable graph retrieval for richer context generator = OasisProfileGenerator(graph_id=state.graph_id) def profile_progress(current, total, msg): if progress_callback: progress_callback( "generating_profiles", int(current / total * 100), msg, current=current, total=total, item_name=msg, ) # Set real-time save file path (prefer Reddit JSON format) realtime_output_path = None realtime_platform = "reddit" if state.enable_reddit: realtime_output_path = os.path.join(sim_dir, "reddit_profiles.json") realtime_platform = "reddit" elif state.enable_twitter: realtime_output_path = os.path.join(sim_dir, "twitter_profiles.csv") realtime_platform = "twitter" profiles = generator.generate_profiles_from_entities( entities=filtered.entities, use_llm=use_llm_for_profiles, progress_callback=profile_progress, graph_id=state.graph_id, # Pass graph_id for graph retrieval parallel_count=parallel_profile_count, # Parallel generation count realtime_output_path=realtime_output_path, # Real-time save path output_platform=realtime_platform, # Output format ) state.profiles_count = len(profiles) # Save Profile files (note: Twitter uses CSV format, Reddit uses JSON format) # Reddit was already saved in real time during generation; save again here to ensure completeness if progress_callback: progress_callback( "generating_profiles", 95, t("progress.savingProfiles"), current=total_entities, total=total_entities, ) if state.enable_reddit: generator.save_profiles( profiles=profiles, file_path=os.path.join(sim_dir, "reddit_profiles.json"), platform="reddit", ) if state.enable_twitter: # Twitter uses CSV format! This is required by OASIS generator.save_profiles( profiles=profiles, file_path=os.path.join(sim_dir, "twitter_profiles.csv"), platform="twitter", ) if progress_callback: progress_callback( "generating_profiles", 100, t("progress.profilesComplete", count=len(profiles)), current=len(profiles), total=len(profiles), ) # ========== Phase 3: LLM intelligent simulation config generation ========== if progress_callback: progress_callback( "generating_config", 0, t("progress.analyzingRequirements"), current=0, total=3, ) config_generator = SimulationConfigGenerator() if progress_callback: progress_callback( "generating_config", 30, t("progress.callingLLMConfig"), current=1, total=3, ) sim_params = config_generator.generate_config( simulation_id=simulation_id, project_id=state.project_id, graph_id=state.graph_id, simulation_requirement=simulation_requirement, document_text=document_text, entities=filtered.entities, enable_twitter=state.enable_twitter, enable_reddit=state.enable_reddit, ) if progress_callback: progress_callback( "generating_config", 70, t("progress.savingConfigFiles"), current=2, total=3, ) # Save config files config_path = os.path.join(sim_dir, "simulation_config.json") with open(config_path, "w", encoding="utf-8") as f: f.write(sim_params.to_json()) state.config_generated = True state.config_reasoning = sim_params.generation_reasoning if progress_callback: progress_callback( "generating_config", 100, t("progress.configComplete"), current=3, total=3, ) # Note: run scripts stay in backend/scripts/, no longer copied to simulation dir # When starting simulation, simulation_runner runs scripts from scripts/ dir # Update state state.status = SimulationStatus.READY self._save_simulation_state(state) logger.info( f"Simulation preparation complete: {simulation_id}, " f"entities={state.entities_count}, profiles={state.profiles_count}" ) return state except Exception as e: logger.error(f"Simulation preparation failed: {simulation_id}, error={str(e)}") import traceback logger.error(traceback.format_exc()) state.status = SimulationStatus.FAILED state.error = str(e) self._save_simulation_state(state) raise def get_simulation(self, simulation_id: str) -> Optional[SimulationState]: """Get simulation state""" return self._load_simulation_state(simulation_id) def list_simulations( self, project_id: Optional[str] = None ) -> List[SimulationState]: """List all simulations""" simulations = [] if os.path.exists(self.SIMULATION_DATA_DIR): for sim_id in os.listdir(self.SIMULATION_DATA_DIR): # Skip hidden files (e.g. .DS_Store) and non-directory files sim_path = os.path.join(self.SIMULATION_DATA_DIR, sim_id) if sim_id.startswith(".") or not os.path.isdir(sim_path): continue state = self._load_simulation_state(sim_id) if state: if project_id is None or state.project_id == project_id: simulations.append(state) return simulations def get_profiles( self, simulation_id: str, platform: str = "reddit" ) -> List[Dict[str, Any]]: """Get the simulation's Agent Profile""" state = self._load_simulation_state(simulation_id) if not state: raise ValueError(t("api.simulationNotFound", id=simulation_id)) sim_dir = self._get_simulation_dir(simulation_id) profile_path = os.path.join(sim_dir, f"{platform}_profiles.json") if not os.path.exists(profile_path): return [] with open(profile_path, "r", encoding="utf-8") as f: return json.load(f) def get_simulation_config(self, simulation_id: str) -> Optional[Dict[str, Any]]: """Get the simulation configuration""" sim_dir = self._get_simulation_dir(simulation_id) config_path = os.path.join(sim_dir, "simulation_config.json") if not os.path.exists(config_path): return None with open(config_path, "r", encoding="utf-8") as f: return json.load(f) def get_run_instructions(self, simulation_id: str) -> Dict[str, str]: """Get run instructions""" sim_dir = self._get_simulation_dir(simulation_id) config_path = os.path.join(sim_dir, "simulation_config.json") scripts_dir = os.path.abspath( os.path.join(os.path.dirname(__file__), "../../scripts") ) return { "simulation_dir": sim_dir, "scripts_dir": scripts_dir, "config_file": config_path, "commands": { "twitter": f"python {scripts_dir}/run_twitter_simulation.py --config {config_path}", "reddit": f"python {scripts_dir}/run_reddit_simulation.py --config {config_path}", "parallel": f"python {scripts_dir}/run_parallel_simulation.py --config {config_path}", }, "instructions": ( f"1. Activate conda environment: conda activate MiroFish\n" f"2. Run simulation (scripts located at {scripts_dir}):\n" f" - Run Twitter only: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\n" f" - Run Reddit only: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\n" f" - Run both platforms in parallel: python {scripts_dir}/run_parallel_simulation.py --config {config_path}" ), }