""" Simulation-related API routes Step2: Entity reading and filtering, OASIS simulation preparation and execution (fully automated) """ import os import traceback from typing import Optional from flask import request, jsonify, send_file from . import simulation_bp from ..config import Config from ..services.entity_reader import EntityReader from ..services.oasis_profile_generator import OasisProfileGenerator from ..services.simulation_manager import SimulationManager, SimulationStatus from ..services.simulation_runner import SimulationRunner, RunnerStatus from ..utils.logger import get_logger from ..utils.locale import t, get_locale, set_locale from ..models.project import ProjectManager logger = get_logger("mirofish.api.simulation") # Interview prompt optimization prefix # Adding this prefix prevents the Agent from calling tools and makes it reply with text directly INTERVIEW_PROMPT_PREFIX = "Based on your persona, all past memories and actions, reply to me with text directly without calling any tools: " def optimize_interview_prompt(prompt: str) -> str: """ Optimize the interview prompt by adding a prefix to prevent the Agent from calling tools Args: prompt: Original prompt Returns: Optimized prompt """ if not prompt: return prompt # Avoid adding the prefix repeatedly if prompt.startswith(INTERVIEW_PROMPT_PREFIX): return prompt return f"{INTERVIEW_PROMPT_PREFIX}{prompt}" # ============== Entity reading endpoints ============== @simulation_bp.route("/entities/", methods=["GET"]) def get_graph_entities(graph_id: str): """ Get all entities in the graph (filtered) Only returns nodes matching predefined entity types (nodes whose Labels are not just Entity) Query parameters: entity_types: Comma-separated list of entity types (optional, for further filtering) enrich: Whether to fetch related edge information (default true) """ try: entity_types_str = request.args.get("entity_types", "") entity_types = ( [t.strip() for t in entity_types_str.split(",") if t.strip()] if entity_types_str else None ) enrich = request.args.get("enrich", "true").lower() == "true" logger.info( f"Getting graph entities: graph_id={graph_id}, entity_types={entity_types}, enrich={enrich}" ) reader = EntityReader() result = reader.filter_defined_entities( graph_id=graph_id, defined_entity_types=entity_types, enrich_with_edges=enrich, ) return jsonify({"success": True, "data": result.to_dict()}) except Exception as e: logger.error(f"Failed to get graph entities: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/entities//", methods=["GET"]) def get_entity_detail(graph_id: str, entity_uuid: str): """Get details of a single entity""" try: reader = EntityReader() entity = reader.get_entity_with_context(graph_id, entity_uuid) if not entity: return jsonify( {"success": False, "error": t("api.entityNotFound", id=entity_uuid)} ), 404 return jsonify({"success": True, "data": entity.to_dict()}) except Exception as e: logger.error(f"Failed to get entity details: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/entities//by-type/", methods=["GET"]) def get_entities_by_type(graph_id: str, entity_type: str): """Get all entities of a specified type""" try: enrich = request.args.get("enrich", "true").lower() == "true" reader = EntityReader() entities = reader.get_entities_by_type( graph_id=graph_id, entity_type=entity_type, enrich_with_edges=enrich ) return jsonify( { "success": True, "data": { "entity_type": entity_type, "count": len(entities), "entities": [e.to_dict() for e in entities], }, } ) except Exception as e: logger.error(f"Failed to get entities: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 # ============== Simulation management endpoints ============== @simulation_bp.route("/create", methods=["POST"]) def create_simulation(): """ Create a new simulation Note: Parameters such as max_rounds are intelligently generated by the LLM; no manual setup is required Request (JSON): { "project_id": "proj_xxxx", // required "graph_id": "mirofish_xxxx", // optional; if not provided, obtained from the project "enable_twitter": true, // optional, default true "enable_reddit": true // optional, default true } Returns: { "success": true, "data": { "simulation_id": "sim_xxxx", "project_id": "proj_xxxx", "graph_id": "mirofish_xxxx", "status": "created", "enable_twitter": true, "enable_reddit": true, "created_at": "2025-12-01T10:00:00" } } """ try: data = request.get_json() or {} project_id = data.get("project_id") if not project_id: return jsonify({"success": False, "error": t("api.requireProjectId")}), 400 project = ProjectManager.get_project(project_id) if not project: return jsonify( {"success": False, "error": t("api.projectNotFound", id=project_id)} ), 404 graph_id = data.get("graph_id") or project.graph_id if not graph_id: return jsonify({"success": False, "error": t("api.graphNotBuilt")}), 400 manager = SimulationManager() state = manager.create_simulation( project_id=project_id, graph_id=graph_id, enable_twitter=data.get("enable_twitter", True), enable_reddit=data.get("enable_reddit", True), ) return jsonify({"success": True, "data": state.to_dict()}) except Exception as e: logger.error(f"Failed to create simulation: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 def _check_simulation_prepared(simulation_id: str) -> tuple: """ Check whether the simulation has finished preparing Checks: 1. state.json exists and status is "ready" 2. Required files exist: reddit_profiles.json, twitter_profiles.csv, simulation_config.json Note: The run scripts (run_*.py) remain in the backend/scripts/ directory and are no longer copied to the simulation directory Args: simulation_id: Simulation ID Returns: (is_prepared: bool, info: dict) """ import os from ..config import Config simulation_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) # Check if directory exists if not os.path.exists(simulation_dir): return False, {"reason": t("api.simDirNotExist")} # Required files list (excluding scripts; scripts are in backend/scripts/) required_files = [ "state.json", "simulation_config.json", "reddit_profiles.json", "twitter_profiles.csv", ] # Check if files exist existing_files = [] missing_files = [] for f in required_files: file_path = os.path.join(simulation_dir, f) if os.path.exists(file_path): existing_files.append(f) else: missing_files.append(f) if missing_files: return False, { "reason": t("api.simMissingFiles"), "missing_files": missing_files, "existing_files": existing_files, } # Check status in state.json state_file = os.path.join(simulation_dir, "state.json") try: import json with open(state_file, "r", encoding="utf-8") as f: state_data = json.load(f) status = state_data.get("status", "") config_generated = state_data.get("config_generated", False) # Detailed log logger.debug( f"Checking simulation preparation status: {simulation_id}, status={status}, config_generated={config_generated}" ) # If config_generated=True and files exist, consider preparation complete # The following statuses all indicate preparation work is done: # - ready: preparation complete, can run # - preparing: if config_generated=True, preparation is done # - running: currently running, preparation was done earlier # - completed: run completed, preparation was done earlier # - stopped: stopped, preparation was done earlier # - failed: run failed (but preparation was done) prepared_statuses = [ "ready", "preparing", "running", "completed", "stopped", "failed", ] if status in prepared_statuses and config_generated: # Get file statistics profiles_file = os.path.join(simulation_dir, "reddit_profiles.json") config_file = os.path.join(simulation_dir, "simulation_config.json") profiles_count = 0 if os.path.exists(profiles_file): with open(profiles_file, "r", encoding="utf-8") as f: profiles_data = json.load(f) profiles_count = ( len(profiles_data) if isinstance(profiles_data, list) else 0 ) # If status is preparing but files are done, auto-update status to ready if status == "preparing": try: state_data["status"] = "ready" from datetime import datetime state_data["updated_at"] = datetime.now().isoformat() with open(state_file, "w", encoding="utf-8") as f: json.dump(state_data, f, ensure_ascii=False, indent=2) logger.info( f"Auto-updating simulation status: {simulation_id} preparing -> ready" ) status = "ready" except Exception as e: logger.warning(f"Failed to auto-update status: {e}") logger.info( f"Simulation {simulation_id} check result: preparation complete (status={status}, config_generated={config_generated})" ) return True, { "status": status, "entities_count": state_data.get("entities_count", 0), "profiles_count": profiles_count, "entity_types": state_data.get("entity_types", []), "config_generated": config_generated, "created_at": state_data.get("created_at"), "updated_at": state_data.get("updated_at"), "existing_files": existing_files, } else: logger.warning( f"Simulation {simulation_id} check result: not prepared (status={status}, config_generated={config_generated})" ) return False, { "reason": t( "api.simNotPrepared", status=status, config_generated=config_generated, ), "status": status, "config_generated": config_generated, } except Exception as e: return False, {"reason": t("api.simStateReadFailed", error=str(e))} @simulation_bp.route("/prepare", methods=["POST"]) def prepare_simulation(): """ Prepare the simulation environment (async task; LLM intelligently generates all parameters) This is a time-consuming operation. The endpoint returns a task_id immediately; use GET /api/simulation/prepare/status to query progress Features: - Automatically detects completed preparation work to avoid duplicate generation - If already prepared, returns the existing result directly - Supports forced regeneration (force_regenerate=true) Steps: 1. Check whether preparation work has already been completed 2. Read and filter entities from the graph store 3. Generate an OASIS Agent Profile for each entity (with retry mechanism) 4. LLM intelligently generates the simulation configuration (with retry mechanism) 5. Save configuration files and preset scripts Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "entity_types": ["Student", "PublicFigure"], // optional, specified entity types "use_llm_for_profiles": true, // optional, whether to use LLM to generate personas "parallel_profile_count": 5, // optional, number of personas generated in parallel, default 5 "force_regenerate": false // optional, force regeneration, default false } Returns: { "success": true, "data": { "simulation_id": "sim_xxxx", "task_id": "task_xxxx", // returned for new tasks "status": "preparing|ready", "message": "Preparation task started|Preparation already completed", "already_prepared": true|false // whether already prepared } } """ import threading import os from ..models.task import TaskManager, TaskStatus from ..config import Config try: data = request.get_json() or {} simulation_id = data.get("simulation_id") if not simulation_id: return jsonify( {"success": False, "error": t("api.requireSimulationId")} ), 400 manager = SimulationManager() state = manager.get_simulation(simulation_id) if not state: return jsonify( { "success": False, "error": t("api.simulationNotFound", id=simulation_id), } ), 404 # Check if force regenerate force_regenerate = data.get("force_regenerate", False) logger.info( f"Processing /prepare request: simulation_id={simulation_id}, force_regenerate={force_regenerate}" ) # Check if already prepared (avoid duplicate generation) if not force_regenerate: logger.debug(f"Checking simulation {simulation_id} is already prepared...") is_prepared, prepare_info = _check_simulation_prepared(simulation_id) logger.debug( f"Check result: is_prepared={is_prepared}, prepare_info={prepare_info}" ) if is_prepared: logger.info(f"Simulation {simulation_id} already prepared, skipping duplicate generation") return jsonify( { "success": True, "data": { "simulation_id": simulation_id, "status": "ready", "message": t("api.alreadyPrepared"), "already_prepared": True, "prepare_info": prepare_info, }, } ) else: logger.info(f"Simulation {simulation_id} not prepared, will start preparation task") # Get required info from project project = ProjectManager.get_project(state.project_id) if not project: return jsonify( { "success": False, "error": t("api.projectNotFound", id=state.project_id), } ), 404 # Get simulation requirement simulation_requirement = project.simulation_requirement or "" if not simulation_requirement: return jsonify( {"success": False, "error": t("api.projectMissingRequirement")} ), 400 # Get document text document_text = ProjectManager.get_extracted_text(state.project_id) or "" entity_types_list = data.get("entity_types") use_llm_for_profiles = data.get("use_llm_for_profiles", True) parallel_profile_count = data.get("parallel_profile_count", 5) # ========== Synchronously get entity count (before background task starts) ========== # So frontend can immediately get expected Agent total after calling prepare try: logger.info(f"Synchronously getting entity count: graph_id={state.graph_id}") reader = EntityReader() # Quick entity read (no edge info needed, just count) filtered_preview = reader.filter_defined_entities( graph_id=state.graph_id, defined_entity_types=entity_types_list, enrich_with_edges=False, # Don't fetch edge info, speed up ) # Save entity count to state (for frontend immediate access) state.entities_count = filtered_preview.filtered_count state.entity_types = list(filtered_preview.entity_types) logger.info( f"Expected entity count: {filtered_preview.filtered_count}, types: {filtered_preview.entity_types}" ) except Exception as e: logger.warning(f"Synchronously getting entity count failed (will retry in background task): {e}") # Failure doesn't affect subsequent flow, background task will retry # Create async task task_manager = TaskManager() task_id = task_manager.create_task( task_type="simulation_prepare", metadata={"simulation_id": simulation_id, "project_id": state.project_id}, ) # Update simulation state (including pre-fetched entity count) state.status = SimulationStatus.PREPARING manager._save_simulation_state(state) # Capture locale before spawning background thread current_locale = get_locale() # Define background task def run_prepare(): set_locale(current_locale) try: task_manager.update_task( task_id, status=TaskStatus.PROCESSING, progress=0, message=t("progress.startPreparingEnv"), ) # Prepare simulation (with progress callback) # Store phase progress details stage_details = {} def progress_callback(stage, progress, message, **kwargs): # Calculate total progress stage_weights = { "reading": (0, 20), # 0-20% "generating_profiles": (20, 70), # 20-70% "generating_config": (70, 90), # 70-90% "copying_scripts": (90, 100), # 90-100% } start, end = stage_weights.get(stage, (0, 100)) current_progress = int(start + (end - start) * progress / 100) # Build detailed progress info stage_names = { "reading": t("progress.readingGraphEntities"), "generating_profiles": t("progress.generatingProfiles"), "generating_config": t("progress.generatingSimConfig"), "copying_scripts": t("progress.preparingScripts"), } stage_index = ( list(stage_weights.keys()).index(stage) + 1 if stage in stage_weights else 1 ) total_stages = len(stage_weights) # Update phase details stage_details[stage] = { "stage_name": stage_names.get(stage, stage), "stage_progress": progress, "current": kwargs.get("current", 0), "total": kwargs.get("total", 0), "item_name": kwargs.get("item_name", ""), } # Build detailed progress info detail = stage_details[stage] progress_detail_data = { "current_stage": stage, "current_stage_name": stage_names.get(stage, stage), "stage_index": stage_index, "total_stages": total_stages, "stage_progress": progress, "current_item": detail["current"], "total_items": detail["total"], "item_description": message, } # Build concise message if detail["total"] > 0: detailed_message = ( f"[{stage_index}/{total_stages}] {stage_names.get(stage, stage)}: " f"{detail['current']}/{detail['total']} - {message}" ) else: detailed_message = f"[{stage_index}/{total_stages}] {stage_names.get(stage, stage)}: {message}" task_manager.update_task( task_id, progress=current_progress, message=detailed_message, progress_detail=progress_detail_data, ) result_state = manager.prepare_simulation( simulation_id=simulation_id, simulation_requirement=simulation_requirement, document_text=document_text, defined_entity_types=entity_types_list, use_llm_for_profiles=use_llm_for_profiles, progress_callback=progress_callback, parallel_profile_count=parallel_profile_count, ) # Task complete task_manager.complete_task( task_id, result=result_state.to_simple_dict() ) except Exception as e: logger.error(f"Simulation preparation failed: {str(e)}") task_manager.fail_task(task_id, str(e)) # Update simulation state to failed state = manager.get_simulation(simulation_id) if state: state.status = SimulationStatus.FAILED state.error = str(e) manager._save_simulation_state(state) # Start background thread thread = threading.Thread(target=run_prepare, daemon=True) thread.start() return jsonify( { "success": True, "data": { "simulation_id": simulation_id, "task_id": task_id, "status": "preparing", "message": t("api.prepareStarted"), "already_prepared": False, "expected_entities_count": state.entities_count, # Expected Agent total "entity_types": state.entity_types, # Entity type list }, } ) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 404 except Exception as e: logger.error(f"Failed to start preparation task: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/prepare/status", methods=["POST"]) def get_prepare_status(): """ Query the progress of a preparation task Supports two query modes: 1. Query an in-progress task's progress via task_id 2. Check whether preparation work has already been completed via simulation_id Request (JSON): { "task_id": "task_xxxx", // optional, the task_id returned by prepare "simulation_id": "sim_xxxx" // optional, simulation ID (used to check completed preparation) } Returns: { "success": true, "data": { "task_id": "task_xxxx", "status": "processing|completed|ready", "progress": 45, "message": "...", "already_prepared": true|false, // whether preparation is already complete "prepare_info": {...} // detailed info when already prepared } } """ from ..models.task import TaskManager try: data = request.get_json() or {} task_id = data.get("task_id") simulation_id = data.get("simulation_id") # If simulation_id provided, check if already prepared first if simulation_id: is_prepared, prepare_info = _check_simulation_prepared(simulation_id) if is_prepared: return jsonify( { "success": True, "data": { "simulation_id": simulation_id, "status": "ready", "progress": 100, "message": t("api.alreadyPrepared"), "already_prepared": True, "prepare_info": prepare_info, }, } ) # If no task_id, return error if not task_id: if simulation_id: # Has simulation_id but not prepared return jsonify( { "success": True, "data": { "simulation_id": simulation_id, "status": "not_started", "progress": 0, "message": t("api.notStartedPrepare"), "already_prepared": False, }, } ) return jsonify( {"success": False, "error": t("api.requireTaskOrSimId")} ), 400 task_manager = TaskManager() task = task_manager.get_task(task_id) if not task: # Task not found, but if simulation_id provided, check if already prepared if simulation_id: is_prepared, prepare_info = _check_simulation_prepared(simulation_id) if is_prepared: return jsonify( { "success": True, "data": { "simulation_id": simulation_id, "task_id": task_id, "status": "ready", "progress": 100, "message": t("api.taskCompletedPrepared"), "already_prepared": True, "prepare_info": prepare_info, }, } ) return jsonify( {"success": False, "error": t("api.taskNotFound", id=task_id)} ), 404 task_dict = task.to_dict() task_dict["already_prepared"] = False return jsonify({"success": True, "data": task_dict}) except Exception as e: logger.error(f"Failed to query task status: {str(e)}") return jsonify({"success": False, "error": str(e)}), 500 @simulation_bp.route("/", methods=["GET"]) def get_simulation(simulation_id: str): """Get simulation state""" try: manager = SimulationManager() state = manager.get_simulation(simulation_id) if not state: return jsonify( { "success": False, "error": t("api.simulationNotFound", id=simulation_id), } ), 404 result = state.to_dict() # If simulation is ready, attach run instructions if state.status == SimulationStatus.READY: result["run_instructions"] = manager.get_run_instructions(simulation_id) return jsonify({"success": True, "data": result}) except Exception as e: logger.error(f"Failed to get simulation status: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/list", methods=["GET"]) def list_simulations(): """ List all simulations Query parameters: project_id: Filter by project ID (optional) """ try: project_id = request.args.get("project_id") manager = SimulationManager() simulations = manager.list_simulations(project_id=project_id) return jsonify( { "success": True, "data": [s.to_dict() for s in simulations], "count": len(simulations), } ) except Exception as e: logger.error(f"Failed to list simulations: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 def _get_report_id_for_simulation(simulation_id: str) -> Optional[str]: """ Get the latest report_id for a simulation Iterates through the reports directory to find reports whose simulation_id matches; if there are multiple, returns the latest one (sorted by created_at) Args: simulation_id: Simulation ID Returns: report_id or None """ import json from datetime import datetime # Reports directory path: backend/uploads/reports # __file__ is app/api/simulation.py, need to go up two levels to backend/ reports_dir = os.path.join(os.path.dirname(__file__), "../../uploads/reports") if not os.path.exists(reports_dir): return None matching_reports = [] try: for report_folder in os.listdir(reports_dir): report_path = os.path.join(reports_dir, report_folder) if not os.path.isdir(report_path): continue meta_file = os.path.join(report_path, "meta.json") if not os.path.exists(meta_file): continue try: with open(meta_file, "r", encoding="utf-8") as f: meta = json.load(f) if meta.get("simulation_id") == simulation_id: matching_reports.append( { "report_id": meta.get("report_id"), "created_at": meta.get("created_at", ""), "status": meta.get("status", ""), } ) except Exception: continue if not matching_reports: return None # Sort by creation time descending, return newest matching_reports.sort(key=lambda x: x.get("created_at", ""), reverse=True) return matching_reports[0].get("report_id") except Exception as e: logger.warning(f"Finding simulation {simulation_id} report failed: {e}") return None @simulation_bp.route("/history", methods=["GET"]) def get_simulation_history(): """ Get the history simulation list (with project details) Used for the homepage history project display; returns a simulation list enriched with project name, description, and other information Query parameters: limit: Return count limit (default 20) Returns: { "success": true, "data": [ { "simulation_id": "sim_xxxx", "project_id": "proj_xxxx", "project_name": "Wuhan University public opinion analysis", "simulation_requirement": "If Wuhan University releases...", "status": "completed", "entities_count": 68, "profiles_count": 68, "entity_types": ["Student", "Professor", ...], "created_at": "2024-12-10", "updated_at": "2024-12-10", "total_rounds": 120, "current_round": 120, "report_id": "report_xxxx", "version": "v1.0.2" }, ... ], "count": 7 } """ try: limit = request.args.get("limit", 20, type=int) manager = SimulationManager() simulations = manager.list_simulations()[:limit] # Enhance simulation data, read only from Simulation files enriched_simulations = [] for sim in simulations: sim_dict = sim.to_dict() # Get simulation config info (read simulation_requirement from simulation_config.json) config = manager.get_simulation_config(sim.simulation_id) if config: sim_dict["simulation_requirement"] = config.get( "simulation_requirement", "" ) time_config = config.get("time_config", {}) sim_dict["total_simulation_hours"] = time_config.get( "total_simulation_hours", 0 ) # Recommended rounds (fallback value) recommended_rounds = int( time_config.get("total_simulation_hours", 0) * 60 / max(time_config.get("minutes_per_round", 60), 1) ) else: sim_dict["simulation_requirement"] = "" sim_dict["total_simulation_hours"] = 0 recommended_rounds = 0 # Get run state (read user-set actual rounds from run_state.json) run_state = SimulationRunner.get_run_state(sim.simulation_id) if run_state: sim_dict["current_round"] = run_state.current_round sim_dict["runner_status"] = run_state.runner_status.value # Use user-set total_rounds, fallback to recommended rounds sim_dict["total_rounds"] = ( run_state.total_rounds if run_state.total_rounds > 0 else recommended_rounds ) else: sim_dict["current_round"] = 0 sim_dict["runner_status"] = "idle" sim_dict["total_rounds"] = recommended_rounds # Get associated project file list (max 3) project = ProjectManager.get_project(sim.project_id) if project and hasattr(project, "files") and project.files: sim_dict["files"] = [ {"filename": f.get("filename", "Unknown file")} for f in project.files[:3] ] else: sim_dict["files"] = [] # Get associated report_id (find newest report for this simulation) sim_dict["report_id"] = _get_report_id_for_simulation(sim.simulation_id) # Add version number sim_dict["version"] = "v1.0.2" # Format date try: created_date = sim_dict.get("created_at", "")[:10] sim_dict["created_date"] = created_date except: sim_dict["created_date"] = "" enriched_simulations.append(sim_dict) return jsonify( { "success": True, "data": enriched_simulations, "count": len(enriched_simulations), } ) except Exception as e: logger.error(f"Failed to get simulation history: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("//profiles", methods=["GET"]) def get_simulation_profiles(simulation_id: str): """ Get the simulation's Agent Profile Query parameters: platform: Platform type (reddit/twitter, default reddit) """ try: platform = request.args.get("platform", "reddit") manager = SimulationManager() profiles = manager.get_profiles(simulation_id, platform=platform) return jsonify( { "success": True, "data": { "platform": platform, "count": len(profiles), "profiles": profiles, }, } ) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 404 except Exception as e: logger.error(f"Failed to get Profile: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("//profiles/realtime", methods=["GET"]) def get_simulation_profiles_realtime(simulation_id: str): """ Get the simulation's Agent Profile in real time (used to view progress during generation) Differences from the /profiles endpoint: - Reads the file directly, without going through SimulationManager - Suitable for real-time viewing during generation - Returns additional metadata (such as file modification time, whether generation is in progress) Query parameters: platform: Platform type (reddit/twitter, default reddit) Returns: { "success": true, "data": { "simulation_id": "sim_xxxx", "platform": "reddit", "count": 15, "total_expected": 93, // expected total (if any) "is_generating": true, // whether generation is in progress "file_exists": true, "file_modified_at": "2025-12-04T18:20:00", "profiles": [...] } } """ import json import csv from datetime import datetime try: platform = request.args.get("platform", "reddit") # Get simulation directory sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) if not os.path.exists(sim_dir): return jsonify( { "success": False, "error": t("api.simulationNotFound", id=simulation_id), } ), 404 # Determine file path if platform == "reddit": profiles_file = os.path.join(sim_dir, "reddit_profiles.json") else: profiles_file = os.path.join(sim_dir, "twitter_profiles.csv") # Check if file exists file_exists = os.path.exists(profiles_file) profiles = [] file_modified_at = None if file_exists: # Get file modification time file_stat = os.stat(profiles_file) file_modified_at = datetime.fromtimestamp(file_stat.st_mtime).isoformat() try: if platform == "reddit": with open(profiles_file, "r", encoding="utf-8") as f: profiles = json.load(f) else: with open(profiles_file, "r", encoding="utf-8") as f: reader = csv.DictReader(f) profiles = list(reader) except (json.JSONDecodeError, Exception) as e: logger.warning(f"Failed to read profiles file (may be writing): {e}") profiles = [] # Check if generating (via state.json) is_generating = False total_expected = None state_file = os.path.join(sim_dir, "state.json") if os.path.exists(state_file): try: with open(state_file, "r", encoding="utf-8") as f: state_data = json.load(f) status = state_data.get("status", "") is_generating = status == "preparing" total_expected = state_data.get("entities_count") except Exception: pass return jsonify( { "success": True, "data": { "simulation_id": simulation_id, "platform": platform, "count": len(profiles), "total_expected": total_expected, "is_generating": is_generating, "file_exists": file_exists, "file_modified_at": file_modified_at, "profiles": profiles, }, } ) except Exception as e: logger.error(f"Real-time Profile fetch failed: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("//config/realtime", methods=["GET"]) def get_simulation_config_realtime(simulation_id: str): """ Get the simulation configuration in real time (used to view progress during generation) Differences from the /config endpoint: - Reads the file directly, without going through SimulationManager - Suitable for real-time viewing during generation - Returns additional metadata (such as file modification time, whether generation is in progress) - Can return partial info even before the configuration is fully generated Returns: { "success": true, "data": { "simulation_id": "sim_xxxx", "file_exists": true, "file_modified_at": "2025-12-04T18:20:00", "is_generating": true, // whether generation is in progress "generation_stage": "generating_config", // current generation stage "config": {...} // configuration content (if it exists) } } """ import json from datetime import datetime try: # Get simulation directory sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) if not os.path.exists(sim_dir): return jsonify( { "success": False, "error": t("api.simulationNotFound", id=simulation_id), } ), 404 # Config file path config_file = os.path.join(sim_dir, "simulation_config.json") # Check if file exists file_exists = os.path.exists(config_file) config = None file_modified_at = None if file_exists: # Get file modification time file_stat = os.stat(config_file) file_modified_at = datetime.fromtimestamp(file_stat.st_mtime).isoformat() try: with open(config_file, "r", encoding="utf-8") as f: config = json.load(f) except (json.JSONDecodeError, Exception) as e: logger.warning(f"Failed to read config file (may be writing): {e}") config = None # Check if generating (via state.json) is_generating = False generation_stage = None config_generated = False state_file = os.path.join(sim_dir, "state.json") if os.path.exists(state_file): try: with open(state_file, "r", encoding="utf-8") as f: state_data = json.load(f) status = state_data.get("status", "") is_generating = status == "preparing" config_generated = state_data.get("config_generated", False) # Determine current phase if is_generating: if state_data.get("profiles_generated", False): generation_stage = "generating_config" else: generation_stage = "generating_profiles" elif status == "ready": generation_stage = "completed" except Exception: pass # Build return data response_data = { "simulation_id": simulation_id, "file_exists": file_exists, "file_modified_at": file_modified_at, "is_generating": is_generating, "generation_stage": generation_stage, "config_generated": config_generated, "config": config, } # If config exists, extract key statistics if config: response_data["summary"] = { "total_agents": len(config.get("agent_configs", [])), "simulation_hours": config.get("time_config", {}).get( "total_simulation_hours" ), "initial_posts_count": len( config.get("event_config", {}).get("initial_posts", []) ), "hot_topics_count": len( config.get("event_config", {}).get("hot_topics", []) ), "has_twitter_config": "twitter_config" in config, "has_reddit_config": "reddit_config" in config, "generated_at": config.get("generated_at"), "llm_model": config.get("llm_model"), } return jsonify({"success": True, "data": response_data}) except Exception as e: logger.error(f"Failed to get Config in real-time: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("//config", methods=["GET"]) def get_simulation_config(simulation_id: str): """ Get the simulation configuration (the complete configuration intelligently generated by the LLM) Returns: - time_config: Time configuration (simulation duration, rounds, peak/off-peak periods) - agent_configs: Activity configuration for each Agent (activity level, posting frequency, stance, etc.) - event_config: Event configuration (initial posts, hot topics) - platform_configs: Platform configuration - generation_reasoning: LLM's configuration reasoning explanation """ try: manager = SimulationManager() config = manager.get_simulation_config(simulation_id) if not config: return jsonify({"success": False, "error": t("api.configNotFound")}), 404 return jsonify({"success": True, "data": config}) except Exception as e: logger.error(f"Failed to get config: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("//config/download", methods=["GET"]) def download_simulation_config(simulation_id: str): """Download the simulation configuration file""" try: manager = SimulationManager() sim_dir = manager._get_simulation_dir(simulation_id) config_path = os.path.join(sim_dir, "simulation_config.json") if not os.path.exists(config_path): return jsonify( {"success": False, "error": t("api.configFileNotFound")} ), 404 return send_file( config_path, as_attachment=True, download_name="simulation_config.json" ) except Exception as e: logger.error(f"Failed to download config: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/script//download", methods=["GET"]) def download_simulation_script(script_name: str): """ Download a simulation run script file (generic scripts, located in backend/scripts/) Possible values for script_name: - run_twitter_simulation.py - run_reddit_simulation.py - run_parallel_simulation.py - action_logger.py """ try: # Scripts are in backend/scripts/ directory scripts_dir = os.path.abspath( os.path.join(os.path.dirname(__file__), "../../scripts") ) # Validate script name allowed_scripts = [ "run_twitter_simulation.py", "run_reddit_simulation.py", "run_parallel_simulation.py", "action_logger.py", ] if script_name not in allowed_scripts: return jsonify( { "success": False, "error": t( "api.unknownScript", name=script_name, allowed=allowed_scripts ), } ), 400 script_path = os.path.join(scripts_dir, script_name) if not os.path.exists(script_path): return jsonify( { "success": False, "error": t("api.scriptFileNotFound", name=script_name), } ), 404 return send_file(script_path, as_attachment=True, download_name=script_name) except Exception as e: logger.error(f"Failed to download script: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 # ============== Profile Generation API (standalone) ============== @simulation_bp.route("/generate-profiles", methods=["POST"]) def generate_profiles(): """ Directly generate an OASIS Agent Profile from the graph (without creating a simulation) Request (JSON): { "graph_id": "mirofish_xxxx", // required "entity_types": ["Student"], // optional "use_llm": true, // optional "platform": "reddit" // optional } """ try: data = request.get_json() or {} graph_id = data.get("graph_id") if not graph_id: return jsonify({"success": False, "error": t("api.requireGraphId")}), 400 entity_types = data.get("entity_types") use_llm = data.get("use_llm", True) platform = data.get("platform", "reddit") reader = EntityReader() filtered = reader.filter_defined_entities( graph_id=graph_id, defined_entity_types=entity_types, enrich_with_edges=True ) if filtered.filtered_count == 0: return jsonify( {"success": False, "error": t("api.noMatchingEntities")} ), 400 generator = OasisProfileGenerator() profiles = generator.generate_profiles_from_entities( entities=filtered.entities, use_llm=use_llm ) if platform == "reddit": profiles_data = [p.to_reddit_format() for p in profiles] elif platform == "twitter": profiles_data = [p.to_twitter_format() for p in profiles] else: profiles_data = [p.to_dict() for p in profiles] return jsonify( { "success": True, "data": { "platform": platform, "entity_types": list(filtered.entity_types), "count": len(profiles_data), "profiles": profiles_data, }, } ) except Exception as e: logger.error(f"Failed to generate Profile: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 # ============== Simulation Run Control API ============== @simulation_bp.route("/start", methods=["POST"]) def start_simulation(): """ Start running the simulation Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "platform": "parallel", // optional: twitter / reddit / parallel (default) "max_rounds": 100, // optional: max simulation rounds, used to truncate overly long simulations "enable_graph_memory_update": false, // optional: whether to dynamically update Agent activity into the graph store memory "force": false // optional: force restart (will stop the running simulation and clean up logs) } About the force parameter: - When enabled, if the simulation is running or has completed, it will first stop and clean up the run logs - Cleaned-up contents include: run_state.json, actions.jsonl, simulation.log, etc. - Configuration files (simulation_config.json) and profile files will not be cleaned up - Suitable for scenarios where the simulation needs to be rerun About enable_graph_memory_update: - When enabled, all Agent activities during the simulation (posting, commenting, liking, etc.) are updated to the graph store in real time - This lets the graph "remember" the simulation process for subsequent analysis or AI conversations - Requires the simulation's associated project to have a valid graph_id - Uses a batch update mechanism to reduce the number of API calls Returns: { "success": true, "data": { "simulation_id": "sim_xxxx", "runner_status": "running", "process_pid": 12345, "twitter_running": true, "reddit_running": true, "started_at": "2025-12-01T10:00:00", "graph_memory_update_enabled": true, // whether graph memory update is enabled "force_restarted": true // whether this is a forced restart } } """ try: data = request.get_json() or {} simulation_id = data.get("simulation_id") if not simulation_id: return jsonify( {"success": False, "error": t("api.requireSimulationId")} ), 400 platform = data.get("platform", "parallel") max_rounds = data.get("max_rounds") # Optional: max simulation rounds enable_graph_memory_update = data.get( "enable_graph_memory_update", False ) # Optional: whether to enable graph memory update force = data.get("force", False) # Optional: force restart # Validate max_rounds parameter if max_rounds is not None: try: max_rounds = int(max_rounds) if max_rounds <= 0: return jsonify( {"success": False, "error": t("api.maxRoundsPositive")} ), 400 except (ValueError, TypeError): return jsonify( {"success": False, "error": t("api.maxRoundsInvalid")} ), 400 if platform not in ["twitter", "reddit", "parallel"]: return jsonify( {"success": False, "error": t("api.invalidPlatform", platform=platform)} ), 400 # Check if simulation is ready manager = SimulationManager() state = manager.get_simulation(simulation_id) if not state: return jsonify( { "success": False, "error": t("api.simulationNotFound", id=simulation_id), } ), 404 force_restarted = False # Smart state handling: if preparation is complete, allow restart if state.status != SimulationStatus.READY: # Check if preparation is complete is_prepared, prepare_info = _check_simulation_prepared(simulation_id) if is_prepared: # Preparation complete, check if there's a running process if state.status == SimulationStatus.RUNNING: # Check if simulation process is actually running run_state = SimulationRunner.get_run_state(simulation_id) if run_state and run_state.runner_status.value == "running": # Process is indeed running if force: # Force mode: stop running simulation logger.info(f"Force mode: stopping running simulation {simulation_id}") try: SimulationRunner.stop_simulation(simulation_id) except Exception as e: logger.warning(f"Warning while stopping simulation: {str(e)}") else: return jsonify( { "success": False, "error": t("api.simRunningForceHint"), } ), 400 # If force mode, clean up run logs if force: logger.info(f"Force mode: cleaning simulation logs {simulation_id}") cleanup_result = SimulationRunner.cleanup_simulation_logs( simulation_id ) if not cleanup_result.get("success"): logger.warning( f"Warning while cleaning logs: {cleanup_result.get('errors')}" ) force_restarted = True # Process doesn't exist or has ended, reset status to ready logger.info( f"Simulation {simulation_id} preparation complete, resetting status to ready (was: {state.status.value})" ) state.status = SimulationStatus.READY manager._save_simulation_state(state) else: # Preparation not complete return jsonify( { "success": False, "error": t("api.simNotReady", status=state.status.value), } ), 400 # Get graph ID (for graph memory update) graph_id = None if enable_graph_memory_update: # Get graph_id from simulation state or project graph_id = state.graph_id if not graph_id: # Try getting from project project = ProjectManager.get_project(state.project_id) if project: graph_id = project.graph_id if not graph_id: return jsonify( {"success": False, "error": t("api.graphIdRequiredForMemory")} ), 400 logger.info( f"Graph memory update enabled: simulation_id={simulation_id}, graph_id={graph_id}" ) # Start simulation run_state = SimulationRunner.start_simulation( simulation_id=simulation_id, platform=platform, max_rounds=max_rounds, enable_graph_memory_update=enable_graph_memory_update, graph_id=graph_id, ) # Update simulation state state.status = SimulationStatus.RUNNING manager._save_simulation_state(state) response_data = run_state.to_dict() if max_rounds: response_data["max_rounds_applied"] = max_rounds response_data["graph_memory_update_enabled"] = enable_graph_memory_update response_data["force_restarted"] = force_restarted if enable_graph_memory_update: response_data["graph_id"] = graph_id return jsonify({"success": True, "data": response_data}) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 except Exception as e: logger.error(f"Failed to start simulation: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/stop", methods=["POST"]) def stop_simulation(): """ Stop the simulation Request (JSON): { "simulation_id": "sim_xxxx" // required, simulation ID } Returns: { "success": true, "data": { "simulation_id": "sim_xxxx", "runner_status": "stopped", "completed_at": "2025-12-01T12:00:00" } } """ try: data = request.get_json() or {} simulation_id = data.get("simulation_id") if not simulation_id: return jsonify( {"success": False, "error": t("api.requireSimulationId")} ), 400 run_state = SimulationRunner.stop_simulation(simulation_id) # Update simulation state manager = SimulationManager() state = manager.get_simulation(simulation_id) if state: state.status = SimulationStatus.PAUSED manager._save_simulation_state(state) return jsonify({"success": True, "data": run_state.to_dict()}) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 except Exception as e: logger.error(f"Failed to stop simulation: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 # ============== Real-time Status Monitoring API ============== @simulation_bp.route("//run-status", methods=["GET"]) def get_run_status(simulation_id: str): """ Get the real-time running status of the simulation (for frontend polling) Returns: { "success": true, "data": { "simulation_id": "sim_xxxx", "runner_status": "running", "current_round": 5, "total_rounds": 144, "progress_percent": 3.5, "simulated_hours": 2, "total_simulation_hours": 72, "twitter_running": true, "reddit_running": true, "twitter_actions_count": 150, "reddit_actions_count": 200, "total_actions_count": 350, "started_at": "2025-12-01T10:00:00", "updated_at": "2025-12-01T10:30:00" } } """ try: run_state = SimulationRunner.get_run_state(simulation_id) if not run_state: return jsonify( { "success": True, "data": { "simulation_id": simulation_id, "runner_status": "idle", "current_round": 0, "total_rounds": 0, "progress_percent": 0, "twitter_actions_count": 0, "reddit_actions_count": 0, "total_actions_count": 0, }, } ) return jsonify({"success": True, "data": run_state.to_dict()}) except Exception as e: logger.error(f"Failed to get run state: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("//run-status/detail", methods=["GET"]) def get_run_status_detail(simulation_id: str): """ Get the detailed running status of the simulation (including all actions) Used for the frontend to display real-time dynamics Query parameters: platform: Filter by platform (twitter/reddit, optional) Returns: { "success": true, "data": { "simulation_id": "sim_xxxx", "runner_status": "running", "current_round": 5, ... "all_actions": [ { "round_num": 5, "timestamp": "2025-12-01T10:30:00", "platform": "twitter", "agent_id": 3, "agent_name": "Agent Name", "action_type": "CREATE_POST", "action_args": {"content": "..."}, "result": null, "success": true }, ... ], "twitter_actions": [...], # all actions on the Twitter platform "reddit_actions": [...] # all actions on the Reddit platform } } """ try: run_state = SimulationRunner.get_run_state(simulation_id) platform_filter = request.args.get("platform") if not run_state: return jsonify( { "success": True, "data": { "simulation_id": simulation_id, "runner_status": "idle", "all_actions": [], "twitter_actions": [], "reddit_actions": [], }, } ) # Get full action list all_actions = SimulationRunner.get_all_actions( simulation_id=simulation_id, platform=platform_filter ) # Get actions by platform twitter_actions = ( SimulationRunner.get_all_actions( simulation_id=simulation_id, platform="twitter" ) if not platform_filter or platform_filter == "twitter" else [] ) reddit_actions = ( SimulationRunner.get_all_actions( simulation_id=simulation_id, platform="reddit" ) if not platform_filter or platform_filter == "reddit" else [] ) # Get current round actions (recent_actions only shows latest round) current_round = run_state.current_round recent_actions = ( SimulationRunner.get_all_actions( simulation_id=simulation_id, platform=platform_filter, round_num=current_round, ) if current_round > 0 else [] ) # Get basic status info result = run_state.to_dict() result["all_actions"] = [a.to_dict() for a in all_actions] result["twitter_actions"] = [a.to_dict() for a in twitter_actions] result["reddit_actions"] = [a.to_dict() for a in reddit_actions] result["rounds_count"] = len(run_state.rounds) # recent_actions only shows latest round content from both platforms result["recent_actions"] = [a.to_dict() for a in recent_actions] return jsonify({"success": True, "data": result}) except Exception as e: logger.error(f"Failed to get detailed status: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("//actions", methods=["GET"]) def get_simulation_actions(simulation_id: str): """ Get the history of Agent actions in the simulation Query parameters: limit: Return count (default 100) offset: Offset (default 0) platform: Filter by platform (twitter/reddit) agent_id: Filter by Agent ID round_num: Filter by round number Returns: { "success": true, "data": { "count": 100, "actions": [...] } } """ try: limit = request.args.get("limit", 100, type=int) offset = request.args.get("offset", 0, type=int) platform = request.args.get("platform") agent_id = request.args.get("agent_id", type=int) round_num = request.args.get("round_num", type=int) actions = SimulationRunner.get_actions( simulation_id=simulation_id, limit=limit, offset=offset, platform=platform, agent_id=agent_id, round_num=round_num, ) return jsonify( { "success": True, "data": { "count": len(actions), "actions": [a.to_dict() for a in actions], }, } ) except Exception as e: logger.error(f"Failed to get action history: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("//timeline", methods=["GET"]) def get_simulation_timeline(simulation_id: str): """ Get the simulation timeline (aggregated by round) Used for the frontend to display the progress bar and timeline view Query parameters: start_round: Starting round (default 0) end_round: Ending round (default all) Returns aggregated info for each round """ try: start_round = request.args.get("start_round", 0, type=int) end_round = request.args.get("end_round", type=int) timeline = SimulationRunner.get_timeline( simulation_id=simulation_id, start_round=start_round, end_round=end_round ) return jsonify( { "success": True, "data": {"rounds_count": len(timeline), "timeline": timeline}, } ) except Exception as e: logger.error(f"Failed to get timeline: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("//agent-stats", methods=["GET"]) def get_agent_stats(simulation_id: str): """ Get statistics for each Agent Used for the frontend to display Agent activity rankings, action distributions, etc. """ try: stats = SimulationRunner.get_agent_stats(simulation_id) return jsonify( {"success": True, "data": {"agents_count": len(stats), "stats": stats}} ) except Exception as e: logger.error(f"Failed to get Agent statistics: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 # ============== Database Query API ============== @simulation_bp.route("//posts", methods=["GET"]) def get_simulation_posts(simulation_id: str): """ Get posts from the simulation Query parameters: platform: Platform type (twitter/reddit) limit: Return count (default 50) offset: Offset Returns a list of posts (read from the SQLite database) """ try: platform = request.args.get("platform", "reddit") limit = request.args.get("limit", 50, type=int) offset = request.args.get("offset", 0, type=int) sim_dir = os.path.join( os.path.dirname(__file__), f"../../uploads/simulations/{simulation_id}" ) db_file = f"{platform}_simulation.db" db_path = os.path.join(sim_dir, db_file) if not os.path.exists(db_path): return jsonify( { "success": True, "data": { "platform": platform, "count": 0, "posts": [], "message": t("api.dbNotExist"), }, } ) import sqlite3 conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() try: cursor.execute( """ SELECT * FROM post ORDER BY created_at DESC LIMIT ? OFFSET ? """, (limit, offset), ) posts = [dict(row) for row in cursor.fetchall()] cursor.execute("SELECT COUNT(*) FROM post") total = cursor.fetchone()[0] except sqlite3.OperationalError: posts = [] total = 0 conn.close() return jsonify( { "success": True, "data": { "platform": platform, "total": total, "count": len(posts), "posts": posts, }, } ) except Exception as e: logger.error(f"Failed to get posts: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("//comments", methods=["GET"]) def get_simulation_comments(simulation_id: str): """ Get comments from the simulation (Reddit only) Query parameters: post_id: Filter by post ID (optional) limit: Return count offset: Offset """ try: post_id = request.args.get("post_id") limit = request.args.get("limit", 50, type=int) offset = request.args.get("offset", 0, type=int) sim_dir = os.path.join( os.path.dirname(__file__), f"../../uploads/simulations/{simulation_id}" ) db_path = os.path.join(sim_dir, "reddit_simulation.db") if not os.path.exists(db_path): return jsonify({"success": True, "data": {"count": 0, "comments": []}}) import sqlite3 conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() try: if post_id: cursor.execute( """ SELECT * FROM comment WHERE post_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ? """, (post_id, limit, offset), ) else: cursor.execute( """ SELECT * FROM comment ORDER BY created_at DESC LIMIT ? OFFSET ? """, (limit, offset), ) comments = [dict(row) for row in cursor.fetchall()] except sqlite3.OperationalError: comments = [] conn.close() return jsonify( {"success": True, "data": {"count": len(comments), "comments": comments}} ) except Exception as e: logger.error(f"Failed to get comments: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 # ============== Interview API ============== @simulation_bp.route("/interview", methods=["POST"]) def interview_agent(): """ Interview a single Agent Note: This feature requires the simulation environment to be running (after completing the simulation loop, it enters wait-for-command mode) Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "agent_id": 0, // required, Agent ID "prompt": "What do you think about this?", // required, interview question "platform": "twitter", // optional, specified platform (twitter/reddit) // if not specified: in dual-platform mode, both platforms are interviewed simultaneously "timeout": 60 // optional, timeout (seconds), default 60 } Returns (no platform specified, dual-platform mode): { "success": true, "data": { "agent_id": 0, "prompt": "What do you think about this?", "result": { "agent_id": 0, "prompt": "...", "platforms": { "twitter": {"agent_id": 0, "response": "...", "platform": "twitter"}, "reddit": {"agent_id": 0, "response": "...", "platform": "reddit"} } }, "timestamp": "2025-12-08T10:00:01" } } Returns (platform specified): { "success": true, "data": { "agent_id": 0, "prompt": "What do you think about this?", "result": { "agent_id": 0, "response": "I think...", "platform": "twitter", "timestamp": "2025-12-08T10:00:00" }, "timestamp": "2025-12-08T10:00:01" } } """ try: data = request.get_json() or {} simulation_id = data.get("simulation_id") agent_id = data.get("agent_id") prompt = data.get("prompt") platform = data.get("platform") # Optional: twitter/reddit/None timeout = data.get("timeout", 60) if not simulation_id: return jsonify( {"success": False, "error": t("api.requireSimulationId")} ), 400 if agent_id is None: return jsonify({"success": False, "error": t("api.requireAgentId")}), 400 if not prompt: return jsonify({"success": False, "error": t("api.requirePrompt")}), 400 # Validate platform parameter if platform and platform not in ("twitter", "reddit"): return jsonify( {"success": False, "error": t("api.invalidInterviewPlatform")} ), 400 # Check environment status if not SimulationRunner.check_env_alive(simulation_id): return jsonify({"success": False, "error": t("api.envNotRunning")}), 400 # Optimize prompt, add prefix to prevent Agent from calling tools optimized_prompt = optimize_interview_prompt(prompt) result = SimulationRunner.interview_agent( simulation_id=simulation_id, agent_id=agent_id, prompt=optimized_prompt, platform=platform, timeout=timeout, ) return jsonify({"success": result.get("success", False), "data": result}) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 except TimeoutError as e: return jsonify( {"success": False, "error": t("api.interviewTimeout", error=str(e))} ), 504 except Exception as e: logger.error(f"Interview failed: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/interview/batch", methods=["POST"]) def interview_agents_batch(): """ Batch interview multiple Agents Note: This feature requires the simulation environment to be running Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "interviews": [ // required, interview list { "agent_id": 0, "prompt": "What do you think about A?", "platform": "twitter" // optional, specifies the interview platform for this Agent }, { "agent_id": 1, "prompt": "What do you think about B?" // if platform is not specified, the default is used } ], "platform": "reddit", // optional, default platform (overridden by each item's platform) // if not specified: in dual-platform mode, each Agent is interviewed on both platforms simultaneously "timeout": 120 // optional, timeout (seconds), default 120 } Returns: { "success": true, "data": { "interviews_count": 2, "result": { "interviews_count": 4, "results": { "twitter_0": {"agent_id": 0, "response": "...", "platform": "twitter"}, "reddit_0": {"agent_id": 0, "response": "...", "platform": "reddit"}, "twitter_1": {"agent_id": 1, "response": "...", "platform": "twitter"}, "reddit_1": {"agent_id": 1, "response": "...", "platform": "reddit"} } }, "timestamp": "2025-12-08T10:00:01" } } """ try: data = request.get_json() or {} simulation_id = data.get("simulation_id") interviews = data.get("interviews") platform = data.get("platform") # Optional: twitter/reddit/None timeout = data.get("timeout", 120) if not simulation_id: return jsonify( {"success": False, "error": t("api.requireSimulationId")} ), 400 if not interviews or not isinstance(interviews, list): return jsonify({"success": False, "error": t("api.requireInterviews")}), 400 # Validate platform parameter if platform and platform not in ("twitter", "reddit"): return jsonify( {"success": False, "error": t("api.invalidInterviewPlatform")} ), 400 # Validate each interview item for i, interview in enumerate(interviews): if "agent_id" not in interview: return jsonify( { "success": False, "error": t("api.interviewListMissingAgentId", index=i + 1), } ), 400 if "prompt" not in interview: return jsonify( { "success": False, "error": t("api.interviewListMissingPrompt", index=i + 1), } ), 400 # Validate platform for each item (if present) item_platform = interview.get("platform") if item_platform and item_platform not in ("twitter", "reddit"): return jsonify( { "success": False, "error": t("api.interviewListInvalidPlatform", index=i + 1), } ), 400 # Check environment status if not SimulationRunner.check_env_alive(simulation_id): return jsonify({"success": False, "error": t("api.envNotRunning")}), 400 # Optimize each interview item's prompt, add prefix to prevent Agent from calling tools optimized_interviews = [] for interview in interviews: optimized_interview = interview.copy() optimized_interview["prompt"] = optimize_interview_prompt( interview.get("prompt", "") ) optimized_interviews.append(optimized_interview) result = SimulationRunner.interview_agents_batch( simulation_id=simulation_id, interviews=optimized_interviews, platform=platform, timeout=timeout, ) return jsonify({"success": result.get("success", False), "data": result}) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 except TimeoutError as e: return jsonify( {"success": False, "error": t("api.batchInterviewTimeout", error=str(e))} ), 504 except Exception as e: logger.error(f"Batch Interview failed: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/interview/all", methods=["POST"]) def interview_all_agents(): """ Global interview - interview all Agents with the same question Note: This feature requires the simulation environment to be running Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "prompt": "What do you think about this whole thing?", // required, interview question (same question for all Agents) "platform": "reddit", // optional, specified platform (twitter/reddit) // if not specified: in dual-platform mode, each Agent is interviewed on both platforms simultaneously "timeout": 180 // optional, timeout (seconds), default 180 } Returns: { "success": true, "data": { "interviews_count": 50, "result": { "interviews_count": 100, "results": { "twitter_0": {"agent_id": 0, "response": "...", "platform": "twitter"}, "reddit_0": {"agent_id": 0, "response": "...", "platform": "reddit"}, ... } }, "timestamp": "2025-12-08T10:00:01" } } """ try: data = request.get_json() or {} simulation_id = data.get("simulation_id") prompt = data.get("prompt") platform = data.get("platform") # Optional: twitter/reddit/None timeout = data.get("timeout", 180) if not simulation_id: return jsonify( {"success": False, "error": t("api.requireSimulationId")} ), 400 if not prompt: return jsonify({"success": False, "error": t("api.requirePrompt")}), 400 # Validate platform parameter if platform and platform not in ("twitter", "reddit"): return jsonify( {"success": False, "error": t("api.invalidInterviewPlatform")} ), 400 # Check environment status if not SimulationRunner.check_env_alive(simulation_id): return jsonify({"success": False, "error": t("api.envNotRunning")}), 400 # Optimize prompt, add prefix to prevent Agent from calling tools optimized_prompt = optimize_interview_prompt(prompt) result = SimulationRunner.interview_all_agents( simulation_id=simulation_id, prompt=optimized_prompt, platform=platform, timeout=timeout, ) return jsonify({"success": result.get("success", False), "data": result}) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 except TimeoutError as e: return jsonify( {"success": False, "error": t("api.globalInterviewTimeout", error=str(e))} ), 504 except Exception as e: logger.error(f"Global Interview failed: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/interview/history", methods=["POST"]) def get_interview_history(): """ Get interview history records Reads all interview records from the simulation database Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "platform": "reddit", // optional, platform type (reddit/twitter) // if not specified, returns all history for both platforms "agent_id": 0, // optional, only get this Agent's interview history "limit": 100 // optional, return count, default 100 } Returns: { "success": true, "data": { "count": 10, "history": [ { "agent_id": 0, "response": "I think...", "prompt": "What do you think about this?", "timestamp": "2025-12-08T10:00:00", "platform": "reddit" }, ... ] } } """ try: data = request.get_json() or {} simulation_id = data.get("simulation_id") platform = data.get("platform") # If not specified, return history from both platforms agent_id = data.get("agent_id") limit = data.get("limit", 100) if not simulation_id: return jsonify( {"success": False, "error": t("api.requireSimulationId")} ), 400 history = SimulationRunner.get_interview_history( simulation_id=simulation_id, platform=platform, agent_id=agent_id, limit=limit, ) return jsonify( {"success": True, "data": {"count": len(history), "history": history}} ) except Exception as e: logger.error(f"Failed to get Interview history: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/env-status", methods=["POST"]) def get_env_status(): """ Get the simulation environment status Checks whether the simulation environment is alive (can receive Interview commands) Request (JSON): { "simulation_id": "sim_xxxx" // required, simulation ID } Returns: { "success": true, "data": { "simulation_id": "sim_xxxx", "env_alive": true, "twitter_available": true, "reddit_available": true, "message": "Environment is running and can receive Interview commands" } } """ try: data = request.get_json() or {} simulation_id = data.get("simulation_id") if not simulation_id: return jsonify( {"success": False, "error": t("api.requireSimulationId")} ), 400 env_alive = SimulationRunner.check_env_alive(simulation_id) # Get more detailed status info env_status = SimulationRunner.get_env_status_detail(simulation_id) if env_alive: message = t("api.envRunning") else: message = t("api.envNotRunningShort") return jsonify( { "success": True, "data": { "simulation_id": simulation_id, "env_alive": env_alive, "twitter_available": env_status.get("twitter_available", False), "reddit_available": env_status.get("reddit_available", False), "message": message, }, } ) except Exception as e: logger.error(f"Failed to get environment status: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 @simulation_bp.route("/close-env", methods=["POST"]) def close_simulation_env(): """ Close the simulation environment Sends a close-environment command to the simulation so that it gracefully exits wait-for-command mode. Note: This differs from the /stop endpoint, which forcibly terminates the process; this endpoint lets the simulation gracefully close the environment and exit. Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "timeout": 30 // optional, timeout (seconds), default 30 } Returns: { "success": true, "data": { "message": "Environment close command sent", "result": {...}, "timestamp": "2025-12-08T10:00:01" } } """ try: data = request.get_json() or {} simulation_id = data.get("simulation_id") timeout = data.get("timeout", 30) if not simulation_id: return jsonify( {"success": False, "error": t("api.requireSimulationId")} ), 400 result = SimulationRunner.close_simulation_env( simulation_id=simulation_id, timeout=timeout ) # Update simulation state manager = SimulationManager() state = manager.get_simulation(simulation_id) if state: state.status = SimulationStatus.COMPLETED manager._save_simulation_state(state) return jsonify({"success": result.get("success", False), "data": result}) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 except Exception as e: logger.error(f"Failed to close environment: {str(e)}") return jsonify( {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500