"""Simulation-related API routes. Step 2: Zep entity reading/filtering, OASIS simulation preparation and execution (end-to-end 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 ..models.project import ProjectManager from ..utils.locale import t logger = get_logger('mirofish.api.simulation') # Prefix injection avoids agent tool-calls and forces a plain-text reply. INTERVIEW_PROMPT_PREFIX = "结合你的人设、所有的过往记忆与行动,不调用任何工具直接用文本回复我:" def optimize_interview_prompt(prompt: str) -> str: """Optimize an interview prompt by prepending the no-tool-call prefix. Args: prompt: Original prompt text. Returns: Prompt with the prefix prepended (or unchanged if already prefixed). """ if not prompt: return prompt 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): """Return all (filtered) entities in the graph. Only nodes matching the predefined entity types are returned (i.e. nodes whose labels include more than just `Entity`). Query params: entity_types: Comma-separated entity-type list (optional, for further filtering). enrich: Whether to include related edge info (default true). """ try: if not Config.NEO4J_PASSWORD: return jsonify({ "success": False, "error": t("api.error.simulation.m001") }), 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(t("log.simulation_api.m002", 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(t("log.simulation_api.m003", str=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): """Return details for a single entity.""" try: if not Config.NEO4J_PASSWORD: return jsonify({ "success": False, "error": t("api.error.simulation.m004") }), 500 reader = ZepEntityReader() entity = reader.get_entity_with_context(graph_id, entity_uuid) if not entity: return jsonify({ "success": False, "error": t("api.error.simulation.m005", entity_uuid=entity_uuid) }), 404 return jsonify({ "success": True, "data": entity.to_dict() }) except Exception as e: logger.error(t("log.simulation_api.m006", str=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): """Return all entities of the given type.""" try: if not Config.NEO4J_PASSWORD: return jsonify({ "success": False, "error": t("api.error.simulation.m007") }), 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(t("log.simulation_api.m008", str=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 generated intelligently by the LLM and do not need to be set manually. Request (JSON): { "project_id": "proj_xxxx", // required "graph_id": "mirofish_xxxx", // optional; falls back to the project's graph_id "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.error.simulation.m009") }), 400 project = ProjectManager.get_project(project_id) if not project: return jsonify({ "success": False, "error": t("api.error.simulation.m010", project_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.error.simulation.m011") }), 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(t("log.simulation_api.m012", str=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 is already fully prepared. Conditions: 1. `state.json` exists and `status` is "ready". 2. Required files exist: `reddit_profiles.json`, `twitter_profiles.csv`, `simulation_config.json`. Note: runner scripts (run_*.py) live under `backend/scripts/` and are no longer copied into the simulation directory. Args: simulation_id: Simulation identifier. Returns: (is_prepared: bool, info: dict) """ import os from ..config import Config simulation_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) if not os.path.exists(simulation_dir): return False, {"reason": "模拟目录不存在"} # Required files (scripts are not included; they live in backend/scripts/). required_files = [ "state.json", "simulation_config.json", "reddit_profiles.json", "twitter_profiles.csv" ] 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_files": missing_files, "existing_files": existing_files } 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) logger.debug(t("log.simulation_api.m013", simulation_id=simulation_id, status=status, config_generated=config_generated)) # All these statuses imply preparation is finished (when config_generated is True): # - ready / preparing / running / completed / stopped / failed. prepared_statuses = ["ready", "preparing", "running", "completed", "stopped", "failed"] if status in prepared_statuses and config_generated: 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 the files are already 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(t("log.simulation_api.m014", simulation_id=simulation_id)) status = "ready" except Exception as e: logger.warning(t("log.simulation_api.m015", e=e)) logger.info(t("log.simulation_api.m016", simulation_id=simulation_id, 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(t("log.simulation_api.m017", simulation_id=simulation_id, status=status, config_generated=config_generated)) return False, { "reason": f"状态不在已准备列表中或config_generated为false: status={status}, config_generated={config_generated}", "status": status, "config_generated": config_generated } except Exception as e: return False, {"reason": f"读取状态文件失败: {str(e)}"} @simulation_bp.route('/prepare', methods=['POST']) def prepare_simulation(): """Prepare the simulation environment (async task; the LLM generates all params). This is a long-running operation. The endpoint returns a `task_id` immediately; use `GET /api/simulation/prepare/status` to poll for progress. Features: - Auto-detects completed preparation work and avoids duplicate generation. - Returns existing results when preparation is already complete. - Supports force regeneration via `force_regenerate=true`. Steps: 1. Check whether preparation is already complete. 2. Read and filter entities from the Zep graph. 3. Generate an OASIS Agent profile per entity (with retry). 4. LLM-generate the simulation configuration (with retry). 5. Save the config files and preset scripts. Request (JSON): { "simulation_id": "sim_xxxx", // required "entity_types": ["Student", "PublicFigure"], // optional "use_llm_for_profiles": true, // optional "parallel_profile_count": 5, // optional, default 5 "force_regenerate": false // optional, default false } Response: { "success": true, "data": { "simulation_id": "sim_xxxx", "task_id": "task_xxxx", // present for newly started tasks "status": "preparing|ready", "message": "...", "already_prepared": true|false } } """ 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.error.simulation.m018") }), 400 manager = SimulationManager() state = manager.get_simulation(simulation_id) if not state: return jsonify({ "success": False, "error": t("api.error.simulation.m019", simulation_id=simulation_id) }), 404 force_regenerate = data.get('force_regenerate', False) logger.info(t("log.simulation_api.m020", simulation_id=simulation_id, force_regenerate=force_regenerate)) # Skip regeneration if preparation is already complete. if not force_regenerate: logger.debug(t("log.simulation_api.m021", simulation_id=simulation_id)) is_prepared, prepare_info = _check_simulation_prepared(simulation_id) logger.debug(t("log.simulation_api.m022", is_prepared=is_prepared, prepare_info=prepare_info)) if is_prepared: logger.info(t("log.simulation_api.m023", simulation_id=simulation_id)) return jsonify({ "success": True, "data": { "simulation_id": simulation_id, "status": "ready", "message": "已有完成的准备工作,无需重复生成", "already_prepared": True, "prepare_info": prepare_info } }) else: logger.info(t("log.simulation_api.m024", simulation_id=simulation_id)) project = ProjectManager.get_project(state.project_id) if not project: return jsonify({ "success": False, "error": t("api.error.simulation.m025", state=state.project_id) }), 404 simulation_requirement = project.simulation_requirement or "" if not simulation_requirement: return jsonify({ "success": False, "error": t("api.error.simulation.m026") }), 400 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 fetch the entity count before starting the background task, # so the frontend can immediately display the expected agent total. try: logger.info(t("log.simulation_api.m027", state=state.graph_id)) reader = ZepEntityReader() filtered_preview = reader.filter_defined_entities( graph_id=state.graph_id, defined_entity_types=entity_types_list, enrich_with_edges=False # Skip edges for speed; only the count matters here. ) state.entities_count = filtered_preview.filtered_count state.entity_types = list(filtered_preview.entity_types) logger.info(t("log.simulation_api.m028", filtered_preview=filtered_preview.filtered_count, filtered_preview_2=filtered_preview.entity_types)) except Exception as e: logger.warning(t("log.simulation_api.m029", e=e)) # Failure here is non-fatal; the background task will re-read the entities. 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 the pre-fetched entity count). state.status = SimulationStatus.PREPARING manager._save_simulation_state(state) def run_prepare(): try: task_manager.update_task( task_id, status=TaskStatus.PROCESSING, progress=0, message="开始准备模拟环境..." ) # Per-stage progress detail (used by the progress callback below). stage_details = {} def progress_callback(stage, progress, message, **kwargs): # Map each stage to a slice of the overall 0-100 progress range. stage_weights = { "reading": (0, 20), "generating_profiles": (20, 70), "generating_config": (70, 90), "copying_scripts": (90, 100) } start, end = stage_weights.get(stage, (0, 100)) current_progress = int(start + (end - start) * progress / 100) stage_names = { "reading": "读取图谱实体", "generating_profiles": "生成Agent人设", "generating_config": "生成模拟配置", "copying_scripts": "准备模拟脚本" } stage_index = list(stage_weights.keys()).index(stage) + 1 if stage in stage_weights else 1 total_stages = len(stage_weights) 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", "") } 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 concise progress 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_manager.complete_task( task_id, result=result_state.to_simple_dict() ) except Exception as e: logger.error(t("log.simulation_api.m030", str=str(e))) task_manager.fail_task(task_id, str(e)) # Mark the simulation state as failed. state = manager.get_simulation(simulation_id) if state: state.status = SimulationStatus.FAILED state.error = str(e) manager._save_simulation_state(state) 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": "准备任务已启动,请通过 /api/simulation/prepare/status 查询进度", "already_prepared": False, "expected_entities_count": state.entities_count, # Expected total agent count. "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(t("log.simulation_api.m031", str=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 progress for a preparation task. Two query modes are supported: 1. By `task_id` — return live progress for an in-flight task. 2. By `simulation_id` — check whether preparation has already finished. Request (JSON): { "task_id": "task_xxxx", // optional; the task_id returned by /prepare "simulation_id": "sim_xxxx" // optional; checks for existing complete prep } Response: { "success": true, "data": { "task_id": "task_xxxx", "status": "processing|completed|ready", "progress": 45, "message": "...", "already_prepared": true|false, // whether prep is already complete "prepare_info": {...} // details when prep is complete } } """ 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 if 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": "已有完成的准备工作", "already_prepared": True, "prepare_info": prepare_info } }) # No task_id provided. if not task_id: if simulation_id: # simulation_id provided but prep is not complete. return jsonify({ "success": True, "data": { "simulation_id": simulation_id, "status": "not_started", "progress": 0, "message": "尚未开始准备,请调用 /api/simulation/prepare 开始", "already_prepared": False } }) return jsonify({ "success": False, "error": t("api.error.simulation.m032") }), 400 task_manager = TaskManager() task = task_manager.get_task(task_id) if not task: # Task is missing; if simulation_id is given, 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, "task_id": task_id, "status": "ready", "progress": 100, "message": "任务已完成(准备工作已存在)", "already_prepared": True, "prepare_info": prepare_info } }) return jsonify({ "success": False, "error": t("api.error.simulation.m033", task_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(t("log.simulation_api.m034", str=str(e))) return jsonify({ "success": False, "error": str(e) }), 500 @simulation_bp.route('/', methods=['GET']) def get_simulation(simulation_id: str): """Return the current simulation state.""" try: manager = SimulationManager() state = manager.get_simulation(simulation_id) if not state: return jsonify({ "success": False, "error": t("api.error.simulation.m035", simulation_id=simulation_id) }), 404 result = state.to_dict() # Attach run instructions when the simulation is ready. 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(t("log.simulation_api.m036", str=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(t("log.simulation_api.m037", str=str(e))) return jsonify({ "success": False, "error": str(e), "traceback": traceback.format_exc() }), 500 def _get_report_id_for_simulation(simulation_id: str) -> str: """Return the latest report_id associated with a simulation. Walks the reports directory, finds reports whose simulation_id matches, and returns the most recent one (sorted by created_at). Args: simulation_id: Simulation identifier. Returns: report_id, or None if no matching report exists. """ import json from datetime import datetime # Reports directory: backend/uploads/reports. # __file__ is app/api/simulation.py, so we 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 creation time descending and return the most recent. 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(t("log.simulation_api.m038", simulation_id=simulation_id, e=e)) return None @simulation_bp.route('/history', methods=['GET']) def get_simulation_history(): """Return historical simulations (with project details). Used by the homepage to display past projects. Returns a list of simulations enriched with project name, description, and other metadata. 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": "...", "simulation_requirement": "...", "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 using only the Simulation files. enriched_simulations = [] for sim in simulations: sim_dict = sim.to_dict() # 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 (used as a 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 # Read user-set 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 # Prefer the user-set total_rounds; fall back to the recommended count. 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 # Up to three files from the associated project. project = ProjectManager.get_project(sim.project_id) if project and hasattr(project, 'files') and project.files: sim_dict["files"] = [ {"filename": f.get("filename", "未知文件")} for f in project.files[:3] ] else: sim_dict["files"] = [] # Latest report_id linked to this simulation. sim_dict["report_id"] = _get_report_id_for_simulation(sim.simulation_id) sim_dict["version"] = "v1.0.2" 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(t("log.simulation_api.m039", str=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): """Return the agent profiles for a simulation. Query params: platform: Platform (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(t("log.simulation_api.m040", str=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): """Return agent profiles in real time (for live progress during generation). Differs from /profiles in that: - Reads files directly, bypassing SimulationManager. - Suitable for live viewing while generation is still running. - Returns extra metadata (file mtime, is_generating, etc.). Query params: platform: Platform (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') 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.error.simulation.m041", simulation_id=simulation_id) }), 404 if platform == "reddit": profiles_file = os.path.join(sim_dir, "reddit_profiles.json") else: profiles_file = os.path.join(sim_dir, "twitter_profiles.csv") file_exists = os.path.exists(profiles_file) profiles = [] file_modified_at = None if file_exists: 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(t("log.simulation_api.m042", e=e)) profiles = [] # Use state.json to detect whether generation is in progress. 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(t("log.simulation_api.m043", str=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): """Return the simulation config in real time (for live progress during generation). Differs from /config in that: - Reads the file directly, bypassing SimulationManager. - Suitable for live viewing while generation is still running. - Returns extra metadata (file mtime, is_generating, etc.). - Returns partial information even if generation has not finished. Response: { "success": true, "data": { "simulation_id": "sim_xxxx", "file_exists": true, "file_modified_at": "2025-12-04T18:20:00", "is_generating": true, // generation in progress "generation_stage": "generating_config", // current stage "config": {...} // config content, if any } } """ import json from datetime import datetime try: 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.error.simulation.m044", simulation_id=simulation_id) }), 404 config_file = os.path.join(sim_dir, "simulation_config.json") file_exists = os.path.exists(config_file) config = None file_modified_at = None if file_exists: 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(t("log.simulation_api.m045", e=e)) config = None # Use state.json to detect whether generation is in progress. 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) # Derive 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 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 } # When config is present, surface a few key summary stats. 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(t("log.simulation_api.m046", str=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): """Return the simulation config (the full LLM-generated config). Returns: - time_config: Time configuration (sim length, rounds, peak/off-peak windows). - agent_configs: Per-agent activity configuration (activity, posting rate, stance). - event_config: Event configuration (initial posts, hot topics). - platform_configs: Platform configuration. - generation_reasoning: The LLM's reasoning notes for the config. """ try: manager = SimulationManager() config = manager.get_simulation_config(simulation_id) if not config: return jsonify({ "success": False, "error": t("api.error.simulation.m047") }), 404 return jsonify({ "success": True, "data": config }) except Exception as e: logger.error(t("log.simulation_api.m048", str=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.error.simulation.m049") }), 404 return send_file( config_path, as_attachment=True, download_name="simulation_config.json" ) except Exception as e: logger.error(t("log.simulation_api.m050", str=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 scripts in backend/scripts/). Allowed values for script_name: - run_twitter_simulation.py - run_reddit_simulation.py - run_parallel_simulation.py - action_logger.py """ try: # Scripts live in the backend/scripts/ directory. scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts')) # Allow only known 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.error.simulation.m051", script_name=script_name, allowed_scripts=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.error.simulation.m052", script_name=script_name) }), 404 return send_file( script_path, as_attachment=True, download_name=script_name ) except Exception as e: logger.error(t("log.simulation_api.m053", str=str(e))) return jsonify({ "success": False, "error": str(e), "traceback": traceback.format_exc() }), 500 # ============== Standalone profile generation endpoints ============== @simulation_bp.route('/generate-profiles', methods=['POST']) def generate_profiles(): """Generate OASIS agent profiles directly from a 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.error.simulation.m054") }), 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.error.simulation.m055") }), 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(t("log.simulation_api.m056", str=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 "platform": "parallel", // optional: twitter / reddit / parallel (default) "max_rounds": 100, // optional: max simulation rounds (truncate long sims) "enable_graph_memory_update": false, // optional: stream agent activity into Zep memory "force": false // optional: force restart (stops running sim, clears logs) } About `force`: - When enabled, if the simulation is running or completed, it is stopped and run logs are cleared. - Cleared artefacts: run_state.json, actions.jsonl, simulation.log, etc. - Config files (simulation_config.json) and profiles are NOT cleared. - Use this when you need to re-run a simulation from scratch. About `enable_graph_memory_update`: - When enabled, all agent activity (posts, comments, likes, etc.) is pushed into the Zep graph in real time, so the graph "remembers" the simulation for later analysis or chat. - Requires the linked project to have a valid graph_id. - Uses batch updates to reduce API calls. Response: { "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, // graph memory update was enabled "force_restarted": true // restart was forced } } """ try: data = request.get_json() or {} simulation_id = data.get('simulation_id') if not simulation_id: return jsonify({ "success": False, "error": t("api.error.simulation.m057") }), 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: enable graph memory update force = data.get('force', False) # optional: force restart if max_rounds is not None: try: max_rounds = int(max_rounds) if max_rounds <= 0: return jsonify({ "success": False, "error": t("api.error.simulation.m058") }), 400 except (ValueError, TypeError): return jsonify({ "success": False, "error": t("api.error.simulation.m059") }), 400 if platform not in ['twitter', 'reddit', 'parallel']: return jsonify({ "success": False, "error": t("api.error.simulation.m060", platform=platform) }), 400 # Verify the simulation is ready. manager = SimulationManager() state = manager.get_simulation(simulation_id) if not state: return jsonify({ "success": False, "error": t("api.error.simulation.m061", simulation_id=simulation_id) }), 404 force_restarted = False # If preparation is complete, allow re-starting even from a non-READY status. if state.status != SimulationStatus.READY: is_prepared, prepare_info = _check_simulation_prepared(simulation_id) if is_prepared: # Preparation is complete; check whether a process is still running. if state.status == SimulationStatus.RUNNING: run_state = SimulationRunner.get_run_state(simulation_id) if run_state and run_state.runner_status.value == "running": # The process is genuinely running. if force: # Force mode: stop the running simulation. logger.info(t("log.simulation_api.m062", simulation_id=simulation_id)) try: SimulationRunner.stop_simulation(simulation_id) except Exception as e: logger.warning(t("log.simulation_api.m063", str=str(e))) else: return jsonify({ "success": False, "error": t("api.error.simulation.m064") }), 400 # When forcing, also clear run logs. if force: logger.info(t("log.simulation_api.m065", simulation_id=simulation_id)) cleanup_result = SimulationRunner.cleanup_simulation_logs(simulation_id) if not cleanup_result.get("success"): logger.warning(t("log.simulation_api.m066", cleanup_result=cleanup_result.get('errors'))) force_restarted = True # Process is gone or finished; reset status to ready. logger.info(t("log.simulation_api.m067", simulation_id=simulation_id, state=state.status.value)) state.status = SimulationStatus.READY manager._save_simulation_state(state) else: # Preparation has not finished. return jsonify({ "success": False, "error": t("api.error.simulation.m068", state=state.status.value) }), 400 # Resolve graph_id (used by graph memory update). graph_id = None if enable_graph_memory_update: graph_id = state.graph_id if not graph_id: # Fall back to the project's graph_id. 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.error.simulation.m069") }), 400 logger.info(t("log.simulation_api.m070", simulation_id=simulation_id, graph_id=graph_id)) 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 ) 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(t("log.simulation_api.m071", str=str(e))) return jsonify({ "success": False, "error": str(e), "traceback": traceback.format_exc() }), 500 @simulation_bp.route('/stop', methods=['POST']) def stop_simulation(): """Stop a simulation. Request (JSON): { "simulation_id": "sim_xxxx" // required } Response: { "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.error.simulation.m072") }), 400 run_state = SimulationRunner.stop_simulation(simulation_id) 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(t("log.simulation_api.m073", str=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): """Return real-time simulation run status (for frontend polling). Response: { "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(t("log.simulation_api.m074", str=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): """Return detailed simulation run status (including all actions). Used by the frontend for live activity views. Query params: platform: Filter platform (twitter/reddit, optional). Response: { "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": [] } }) all_actions = SimulationRunner.get_all_actions( simulation_id=simulation_id, platform=platform_filter ) # Per-platform action lists. 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 [] # `recent_actions` only surfaces 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 [] 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) result["recent_actions"] = [a.to_dict() for a in recent_actions] return jsonify({ "success": True, "data": result }) except Exception as e: logger.error(t("log.simulation_api.m075", str=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): """Return the agent action history for a simulation. Query params: limit: Number of items to return (default 100). offset: Offset (default 0). platform: Filter platform (twitter/reddit). agent_id: Filter agent ID. round_num: Filter round. Response: { "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(t("log.simulation_api.m076", str=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): """Return the simulation timeline (summary per round). Used by the frontend for the progress bar and timeline view. Query params: start_round: Starting round (default 0). end_round: Ending round (default: all). Returns: Per-round summary info. """ 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(t("log.simulation_api.m077", str=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): """Return per-agent statistics. Used by the frontend to show 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(t("log.simulation_api.m078", str=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): """Return the posts created in a simulation. Query params: platform: Platform (twitter/reddit). limit: Number of items to return (default 50). offset: Offset. Returns: 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": "数据库不存在,模拟可能尚未运行" } }) 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(t("log.simulation_api.m079", str=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): """Return comments from a simulation (Reddit only). Query params: post_id: Filter by post ID (optional). limit: Number of items to return. 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(t("log.simulation_api.m080", str=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: requires the simulation environment to be running (i.e. the sim loop has finished and the runner is in command-wait mode). Request (JSON): { "simulation_id": "sim_xxxx", // required "agent_id": 0, // required "prompt": "...", // required, interview question "platform": "twitter", // optional (twitter/reddit) // omit -> dual-platform sims interview both platforms "timeout": 60 // optional, timeout in seconds, default 60 } Response (when `platform` is omitted; dual-platform mode): { "success": true, "data": { "agent_id": 0, "prompt": "...", "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" } } Response (when `platform` is specified): { "success": true, "data": { "agent_id": 0, "prompt": "...", "result": { "agent_id": 0, "response": "...", "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.error.simulation.m081") }), 400 if agent_id is None: return jsonify({ "success": False, "error": t("api.error.simulation.m082") }), 400 if not prompt: return jsonify({ "success": False, "error": t("api.error.simulation.m083") }), 400 if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, "error": t("api.error.simulation.m084") }), 400 if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, "error": t("api.error.simulation.m085") }), 400 # Inject the no-tool-call prefix into the prompt. 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.error.simulation.m086", str=str(e)) }), 504 except Exception as e: logger.error(t("log.simulation_api.m087", str=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(): """Interview multiple agents in batch. Note: requires the simulation environment to be running. Request (JSON): { "simulation_id": "sim_xxxx", // required "interviews": [ // required { "agent_id": 0, "prompt": "...", "platform": "twitter" // optional, per-agent platform override }, { "agent_id": 1, "prompt": "..." // omit `platform` to use the default } ], "platform": "reddit", // optional default platform (overridden by each item's platform) // omit -> dual-platform sims interview each agent on both platforms "timeout": 120 // optional, timeout in seconds, default 120 } Response: { "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.error.simulation.m088") }), 400 if not interviews or not isinstance(interviews, list): return jsonify({ "success": False, "error": t("api.error.simulation.m089") }), 400 if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, "error": t("api.error.simulation.m090") }), 400 # Validate each interview item. for i, interview in enumerate(interviews): if 'agent_id' not in interview: return jsonify({ "success": False, "error": t("api.error.simulation.m091", i=i + 1) }), 400 if 'prompt' not in interview: return jsonify({ "success": False, "error": t("api.error.simulation.m092", i=i + 1) }), 400 # Validate each item's platform (if present). item_platform = interview.get('platform') if item_platform and item_platform not in ("twitter", "reddit"): return jsonify({ "success": False, "error": t("api.error.simulation.m093", i=i + 1) }), 400 if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, "error": t("api.error.simulation.m094") }), 400 # Inject the no-tool-call prefix into every interview prompt. 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.error.simulation.m095", str=str(e)) }), 504 except Exception as e: logger.error(t("log.simulation_api.m096", str=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 — ask the same question of every agent. Note: requires the simulation environment to be running. Request (JSON): { "simulation_id": "sim_xxxx", // required "prompt": "...", // required, the same question for every agent "platform": "reddit", // optional (twitter/reddit) // omit -> dual-platform sims interview each agent on both platforms "timeout": 180 // optional, timeout in seconds, default 180 } Response: { "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.error.simulation.m097") }), 400 if not prompt: return jsonify({ "success": False, "error": t("api.error.simulation.m098") }), 400 if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, "error": t("api.error.simulation.m099") }), 400 if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, "error": t("api.error.simulation.m100") }), 400 # Inject the no-tool-call prefix into the prompt. 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.error.simulation.m101", str=str(e)) }), 504 except Exception as e: logger.error(t("log.simulation_api.m102", str=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(): """Return interview history. Reads all interview records from the simulation database. Request (JSON): { "simulation_id": "sim_xxxx", // required "platform": "reddit", // optional (reddit/twitter) // omit -> return history for both platforms "agent_id": 0, // optional, restrict to one agent "limit": 100 // optional, default 100 } Response: { "success": true, "data": { "count": 10, "history": [ { "agent_id": 0, "response": "...", "prompt": "...", "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 omitted, returns history for both platforms. agent_id = data.get('agent_id') limit = data.get('limit', 100) if not simulation_id: return jsonify({ "success": False, "error": t("api.error.simulation.m103") }), 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(t("log.simulation_api.m104", str=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(): """Return the simulation environment status. Checks whether the simulation environment is alive (i.e. able to accept interview commands). Request (JSON): { "simulation_id": "sim_xxxx" // required } Response: { "success": true, "data": { "simulation_id": "sim_xxxx", "env_alive": true, "twitter_available": true, "reddit_available": true, "message": "..." } } """ try: data = request.get_json() or {} simulation_id = data.get('simulation_id') if not simulation_id: return jsonify({ "success": False, "error": t("api.error.simulation.m105") }), 400 env_alive = SimulationRunner.check_env_alive(simulation_id) env_status = SimulationRunner.get_env_status_detail(simulation_id) if env_alive: message = "环境正在运行,可以接收Interview命令" else: message = "环境未运行或已关闭" 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(t("log.simulation_api.m106", str=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-env" command to the simulation so it can gracefully exit command-wait mode. Note: this is different from `/stop`, which kills the process. This endpoint asks the simulation to shut down its environment cleanly. Request (JSON): { "simulation_id": "sim_xxxx", // required "timeout": 30 // optional, timeout in seconds, default 30 } Response: { "success": true, "data": { "message": "...", "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.error.simulation.m107") }), 400 result = SimulationRunner.close_simulation_env( simulation_id=simulation_id, timeout=timeout ) 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(t("log.simulation_api.m108", str=str(e))) return jsonify({ "success": False, "error": str(e), "traceback": traceback.format_exc() }), 500