MicroFish/backend/app/api/simulation.py

2638 lines
90 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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/<graph_id>', 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/<graph_id>/<entity_uuid>', 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/<graph_id>/by-type/<entity_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('/<simulation_id>', 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('/<simulation_id>/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('/<simulation_id>/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('/<simulation_id>/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('/<simulation_id>/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('/<simulation_id>/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/<script_name>/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('/<simulation_id>/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('/<simulation_id>/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('/<simulation_id>/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('/<simulation_id>/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('/<simulation_id>/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('/<simulation_id>/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('/<simulation_id>/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