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