Merge bd4babb51a into b5b53acc57
This commit is contained in:
commit
fca19e0968
|
|
@ -494,7 +494,7 @@ def _build_graph_impl():
|
|||
}), 500
|
||||
|
||||
# 解析请求
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
project_id = data.get('project_id')
|
||||
logger.debug(f"请求参数: project_id={project_id}")
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ def generate_report():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
if not simulation_id:
|
||||
|
|
@ -342,7 +342,7 @@ def get_generate_status():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
task_id = data.get('task_id')
|
||||
simulation_id = data.get('simulation_id')
|
||||
|
|
@ -616,7 +616,7 @@ def chat_with_report_agent():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
message = data.get('message')
|
||||
|
|
@ -1064,7 +1064,7 @@ def search_graph_tool():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
graph_id = data.get('graph_id')
|
||||
query = data.get('query')
|
||||
|
|
@ -1110,7 +1110,7 @@ def get_graph_statistics_tool():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
graph_id = data.get('graph_id')
|
||||
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ def create_simulation():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
project_id = data.get('project_id')
|
||||
if not project_id:
|
||||
|
|
@ -321,7 +321,7 @@ def _check_simulation_prepared(simulation_id: str) -> tuple:
|
|||
state_file = os.path.join(simulation_dir, "state.json")
|
||||
try:
|
||||
import json
|
||||
with open(state_file, 'r', encoding='utf-8') as f:
|
||||
with open(state_file, 'r', encoding='utf-8-sig') as f:
|
||||
state_data = json.load(f)
|
||||
|
||||
status = state_data.get("status", "")
|
||||
|
|
@ -337,8 +337,9 @@ def _check_simulation_prepared(simulation_id: str) -> tuple:
|
|||
# - running: 正在运行,说明准备早就完成了
|
||||
# - completed: 运行完成,说明准备早就完成了
|
||||
# - stopped: 已停止,说明准备早就完成了
|
||||
# - paused: 手动停止后会写入 paused,配置仍然可复用
|
||||
# - failed: 运行失败(但准备是完成的)
|
||||
prepared_statuses = ["ready", "preparing", "running", "completed", "stopped", "failed"]
|
||||
prepared_statuses = ["ready", "preparing", "running", "completed", "stopped", "paused", "failed"]
|
||||
if status in prepared_statuses and config_generated:
|
||||
# 获取文件统计信息
|
||||
profiles_file = os.path.join(simulation_dir, "reddit_profiles.json")
|
||||
|
|
@ -433,7 +434,7 @@ def prepare_simulation():
|
|||
from ..config import Config
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
if not simulation_id:
|
||||
|
|
@ -705,7 +706,7 @@ def get_prepare_status():
|
|||
from ..models.task import TaskManager
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
task_id = data.get('task_id')
|
||||
simulation_id = data.get('simulation_id')
|
||||
|
|
@ -1141,7 +1142,7 @@ def get_simulation_profiles_realtime(simulation_id: str):
|
|||
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:
|
||||
with open(state_file, 'r', encoding='utf-8-sig') as f:
|
||||
state_data = json.load(f)
|
||||
status = state_data.get("status", "")
|
||||
is_generating = status == "preparing"
|
||||
|
|
@ -1243,7 +1244,7 @@ def get_simulation_config_realtime(simulation_id: str):
|
|||
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:
|
||||
with open(state_file, 'r', encoding='utf-8-sig') as f:
|
||||
state_data = json.load(f)
|
||||
status = state_data.get("status", "")
|
||||
error = state_data.get("error")
|
||||
|
|
@ -1438,7 +1439,7 @@ def generate_profiles():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
graph_id = data.get('graph_id')
|
||||
if not graph_id:
|
||||
|
|
@ -1540,7 +1541,7 @@ def start_simulation():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
if not simulation_id:
|
||||
|
|
@ -1691,6 +1692,40 @@ def start_simulation():
|
|||
"error": t('api.graphIdRequiredForMemory')
|
||||
}), 400
|
||||
|
||||
existing_run_state = SimulationRunner.get_run_state(simulation_id)
|
||||
restartable_statuses = {
|
||||
RunnerStatus.IDLE,
|
||||
RunnerStatus.STOPPED,
|
||||
RunnerStatus.COMPLETED,
|
||||
RunnerStatus.FAILED,
|
||||
}
|
||||
if (
|
||||
existing_run_state
|
||||
and existing_run_state.runner_status in restartable_statuses
|
||||
):
|
||||
if ZepGraphMemoryManager.get_updater(simulation_id) is not None:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": (
|
||||
"The previous simulation still has pending graph "
|
||||
"memory updates; finalize or reset it before restarting"
|
||||
),
|
||||
}), 409
|
||||
logger.info(
|
||||
f"清理已结束的旧运行记录后重新启动: "
|
||||
f"simulation_id={simulation_id}, runner_status={existing_run_state.runner_status.value}"
|
||||
)
|
||||
cleanup_result = SimulationRunner.cleanup_simulation_logs(simulation_id)
|
||||
if not cleanup_result.get("success"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": (
|
||||
"Failed to clean previous simulation logs: "
|
||||
f"{cleanup_result.get('errors')}"
|
||||
),
|
||||
}), 500
|
||||
force_restarted = True
|
||||
|
||||
graph_guard = (
|
||||
graph_lifecycle_lock(graph_id)
|
||||
if enable_graph_memory_update
|
||||
|
|
@ -1806,7 +1841,7 @@ def stop_simulation():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
if not simulation_id:
|
||||
|
|
@ -2353,7 +2388,7 @@ def interview_agent():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
agent_id = data.get('agent_id')
|
||||
|
|
@ -2475,7 +2510,7 @@ def interview_agents_batch():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
interviews = data.get('interviews')
|
||||
|
|
@ -2602,7 +2637,7 @@ def interview_all_agents():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
prompt = data.get('prompt')
|
||||
|
|
@ -2706,7 +2741,7 @@ def get_interview_history():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
platform = data.get('platform') # 不指定则返回两个平台的历史
|
||||
|
|
@ -2768,7 +2803,7 @@ def get_env_status():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
|
||||
|
|
@ -2835,7 +2870,7 @@ def close_simulation_env():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
timeout = data.get('timeout', 30)
|
||||
|
|
|
|||
|
|
@ -257,7 +257,8 @@ class OasisProfileGenerator:
|
|||
|
||||
self.client = OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url
|
||||
base_url=self.base_url,
|
||||
default_headers={"User-Agent": "python-requests/2.32.5"}
|
||||
)
|
||||
|
||||
# Zep客户端用于检索丰富上下文
|
||||
|
|
|
|||
|
|
@ -238,7 +238,8 @@ class SimulationConfigGenerator:
|
|||
|
||||
self.client = OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url
|
||||
base_url=self.base_url,
|
||||
default_headers={"User-Agent": "python-requests/2.32.5"}
|
||||
)
|
||||
|
||||
def generate_config(
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ class SimulationIPCClient:
|
|||
return False
|
||||
|
||||
try:
|
||||
with open(status_file, 'r', encoding='utf-8') as f:
|
||||
with open(status_file, 'r', encoding='utf-8-sig') as f:
|
||||
status = json.load(f)
|
||||
return status.get("status") == "alive"
|
||||
except (json.JSONDecodeError, OSError):
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ class SimulationManager:
|
|||
if not os.path.exists(state_file):
|
||||
return None
|
||||
|
||||
with open(state_file, 'r', encoding='utf-8') as f:
|
||||
with open(state_file, 'r', encoding='utf-8-sig') as f:
|
||||
data = json.load(f)
|
||||
|
||||
state = SimulationState(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import threading
|
|||
import subprocess
|
||||
import signal
|
||||
import atexit
|
||||
import psutil
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
|
@ -305,7 +306,7 @@ class SimulationRunner:
|
|||
return None
|
||||
|
||||
try:
|
||||
with open(state_file, 'r', encoding='utf-8') as f:
|
||||
with open(state_file, 'r', encoding='utf-8-sig') as f:
|
||||
data = json.load(f)
|
||||
|
||||
state = SimulationRunState(
|
||||
|
|
@ -1652,7 +1653,23 @@ class SimulationRunner:
|
|||
return False
|
||||
|
||||
ipc_client = SimulationIPCClient(sim_dir)
|
||||
return ipc_client.check_env_alive()
|
||||
if not ipc_client.check_env_alive():
|
||||
return False
|
||||
|
||||
process = cls._processes.get(simulation_id)
|
||||
if process is not None:
|
||||
return process.poll() is None
|
||||
|
||||
state = cls.get_run_state(simulation_id)
|
||||
pid = state.process_pid if state else None
|
||||
if not pid:
|
||||
return False
|
||||
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
return proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE
|
||||
except psutil.Error:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_env_status_detail(cls, simulation_id: str) -> Dict[str, Any]:
|
||||
|
|
@ -1679,12 +1696,13 @@ class SimulationRunner:
|
|||
return default_status
|
||||
|
||||
try:
|
||||
with open(status_file, 'r', encoding='utf-8') as f:
|
||||
with open(status_file, 'r', encoding='utf-8-sig') as f:
|
||||
status = json.load(f)
|
||||
env_alive = cls.check_env_alive(simulation_id)
|
||||
return {
|
||||
"status": status.get("status", "stopped"),
|
||||
"twitter_available": status.get("twitter_available", False),
|
||||
"reddit_available": status.get("reddit_available", False),
|
||||
"status": status.get("status", "stopped") if env_alive else "stopped",
|
||||
"twitter_available": status.get("twitter_available", False) if env_alive else False,
|
||||
"reddit_available": status.get("reddit_available", False) if env_alive else False,
|
||||
"timestamp": status.get("timestamp")
|
||||
}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
|
|
@ -1725,7 +1743,7 @@ class SimulationRunner:
|
|||
|
||||
ipc_client = SimulationIPCClient(sim_dir)
|
||||
|
||||
if not ipc_client.check_env_alive():
|
||||
if not cls.check_env_alive(simulation_id):
|
||||
raise ValueError(f"模拟环境未运行或已关闭,无法执行Interview: {simulation_id}")
|
||||
|
||||
logger.info(f"发送Interview命令: simulation_id={simulation_id}, agent_id={agent_id}, platform={platform}")
|
||||
|
|
@ -1787,7 +1805,7 @@ class SimulationRunner:
|
|||
|
||||
ipc_client = SimulationIPCClient(sim_dir)
|
||||
|
||||
if not ipc_client.check_env_alive():
|
||||
if not cls.check_env_alive(simulation_id):
|
||||
raise ValueError(f"模拟环境未运行或已关闭,无法执行Interview: {simulation_id}")
|
||||
|
||||
logger.info(f"发送批量Interview命令: simulation_id={simulation_id}, count={len(interviews)}, platform={platform}")
|
||||
|
|
@ -1897,7 +1915,7 @@ class SimulationRunner:
|
|||
|
||||
ipc_client = SimulationIPCClient(sim_dir)
|
||||
|
||||
if not ipc_client.check_env_alive():
|
||||
if not cls.check_env_alive(simulation_id):
|
||||
return {
|
||||
"success": True,
|
||||
"message": "环境已经关闭"
|
||||
|
|
|
|||
|
|
@ -106,7 +106,8 @@ class LLMClient:
|
|||
|
||||
self.client = OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url
|
||||
base_url=self.base_url,
|
||||
default_headers={"User-Agent": "python-requests/2.32.5"}
|
||||
)
|
||||
|
||||
def _create_completion(
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ dependencies = [
|
|||
# 工具库
|
||||
"python-dotenv>=1.0.0",
|
||||
"pydantic>=2.0.0",
|
||||
"psutil>=5.9.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -34,3 +34,4 @@ python-dotenv>=1.0.0
|
|||
|
||||
# 数据验证
|
||||
pydantic>=2.0.0
|
||||
psutil>=5.9.0
|
||||
|
|
|
|||
|
|
@ -608,7 +608,7 @@ def load_config(config_path: str) -> Dict[str, Any]:
|
|||
|
||||
|
||||
# 需要过滤掉的非核心动作类型(这些动作对分析价值较低)
|
||||
FILTERED_ACTIONS = {'refresh', 'sign_up'}
|
||||
FILTERED_ACTIONS = {'refresh', 'sign_up', 'do_nothing'}
|
||||
|
||||
# 动作类型映射表(数据库中的名称 -> 标准名称)
|
||||
ACTION_TYPE_MAP = {
|
||||
|
|
@ -678,8 +678,9 @@ def fetch_new_actions_from_db(
|
|||
if not os.path.exists(db_path):
|
||||
return actions, new_last_rowid
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn = sqlite3.connect(db_path, timeout=5)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 使用 rowid 来追踪已处理的记录(rowid 是 SQLite 的内置自增字段)
|
||||
|
|
@ -739,13 +740,35 @@ def fetch_new_actions_from_db(
|
|||
'action_args': simplified_args,
|
||||
})
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"读取数据库动作失败: {e}")
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
return actions, new_last_rowid
|
||||
|
||||
|
||||
def get_latest_trace_rowid(db_path: str) -> int:
|
||||
"""Return the latest processed trace row id for a platform DB."""
|
||||
if not os.path.exists(db_path):
|
||||
return 0
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(db_path, timeout=5)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COALESCE(MAX(rowid), 0) FROM trace")
|
||||
rowid = int(cursor.fetchone()[0] or 0)
|
||||
return rowid
|
||||
except Exception as e:
|
||||
print(f"读取最新动作游标失败: {e}")
|
||||
return 0
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _enrich_action_context(
|
||||
cursor,
|
||||
action_type: str,
|
||||
|
|
@ -1034,6 +1057,9 @@ def create_model(config: Dict[str, Any], use_boost: bool = False):
|
|||
return ModelFactory.create(
|
||||
model_platform=ModelPlatformType.OPENAI,
|
||||
model_type=llm_model,
|
||||
api_key=llm_api_key or None,
|
||||
url=llm_base_url or None,
|
||||
default_headers={"User-Agent": "python-requests/2.32.5"},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1184,10 +1210,16 @@ async def run_twitter_simulation(
|
|||
content = post.get("content", "")
|
||||
try:
|
||||
agent = result.env.agent_graph.get_agent(agent_id)
|
||||
initial_actions[agent] = ManualAction(
|
||||
manual_action = ManualAction(
|
||||
action_type=ActionType.CREATE_POST,
|
||||
action_args={"content": content}
|
||||
)
|
||||
if agent in initial_actions:
|
||||
if not isinstance(initial_actions[agent], list):
|
||||
initial_actions[agent] = [initial_actions[agent]]
|
||||
initial_actions[agent].append(manual_action)
|
||||
else:
|
||||
initial_actions[agent] = manual_action
|
||||
|
||||
if action_logger:
|
||||
action_logger.log_action(
|
||||
|
|
@ -1204,7 +1236,9 @@ async def run_twitter_simulation(
|
|||
|
||||
if initial_actions:
|
||||
await result.env.step(initial_actions)
|
||||
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
|
||||
last_rowid = get_latest_trace_rowid(db_path)
|
||||
posted_count = sum(len(action) if isinstance(action, list) else 1 for action in initial_actions.values())
|
||||
log_info(f"已发布 {posted_count} 条初始帖子")
|
||||
|
||||
# 记录 round 0 结束
|
||||
if action_logger:
|
||||
|
|
@ -1403,7 +1437,9 @@ async def run_reddit_simulation(
|
|||
|
||||
if initial_actions:
|
||||
await result.env.step(initial_actions)
|
||||
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
|
||||
last_rowid = get_latest_trace_rowid(db_path)
|
||||
posted_count = sum(len(action) if isinstance(action, list) else 1 for action in initial_actions.values())
|
||||
log_info(f"已发布 {posted_count} 条初始帖子")
|
||||
|
||||
# 记录 round 0 结束
|
||||
if action_logger:
|
||||
|
|
|
|||
|
|
@ -464,6 +464,9 @@ class RedditSimulationRunner:
|
|||
return ModelFactory.create(
|
||||
model_platform=ModelPlatformType.OPENAI,
|
||||
model_type=llm_model,
|
||||
api_key=llm_api_key or None,
|
||||
url=llm_base_url or None,
|
||||
default_headers={"User-Agent": "python-requests/2.32.5"},
|
||||
)
|
||||
|
||||
def _get_active_agents_for_round(
|
||||
|
|
@ -617,7 +620,8 @@ class RedditSimulationRunner:
|
|||
|
||||
if initial_actions:
|
||||
await self.env.step(initial_actions)
|
||||
print(f" 已发布 {len(initial_actions)} 条初始帖子")
|
||||
posted_count = sum(len(action) if isinstance(action, list) else 1 for action in initial_actions.values())
|
||||
print(f" 已发布 {posted_count} 条初始帖子")
|
||||
|
||||
# 主模拟循环
|
||||
print("\n开始模拟循环...")
|
||||
|
|
|
|||
|
|
@ -457,6 +457,9 @@ class TwitterSimulationRunner:
|
|||
return ModelFactory.create(
|
||||
model_platform=ModelPlatformType.OPENAI,
|
||||
model_type=llm_model,
|
||||
api_key=llm_api_key or None,
|
||||
url=llm_base_url or None,
|
||||
default_headers={"User-Agent": "python-requests/2.32.5"},
|
||||
)
|
||||
|
||||
def _get_active_agents_for_round(
|
||||
|
|
@ -615,16 +618,23 @@ class TwitterSimulationRunner:
|
|||
content = post.get("content", "")
|
||||
try:
|
||||
agent = self.env.agent_graph.get_agent(agent_id)
|
||||
initial_actions[agent] = ManualAction(
|
||||
manual_action = ManualAction(
|
||||
action_type=ActionType.CREATE_POST,
|
||||
action_args={"content": content}
|
||||
)
|
||||
if agent in initial_actions:
|
||||
if not isinstance(initial_actions[agent], list):
|
||||
initial_actions[agent] = [initial_actions[agent]]
|
||||
initial_actions[agent].append(manual_action)
|
||||
else:
|
||||
initial_actions[agent] = manual_action
|
||||
except Exception as e:
|
||||
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
|
||||
|
||||
if initial_actions:
|
||||
await self.env.step(initial_actions)
|
||||
print(f" 已发布 {len(initial_actions)} 条初始帖子")
|
||||
posted_count = sum(len(action) if isinstance(action, list) else 1 for action in initial_actions.values())
|
||||
print(f" 已发布 {posted_count} 条初始帖子")
|
||||
|
||||
# 主模拟循环
|
||||
print("\n开始模拟循环...")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import json
|
|||
import pytest
|
||||
|
||||
from app import create_app
|
||||
from app.api import simulation as simulation_api
|
||||
from app.config import Config
|
||||
from app.services import simulation_manager as simulation_manager_module
|
||||
from app.services.simulation_manager import (
|
||||
|
|
@ -31,6 +32,63 @@ def _write_failed_state(root, simulation_id="sim_failed"):
|
|||
return simulation_id
|
||||
|
||||
|
||||
def _write_prepared_files(
|
||||
root,
|
||||
*,
|
||||
simulation_id="sim_paused",
|
||||
status="paused",
|
||||
config_generated=True,
|
||||
):
|
||||
sim_dir = root / simulation_id
|
||||
sim_dir.mkdir(parents=True)
|
||||
(sim_dir / "state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": status,
|
||||
"config_generated": config_generated,
|
||||
"entities_count": 2,
|
||||
"entity_types": ["Person"],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(sim_dir / "simulation_config.json").write_text("{}", encoding="utf-8")
|
||||
(sim_dir / "reddit_profiles.json").write_text(
|
||||
json.dumps([{"user_id": 1}, {"user_id": 2}]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(sim_dir / "twitter_profiles.csv").write_text(
|
||||
"user_id,name\n1,agent\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return simulation_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_generated", "expected_prepared"),
|
||||
[(True, True), (False, False)],
|
||||
)
|
||||
def test_paused_state_requires_generated_config(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
config_generated,
|
||||
expected_prepared,
|
||||
):
|
||||
simulation_id = _write_prepared_files(
|
||||
tmp_path,
|
||||
config_generated=config_generated,
|
||||
)
|
||||
monkeypatch.setattr(Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path))
|
||||
|
||||
is_prepared, info = simulation_api._check_simulation_prepared(simulation_id)
|
||||
|
||||
assert is_prepared is expected_prepared
|
||||
assert info["status"] == "paused"
|
||||
assert info["config_generated"] is config_generated
|
||||
if expected_prepared:
|
||||
assert info["profiles_count"] == 2
|
||||
|
||||
|
||||
def test_realtime_endpoints_expose_terminal_failure(tmp_path, monkeypatch):
|
||||
simulation_id = _write_failed_state(tmp_path)
|
||||
monkeypatch.setattr(Config, "OASIS_SIMULATION_DATA_DIR", str(tmp_path))
|
||||
|
|
|
|||
|
|
@ -270,6 +270,119 @@ def test_force_restart_does_not_continue_while_old_ingestion_is_pending(monkeypa
|
|||
assert cleanup_called == []
|
||||
|
||||
|
||||
def test_terminal_restart_cleans_old_logs_before_start(monkeypatch):
|
||||
simulation = SimpleNamespace(
|
||||
simulation_id="sim-terminal",
|
||||
project_id="proj-1",
|
||||
graph_id=None,
|
||||
status=SimulationStatus.READY,
|
||||
)
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
simulation_api,
|
||||
"SimulationManager",
|
||||
lambda: SimpleNamespace(get_simulation=lambda _simulation_id: simulation),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
simulation_api.SimulationRunner,
|
||||
"get_run_state",
|
||||
classmethod(
|
||||
lambda _cls, _simulation_id: SimulationRunState(
|
||||
simulation_id="sim-terminal",
|
||||
runner_status=RunnerStatus.COMPLETED,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
simulation_api.SimulationRunner,
|
||||
"cleanup_simulation_logs",
|
||||
classmethod(
|
||||
lambda _cls, _simulation_id: (
|
||||
events.append("cleanup") or {"success": True, "errors": []}
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
simulation_api.SimulationRunner,
|
||||
"start_simulation",
|
||||
classmethod(
|
||||
lambda _cls, **_kwargs: (
|
||||
events.append("start")
|
||||
or SimulationRunState(
|
||||
simulation_id="sim-terminal",
|
||||
runner_status=RunnerStatus.STARTING,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
simulation_api.ZepGraphMemoryManager,
|
||||
"get_updater",
|
||||
classmethod(lambda _cls, _simulation_id: None),
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/api/simulation/start",
|
||||
method="POST",
|
||||
json={"simulation_id": "sim-terminal"},
|
||||
):
|
||||
response = simulation_api.start_simulation()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"]["force_restarted"] is True
|
||||
assert events == ["cleanup", "start"]
|
||||
|
||||
|
||||
def test_terminal_restart_rejects_pending_graph_updates(monkeypatch):
|
||||
simulation = SimpleNamespace(
|
||||
simulation_id="sim-pending-terminal",
|
||||
project_id="proj-1",
|
||||
graph_id=None,
|
||||
status=SimulationStatus.READY,
|
||||
)
|
||||
cleanup_called = []
|
||||
monkeypatch.setattr(
|
||||
simulation_api,
|
||||
"SimulationManager",
|
||||
lambda: SimpleNamespace(get_simulation=lambda _simulation_id: simulation),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
simulation_api.SimulationRunner,
|
||||
"get_run_state",
|
||||
classmethod(
|
||||
lambda _cls, _simulation_id: SimulationRunState(
|
||||
simulation_id="sim-pending-terminal",
|
||||
runner_status=RunnerStatus.FAILED,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
simulation_api.SimulationRunner,
|
||||
"cleanup_simulation_logs",
|
||||
classmethod(
|
||||
lambda _cls, _simulation_id: cleanup_called.append(True)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
simulation_api.ZepGraphMemoryManager,
|
||||
"get_updater",
|
||||
classmethod(lambda _cls, _simulation_id: object()),
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/api/simulation/start",
|
||||
method="POST",
|
||||
json={"simulation_id": "sim-pending-terminal"},
|
||||
):
|
||||
response, status = simulation_api.start_simulation()
|
||||
|
||||
assert status == 409
|
||||
assert "pending graph memory updates" in response.get_json()["error"]
|
||||
assert cleanup_called == []
|
||||
|
||||
|
||||
def test_monitor_start_failure_terminates_the_spawned_process(monkeypatch, tmp_path):
|
||||
simulation_id = "sim-start-failure"
|
||||
sim_dir = tmp_path / "runs" / simulation_id
|
||||
|
|
@ -482,3 +595,45 @@ def test_shutdown_drain_failure_remains_failed_and_retryable(monkeypatch):
|
|||
SimulationRunner._cleanup_done = False
|
||||
SimulationRunner._graph_memory_enabled.pop(simulation_id, None)
|
||||
SimulationRunner._manual_stop_requests.discard(simulation_id)
|
||||
|
||||
|
||||
def test_shutdown_preserves_completed_state_without_pending_resources(monkeypatch):
|
||||
simulation_id = "sim-shutdown-completed"
|
||||
state = SimulationRunState(
|
||||
simulation_id=simulation_id,
|
||||
runner_status=RunnerStatus.COMPLETED,
|
||||
completed_at="2026-07-23T00:00:00",
|
||||
)
|
||||
|
||||
class FinishedProcess:
|
||||
def poll(self):
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
SimulationRunner,
|
||||
"get_run_state",
|
||||
classmethod(lambda _cls, _simulation_id: state),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runner_module.ZepGraphMemoryManager,
|
||||
"get_simulation_ids",
|
||||
classmethod(lambda _cls: []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runner_module.ZepGraphMemoryManager,
|
||||
"get_updater",
|
||||
classmethod(lambda _cls, _simulation_id: None),
|
||||
)
|
||||
|
||||
SimulationRunner._cleanup_done = False
|
||||
SimulationRunner._processes[simulation_id] = FinishedProcess()
|
||||
SimulationRunner._graph_memory_enabled.pop(simulation_id, None)
|
||||
try:
|
||||
SimulationRunner.cleanup_all_simulations()
|
||||
assert state.runner_status == RunnerStatus.COMPLETED
|
||||
assert state.completed_at == "2026-07-23T00:00:00"
|
||||
assert state.error is None
|
||||
finally:
|
||||
SimulationRunner._cleanup_done = False
|
||||
SimulationRunner._processes.pop(simulation_id, None)
|
||||
SimulationRunner._manual_stop_requests.discard(simulation_id)
|
||||
|
|
|
|||
|
|
@ -996,6 +996,7 @@ dependencies = [
|
|||
{ name = "flask-cors" },
|
||||
{ name = "httpx" },
|
||||
{ name = "openai" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pymupdf" },
|
||||
{ name = "python-dotenv" },
|
||||
|
|
@ -1026,6 +1027,7 @@ requires-dist = [
|
|||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "openai", specifier = ">=1.0.0" },
|
||||
{ name = "pipreqs", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
||||
{ name = "psutil", specifier = ">=5.9.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pymupdf", specifier = ">=1.24.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
|
|
|
|||
|
|
@ -106,9 +106,9 @@
|
|||
<!-- Main Content: Dual Timeline -->
|
||||
<div class="main-content-area" ref="scrollContainer">
|
||||
<!-- Timeline Header -->
|
||||
<div class="timeline-header" v-if="allActions.length > 0">
|
||||
<div class="timeline-header" v-if="chronologicalActions.length > 0">
|
||||
<div class="timeline-stats">
|
||||
<span class="total-count">TOTAL EVENTS: <span class="mono">{{ allActions.length }}</span></span>
|
||||
<span class="total-count">TOTAL EVENTS: <span class="mono">{{ chronologicalActions.length }}</span></span>
|
||||
<span class="platform-breakdown">
|
||||
<span class="breakdown-item twitter">
|
||||
<svg class="mini-icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
||||
|
|
@ -189,15 +189,32 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- LIKE_POST: 点赞帖子 -->
|
||||
<template v-if="action.action_type === 'LIKE_POST'">
|
||||
<!-- LIKE_POST / DISLIKE_POST: 点赞/踩帖子 -->
|
||||
<template v-if="action.action_type === 'LIKE_POST' || action.action_type === 'DISLIKE_POST'">
|
||||
<div class="like-info">
|
||||
<svg class="icon-small filled" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
|
||||
<span class="like-label">Liked @{{ action.action_args?.post_author_name || 'User' }}'s post</span>
|
||||
<span class="like-label">{{ action.action_type === 'DISLIKE_POST' ? 'Disliked' : 'Liked' }} @{{ action.action_args?.post_author_name || 'User' }}'s post</span>
|
||||
</div>
|
||||
<div v-if="action.action_args?.post_content" class="liked-content">
|
||||
"{{ truncateContent(action.action_args.post_content, 120) }}"
|
||||
</div>
|
||||
<div v-else-if="action.action_args?.post_id" class="comment-context">
|
||||
<span>Post #{{ action.action_args.post_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- LIKE_COMMENT / DISLIKE_COMMENT: 点赞/踩评论 -->
|
||||
<template v-if="action.action_type === 'LIKE_COMMENT' || action.action_type === 'DISLIKE_COMMENT'">
|
||||
<div class="like-info">
|
||||
<svg class="icon-small filled" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
|
||||
<span class="like-label">{{ action.action_type === 'DISLIKE_COMMENT' ? 'Disliked' : 'Liked' }} @{{ action.action_args?.comment_author_name || 'User' }}'s comment</span>
|
||||
</div>
|
||||
<div v-if="action.action_args?.comment_content" class="liked-content">
|
||||
"{{ truncateContent(action.action_args.comment_content, 120) }}"
|
||||
</div>
|
||||
<div v-else-if="action.action_args?.comment_id" class="comment-context">
|
||||
<span>Comment #{{ action.action_args.comment_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- CREATE_COMMENT: 发表评论 -->
|
||||
|
|
@ -224,7 +241,7 @@
|
|||
<template v-if="action.action_type === 'FOLLOW'">
|
||||
<div class="follow-info">
|
||||
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
|
||||
<span class="follow-label">Followed @{{ action.action_args?.target_user || action.action_args?.user_id || 'User' }}</span>
|
||||
<span class="follow-label">Followed @{{ action.action_args?.target_user_name || action.action_args?.target_user || action.action_args?.user_id || 'User' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -249,7 +266,7 @@
|
|||
</template>
|
||||
|
||||
<!-- 通用回退:未知类型或有 content 但未被上述处理 -->
|
||||
<div v-if="!['CREATE_POST', 'QUOTE_POST', 'REPOST', 'LIKE_POST', 'CREATE_COMMENT', 'SEARCH_POSTS', 'FOLLOW', 'UPVOTE_POST', 'DOWNVOTE_POST', 'DO_NOTHING'].includes(action.action_type) && action.action_args?.content" class="content-text">
|
||||
<div v-if="!['CREATE_POST', 'QUOTE_POST', 'REPOST', 'LIKE_POST', 'DISLIKE_POST', 'LIKE_COMMENT', 'DISLIKE_COMMENT', 'CREATE_COMMENT', 'SEARCH_POSTS', 'FOLLOW', 'UPVOTE_POST', 'DOWNVOTE_POST', 'DO_NOTHING'].includes(action.action_type) && action.action_args?.content" class="content-text">
|
||||
{{ action.action_args.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -262,7 +279,7 @@
|
|||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<div v-if="allActions.length === 0" class="waiting-state">
|
||||
<div v-if="chronologicalActions.length === 0" class="waiting-state">
|
||||
<div class="pulse-ring"></div>
|
||||
<span>Waiting for agent actions...</span>
|
||||
</div>
|
||||
|
|
@ -326,19 +343,21 @@ const allActions = ref([]) // 所有动作(增量累积)
|
|||
const actionIds = ref(new Set()) // 用于去重的动作ID集合
|
||||
const scrollContainer = ref(null)
|
||||
|
||||
const isDisplayableAction = (action) => action?.action_type !== 'DO_NOTHING'
|
||||
|
||||
// Computed
|
||||
// 按时间顺序显示动作(最新的在最后面,即底部)
|
||||
const chronologicalActions = computed(() => {
|
||||
return allActions.value
|
||||
return allActions.value.filter(isDisplayableAction)
|
||||
})
|
||||
|
||||
// 各平台动作计数
|
||||
const twitterActionsCount = computed(() => {
|
||||
return allActions.value.filter(a => a.platform === 'twitter').length
|
||||
return chronologicalActions.value.filter(a => a.platform === 'twitter').length
|
||||
})
|
||||
|
||||
const redditActionsCount = computed(() => {
|
||||
return allActions.value.filter(a => a.platform === 'reddit').length
|
||||
return chronologicalActions.value.filter(a => a.platform === 'reddit').length
|
||||
})
|
||||
|
||||
// 格式化模拟流逝时间(根据轮次和每轮分钟数计算)
|
||||
|
|
@ -598,8 +617,10 @@ const getActionTypeLabel = (type) => {
|
|||
'CREATE_POST': 'POST',
|
||||
'REPOST': 'REPOST',
|
||||
'LIKE_POST': 'LIKE',
|
||||
'DISLIKE_POST': 'DISLIKE',
|
||||
'CREATE_COMMENT': 'COMMENT',
|
||||
'LIKE_COMMENT': 'LIKE',
|
||||
'DISLIKE_COMMENT': 'DISLIKE',
|
||||
'DO_NOTHING': 'IDLE',
|
||||
'FOLLOW': 'FOLLOW',
|
||||
'SEARCH_POSTS': 'SEARCH',
|
||||
|
|
@ -615,8 +636,10 @@ const getActionTypeClass = (type) => {
|
|||
'CREATE_POST': 'badge-post',
|
||||
'REPOST': 'badge-action',
|
||||
'LIKE_POST': 'badge-action',
|
||||
'DISLIKE_POST': 'badge-action',
|
||||
'CREATE_COMMENT': 'badge-comment',
|
||||
'LIKE_COMMENT': 'badge-action',
|
||||
'DISLIKE_COMMENT': 'badge-action',
|
||||
'QUOTE_POST': 'badge-post',
|
||||
'FOLLOW': 'badge-meta',
|
||||
'SEARCH_POSTS': 'badge-meta',
|
||||
|
|
@ -1125,7 +1148,7 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
/* Info Blocks (Quote, Repost, etc) */
|
||||
.quoted-block, .repost-content {
|
||||
.quoted-block, .repost-content, .liked-content, .voted-content {
|
||||
background: #F9F9F9;
|
||||
border: 1px solid #EEE;
|
||||
padding: 10px 12px;
|
||||
|
|
|
|||
Loading…
Reference in New Issue