""" Simulation-related API routes Step2: Zep entity reading & filtering, OASIS simulation prep & run (fully automated) """ import os import traceback from flask import request, jsonify, send_file from . import simulation_bp from ..config import Config from ..services.zep_entity_reader import ZepEntityReader 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 in plain text INTERVIEW_PROMPT_PREFIX = "Combine your persona, all past memories and actions, and reply directly in text without calling any tools: " def optimize_interview_prompt(prompt: str) -> str: """ Optimize the interview prompt by adding a prefix that prevents 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): """ Fetch all entities in the graph (already filtered) Only returns nodes whose type matches one of the predefined entity types (nodes whose labels are not just "Entity") Query params: entity_types: Comma-separated list of entity types to filter by (optional, further narrows the result) enrich: Whether to also fetch related edge info (default true) """ try: if not Config.FALKORDB_HOST: return jsonify({ "success": False, "error": t('api.zepApiKeyMissing') }), 500 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"Fetching graph entities: graph_id={graph_id}, entity_types={entity_types}, enrich={enrich}") reader = ZepEntityReader() 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 fetch 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): """Fetch the detailed information of a single entity""" try: if not Config.FALKORDB_HOST: return jsonify({ "success": False, "error": t('api.zepApiKeyMissing') }), 500 reader = ZepEntityReader() 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 fetch 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): """Fetch all entities of a specified type""" try: if not Config.FALKORDB_HOST: return jsonify({ "success": False, "error": t('api.zepApiKeyMissing') }), 500 enrich = request.args.get('enrich', 'true').lower() == 'true' reader = ZepEntityReader() 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 fetch 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 like max_rounds are generated intelligently by the LLM, no manual setup required Request (JSON): { "project_id": "proj_xxxx", // required "graph_id": "mirofish_xxxx", // optional, falls back to the project's value "enable_twitter": true, // optional, default true "enable_reddit": true // optional, default true } Response: { "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 a simulation has finished preparation Conditions to consider prepared: 1. state.json exists and status is "ready" 2. Required files exist: reddit_profiles.json, twitter_profiles.csv, simulation_config.json Note: the runner scripts (run_*.py) live in backend/scripts/ and are no longer copied into each 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 that the directory exists if not os.path.exists(simulation_dir): return False, {"reason": "Simulation directory does not exist"} # Required files (scripts live in backend/scripts/, not here) required_files = [ "state.json", "simulation_config.json", "reddit_profiles.json", "twitter_profiles.csv" ] # Check each file 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": "Missing required files", "missing_files": missing_files, "existing_files": existing_files } # Inspect 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 prep status: {simulation_id}, status={status}, config_generated={config_generated}") # If config_generated=True and the files exist, treat the simulation as prepared. # The following statuses all imply prep is done: # - ready: prep complete, ready to run # - preparing: prep is still running but config_generated=True means it's done # - running: already running, so prep was completed a while ago # - completed: run finished, so prep was completed a while ago # - stopped: stopped, so prep was completed a while ago # - failed: run failed, but prep itself was completed prepared_statuses = ["ready", "preparing", "running", "completed", "stopped", "failed"] if status in prepared_statuses and config_generated: # Collect a few file stats 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 complete, auto-promote 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-promoting simulation status: {simulation_id} preparing -> ready") status = "ready" except Exception as e: logger.warning(f"Auto-promote of status failed: {e}") logger.info(f"Simulation {simulation_id} check result: prep 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: prep not complete (status={status}, config_generated={config_generated})") return False, { "reason": f"Status is not in the prepared set or config_generated is false: status={status}, config_generated={config_generated}", "status": status, "config_generated": config_generated } except Exception as e: return False, {"reason": f"Failed to read state file: {str(e)}"} @simulation_bp.route('/prepare', methods=['POST']) def prepare_simulation(): """ Prepare the simulation environment (async task, LLM generates every parameter) This is a long-running operation, the endpoint immediately returns a task_id; use GET /api/simulation/prepare/status to poll progress. Features: - Auto-detects existing prep work to avoid regenerating - Returns the existing prep info directly if it is already complete - Supports force regeneration via force_regenerate=true Steps: 1. Check whether prep has already been completed 2. Read and filter entities from the Zep graph 3. Generate an OASIS Agent profile for each entity (with retry) 4. LLM-generate the simulation config (with retry) 5. Persist the config files and prebuilt scripts Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "entity_types": ["Student", "PublicFigure"], // optional, restrict entity types "use_llm_for_profiles": true, // optional, whether to use the LLM to build personas "parallel_profile_count": 5, // optional, number of personas to generate in parallel, default 5 "force_regenerate": false // optional, force regeneration, default false } Response: { "success": true, "data": { "simulation_id": "sim_xxxx", "task_id": "task_xxxx", // returned for a new task "status": "preparing|ready", "message": "Prepare task started|Existing prep already complete", "already_prepared": true|false // whether prep is already done } } """ 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 whether regeneration is forced force_regenerate = data.get('force_regenerate', False) logger.info(f"Processing /prepare request: simulation_id={simulation_id}, force_regenerate={force_regenerate}") # Check whether prep is already complete (to avoid regenerating) if not force_regenerate: logger.debug(f"Checking whether 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} is already prepared, skipping regeneration") 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} is not prepared yet, starting prep task") # Pull the required info from the 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 the simulation requirement simulation_requirement = project.simulation_requirement or "" if not simulation_requirement: return jsonify({ "success": False, "error": t('api.projectMissingRequirement') }), 400 # Get the 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) # ========== Fetch entity count synchronously (before the background task starts) ========== # This way the frontend can read the expected total agent count immediately after calling /prepare try: logger.info(f"Fetching entity count synchronously: graph_id={state.graph_id}") reader = ZepEntityReader() # Read entities quickly (no edge info, just a count) filtered_preview = reader.filter_defined_entities( graph_id=state.graph_id, defined_entity_types=entity_types_list, enrich_with_edges=False # skip edge info for speed ) # Save entity count to state (so the frontend can grab it immediately) 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"Synchronous entity count fetch failed (will retry in the background task): {e}") # A failure here does not block the flow; the background task will retry # Create the 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 the simulation state (with the pre-fetched entity count) state.status = SimulationStatus.PREPARING manager._save_simulation_state(state) # Capture locale before spawning background thread current_locale = get_locale() # Define the 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 the simulation (with progress callback) # Storage for per-stage progress details stage_details = {} def progress_callback(stage, progress, message, **kwargs): # Compute the overall 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 the detailed progress payload 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 per-stage detail 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 the 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 a compact status 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"Failed to prepare simulation: {str(e)}") task_manager.fail_task(task_id, str(e)) # Mark the simulation as failed state = manager.get_simulation(simulation_id) if state: state.status = SimulationStatus.FAILED state.error = str(e) manager._save_simulation_state(state) # Start the 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 total agent count "entity_types": state.entity_types # list of entity types } }) except ValueError as e: return jsonify({ "success": False, "error": str(e) }), 404 except Exception as e: logger.error(f"Failed to start prep 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 prep task progress Supports two query modes: 1. By task_id to follow the in-flight task progress 2. By simulation_id to check whether prep is already complete Request (JSON): { "task_id": "task_xxxx", // optional, the task_id returned by /prepare "simulation_id": "sim_xxxx" // optional, simulation ID (used to check for completed prep) } Response: { "success": true, "data": { "task_id": "task_xxxx", "status": "processing|completed|ready", "progress": 45, "message": "...", "already_prepared": true|false, // whether prep is already done "prepare_info": {...} // detailed info when prep is already done } } """ 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 is provided, first check whether prep is already complete 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 } }) # Without a task_id, return an error if not task_id: if simulation_id: # simulation_id given but prep is not done 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 does not exist, but if simulation_id is given, check for completed prep 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): """Fetch the simulation status""" 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 the simulation is ready, also 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 fetch 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 params: 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) -> str: """ Get the latest report_id for the given simulation Walks the reports directory, collects every report whose simulation_id matches, and returns the newest one (sorted by created_at). Args: simulation_id: Simulation ID Returns: The report_id, or None """ import json from datetime import datetime # reports directory: backend/uploads/reports # __file__ is app/api/simulation.py, so go up two levels to reach 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 created_at descending, return the 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"Failed to find report for simulation {simulation_id}: {e}") return None @simulation_bp.route('/history', methods=['GET']) def get_simulation_history(): """ Fetch the historical simulation list (with project details) Used by the home page to show recent projects; returns simulations enriched with project name, description and other useful fields. Query params: limit: Maximum number of items to return (default 20) Response: { "success": true, "data": [ { "simulation_id": "sim_xxxx", "project_id": "proj_xxxx", "project_name": "WHU Public Sentiment Analysis", "simulation_requirement": "If Wuhan University announces ...", "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] # Enrich simulation data, reading only from the Simulation files enriched_simulations = [] for sim in simulations: sim_dict = sim.to_dict() # Get the simulation config (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 round count (fallback) 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 the running state (read the user-configured total_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 the user-configured total_rounds; fall back to the recommended value 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 the file list of the linked project (up to 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 the associated report_id (find the newest report for this simulation) sim_dict["report_id"] = _get_report_id_for_simulation(sim.simulation_id) # Add the version number sim_dict["version"] = "v1.0.2" # Format the 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 fetch historical simulations: {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): """ Fetch the Agent profiles for a simulation Query params: 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 fetch profiles: {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): """ Real-time fetch of Agent profiles (for live progress while generating) Differences from /profiles: - Reads files directly, bypassing the SimulationManager - Designed for live progress while generation is in progress - Returns extra metadata (file mtime, whether generation is in progress, ...) Query params: platform: Platform type (reddit/twitter, default reddit) Response: { "success": true, "data": { "simulation_id": "sim_xxxx", "platform": "reddit", "count": 15, "total_expected": 93, // expected total (if known) "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') # Locate the 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 # Pick the right file 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 that the file exists file_exists = os.path.exists(profiles_file) profiles = [] file_modified_at = None if file_exists: # Read the file mtime 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 mid-write): {e}") profiles = [] # Detect whether generation is in progress (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"Failed to fetch realtime profiles: {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): """ Real-time fetch of the simulation config (for live progress while generating) Differences from /config: - Reads the file directly, bypassing the SimulationManager - Designed for live progress while generation is in progress - Returns extra metadata (file mtime, whether generation is in progress, ...) - Returns partial info even if config generation isn't finished yet Response: { "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": {...} // config payload (if it exists) } } """ import json from datetime import datetime try: # Locate the 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 # Path to the config file config_file = os.path.join(sim_dir, "simulation_config.json") # Check that the file exists file_exists = os.path.exists(config_file) config = None file_modified_at = None if file_exists: # Read the file mtime 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 mid-write): {e}") config = None # Detect whether generation is in progress (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) # Decide the current stage 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 the response payload 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, surface a few 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 fetch realtime config: {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): """ Fetch the full LLM-generated simulation config Returns: - time_config: time config (duration, rounds, peak/off-peak hours) - agent_configs: per-agent activity config (activity level, frequency, stance, ...) - event_config: event config (initial posts, hot topics) - platform_configs: platform-specific config - generation_reasoning: the LLM's reasoning notes """ 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 fetch 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 config 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 runner script (shared script, lives in backend/scripts/) Allowed script_name values: - run_twitter_simulation.py - run_reddit_simulation.py - run_parallel_simulation.py - action_logger.py """ try: # The script lives under backend/scripts/ scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts')) # Whitelist of allowed script names 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 # ============== Standalone profile generation endpoint ============== @simulation_bp.route('/generate-profiles', methods=['POST']) def generate_profiles(): """ Generate OASIS Agent profiles directly 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 = ZepEntityReader() 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 profiles: {str(e)}") return jsonify({ "success": False, "error": str(e), "traceback": traceback.format_exc() }), 500 # ============== Simulation Run Control Endpoints ============== @simulation_bp.route('/start', methods=['POST']) def start_simulation(): """ Start running a simulation Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "platform": "parallel", // optional: twitter / reddit / parallel (default) "max_rounds": 100, // optional: maximum number of simulation rounds, used to truncate overlong simulations "enable_graph_memory_update": false, // optional: whether to dynamically update Agent activity to the Zep graph memory "force": false // optional: force a restart (will stop a running simulation and clean up logs) } About the force parameter: - When enabled, if the simulation is running or has completed, the running logs will first be stopped and cleaned up - Files cleaned up 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 re-run About enable_graph_memory_update: - When enabled, all Agent activities (posting, commenting, liking, etc.) in the simulation are updated in real time to the Zep graph - This lets the graph "remember" the simulation process for later analysis or AI conversations - Requires the project associated with the simulation 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: maximum number of 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 the 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 whether the 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 status handling: if prep work is already complete, allow a restart if state.status != SimulationStatus.READY: # Check whether prep work is already complete is_prepared, prepare_info = _check_simulation_prepared(simulation_id) if is_prepared: # Prep work is complete, check whether a process is still running if state.status == SimulationStatus.RUNNING: # Check whether the simulation process is really running run_state = SimulationRunner.get_run_state(simulation_id) if run_state and run_state.runner_status.value == "running": # Process is really running if force: # Force mode: stop the 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 when stopping simulation: {str(e)}") else: return jsonify({ "success": False, "error": t('api.simRunningForceHint') }), 400 # If in force mode, clean up the run logs if force: logger.info(f"Force mode: cleaning up simulation logs {simulation_id}") cleanup_result = SimulationRunner.cleanup_simulation_logs(simulation_id) if not cleanup_result.get("success"): logger.warning(f"Warning when cleaning up logs: {cleanup_result.get('errors')}") force_restarted = True # Process does not exist or has ended, reset status to ready logger.info(f"Simulation {simulation_id} prep work is complete, resetting status to ready (previous status: {state.status.value})") state.status = SimulationStatus.READY manager._save_simulation_state(state) else: # Prep work is not complete return jsonify({ "success": False, "error": t('api.simNotReady', status=state.status.value) }), 400 # Get the graph_id (used for graph memory update) graph_id = None if enable_graph_memory_update: # Get graph_id from the simulation state or the project graph_id = state.graph_id if not graph_id: # Try to get it from the 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 the 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 the simulation status 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 the simulation status 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 Endpoints ============== @simulation_bp.route('//run-status', methods=['GET']) def get_run_status(simulation_id: str): """ Get the real-time run status of a simulation (used 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 fetch run status: {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 run status of a simulation (including all actions) Used by the frontend to display real-time dynamics Query params: platform: filter 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 the 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 actions for the current round (recent_actions only shows the 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 the 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 the latest round's content across 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 fetch 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 Agent action history of a simulation Query params: limit: return count (default 100) offset: offset (default 0) platform: filter platform (twitter/reddit) agent_id: filter Agent ID round_num: filter round 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 fetch 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 (summarized by round) Used by the frontend to display the progress bar and timeline view Query params: start_round: starting round (default 0) end_round: ending round (default all) Returns the summary 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 fetch 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 per-Agent statistics Used by the frontend to display Agent activity rankings, action distribution, 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 fetch Agent statistics: {str(e)}") return jsonify({ "success": False, "error": str(e), "traceback": traceback.format_exc() }), 500 # ============== Database Query Endpoints ============== @simulation_bp.route('//posts', methods=['GET']) def get_simulation_posts(simulation_id: str): """ Get the posts in a simulation Query params: platform: platform type (twitter/reddit) limit: return count (default 50) offset: offset Returns the post list (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 fetch 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 the comments in a simulation (Reddit only) Query params: post_id: filter 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 fetch comments: {str(e)}") return jsonify({ "success": False, "error": str(e), "traceback": traceback.format_exc() }), 500 # ============== Interview Endpoints ============== @simulation_bp.route('/interview', methods=['POST']) def interview_agent(): """ Interview a single Agent Note: this feature requires the simulation environment to be running (it enters the wait-for-command mode after finishing a simulation loop) 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, specify platform (twitter/reddit) // when not specified: in dual-platform simulations, both platforms are interviewed simultaneously "timeout": 60 // optional, timeout in 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 the platform parameter if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, "error": t('api.invalidInterviewPlatform') }), 400 # Check the environment status if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, "error": t('api.envNotRunning') }), 400 # Optimize the prompt by adding a prefix to prevent the 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, list of interviews { "agent_id": 0, "prompt": "What do you think of A?", "platform": "twitter" // optional, specify the interview platform for this Agent }, { "agent_id": 1, "prompt": "What do you think of B?" // when platform is not specified, the default is used } ], "platform": "reddit", // optional, default platform (overridden by each item's platform) // when not specified: in dual-platform simulations, both platforms are interviewed simultaneously for each Agent "timeout": 120 // optional, timeout in 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 the platform parameter if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, "error": t('api.invalidInterviewPlatform') }), 400 # Validate every 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 each item's platform (if any) 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 the 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 by adding a prefix to prevent the 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 - use the same question to interview all Agents Note: this feature requires the simulation environment to be running Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "prompt": "What is your overall take on this?", // required, interview question (all Agents use the same question) "platform": "reddit", // optional, specify platform (twitter/reddit) // when not specified: in dual-platform simulations, both platforms are interviewed simultaneously for each Agent "timeout": 180 // optional, timeout in 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 the platform parameter if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, "error": t('api.invalidInterviewPlatform') }), 400 # Check the environment status if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, "error": t('api.envNotRunning') }), 400 # Optimize the prompt by adding a prefix to prevent the 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 the Interview history Reads all Interview records from the simulation database Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "platform": "reddit", // optional, platform type (reddit/twitter) // when not specified, history for both platforms is returned "agent_id": 0, // optional, only fetch 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') # when not specified, history for both platforms is returned 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 fetch 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 Check whether the simulation environment is alive (able to 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 ready to 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 fetch 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 the wait-for-command mode. Note: this differs from the /stop endpoint - /stop forcefully terminates the process, while this endpoint lets the simulation gracefully close the environment and exit. Request (JSON): { "simulation_id": "sim_xxxx", // required, simulation ID "timeout": 30 // optional, timeout in seconds, default 30 } Returns: { "success": true, "data": { "message": "Environment close command has been 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 the simulation status 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