From 12669495bb6e5f79022c97bac62a33c84b3d1eaf Mon Sep 17 00:00:00 2001 From: Laurens Profittlich Date: Wed, 10 Jun 2026 12:28:40 +0700 Subject: [PATCH] i18n: translate all Chinese in codebase to English; add Dutch locale as default (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend (.vue, .js, .ts): - All Chinese in HTML comments, JS comments, CSS comments translated - All hardcoded Chinese UI strings (titles, alerts, console errors) translated - The few remaining Chinese fragments in Step2EnvSetup.vue (4) and Step4Report.vue (77) are intentional: stage-name identifiers that must match what the backend emits, and LLM-output parser patterns that match Chinese section headers in the LLM response. Backend (Python): - All # comments, """ and ''' docstrings translated - All log/print/raise/error message strings translated - All LLM system prompts (ONTOLOGY_SYSTEM_PROMPT, _PROMPT, _INSTRUCTION, _TEMPLATE, _TASK_PROMPT, _EXTRACTOR, _PLANNER, _REPORTER, _SUMMARY, _OUTLINE, _SYNTHESIS, _REWRITE, _SEARCH_PROMPT, _INTERVIEW, _REFLECTION, _REACT, _ROLE, _CONTEXT, _GUIDELINES, _AGENT, _MESSAGE, _EXAMPLE, _SYNTHESIZER, _WRITER, _CRITIQUE, _REVISION, _FEEDBACK, _PERSONA, _FORMAT, _JUDGE, _ROUTER, _SECTION_PLANNER, _PARSER, _EXTRACT, _RATIONALE variables) translated - stage-name string literals in zep_graph_memory_updater.py (return values used as Zep episode content) translated - LLM-output parser patterns in zep_tools.py and report_agent.py (e.g. text.match(/分析问题:/) and section headers like '### 【关键事实】') kept as Chinese, because they match the LLM's output format. i18n: - locales/zh.json: kept as source of truth for Chinese strings - locales/en.json: kept (was already a complete English translation) - locales/nl.json: NEW — full Dutch (Nederlands) translation of en.json (633 leaf keys, all interpolation placeholders preserved) - locales/languages.json: added 'nl' entry with label 'Nederlands' and llmInstruction 'Antwoord in het Nederlands.' Frontend i18n config: - frontend/src/i18n/index.js: default locale changed from 'zh' to 'nl', fallback locale from 'zh' to 'en'. Dutch is now the default UI language; English is the fallback. Misc: - package.json: description translated - docker-compose.yml: inline comment translated - README.md / README-ZH.md / locales/zh.json / locales/languages.json: Chinese content intentionally preserved (bilingual readme pointers and language-metadata source of truth) Files: 59 changed, +6810 / -6102 Co-authored-by: hermes --- backend/app/__init__.py | 66 +- backend/app/api/__init__.py | 3 +- backend/app/api/graph.py | 325 ++-- backend/app/api/report.py | 485 +++--- backend/app/api/simulation.py | 1320 ++++++++------- backend/app/config.py | 65 +- backend/app/models/__init__.py | 3 +- backend/app/models/project.py | 216 +-- backend/app/models/task.py | 105 +- backend/app/services/__init__.py | 9 +- backend/app/services/graph_builder.py | 234 +-- .../app/services/oasis_profile_generator.py | 624 +++---- backend/app/services/ontology_generator.py | 318 ++-- backend/app/services/report_agent.py | 1504 +++++++++-------- .../services/simulation_config_generator.py | 497 +++--- backend/app/services/simulation_ipc.py | 262 +-- backend/app/services/simulation_manager.py | 291 ++-- backend/app/services/simulation_runner.py | 696 ++++---- backend/app/services/text_processor.py | 34 +- backend/app/services/zep_entity_reader.py | 265 +-- .../app/services/zep_graph_memory_updater.py | 482 +++--- backend/app/services/zep_tools.py | 834 ++++----- backend/app/utils/__init__.py | 3 +- backend/app/utils/file_parser.py | 82 +- backend/app/utils/llm_client.py | 38 +- backend/app/utils/locale.py | 12 +- backend/app/utils/logger.py | 48 +- backend/app/utils/retry.py | 70 +- backend/run.py | 24 +- backend/scripts/action_logger.py | 110 +- backend/scripts/run_parallel_simulation.py | 612 +++---- backend/scripts/run_reddit_simulation.py | 260 +-- backend/scripts/run_twitter_simulation.py | 304 ++-- backend/scripts/test_profile_format.py | 104 +- docker-compose.yml | 2 +- frontend/index.html | 8 +- frontend/src/App.vue | 8 +- frontend/src/api/graph.js | 24 +- frontend/src/api/index.js | 28 +- frontend/src/api/report.js | 16 +- frontend/src/api/simulation.js | 59 +- frontend/src/components/GraphPanel.vue | 168 +- frontend/src/components/HistoryDatabase.vue | 248 +-- frontend/src/components/Step1GraphBuild.vue | 10 +- frontend/src/components/Step2EnvSetup.vue | 162 +- frontend/src/components/Step3Simulation.vue | 104 +- frontend/src/components/Step4Report.vue | 394 ++--- frontend/src/components/Step5Interaction.vue | 68 +- frontend/src/i18n/index.js | 4 +- frontend/src/store/pendingUpload.js | 5 +- frontend/src/views/Home.vue | 86 +- frontend/src/views/InteractionView.vue | 12 +- frontend/src/views/MainView.vue | 8 +- frontend/src/views/Process.vue | 382 ++--- frontend/src/views/ReportView.vue | 12 +- frontend/src/views/SimulationRunView.vue | 36 +- frontend/src/views/SimulationView.vue | 44 +- locales/languages.json | 4 + locales/nl.json | 665 ++++++++ package.json | 2 +- 60 files changed, 6786 insertions(+), 6078 deletions(-) create mode 100644 locales/nl.json diff --git a/backend/app/__init__.py b/backend/app/__init__.py index aba624bb..b461a094 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,12 +1,12 @@ """ -MiroFish Backend - Flask应用工厂 +MiroFish Backend - Flask application factory. """ import os import warnings -# 抑制 multiprocessing resource_tracker 的警告(来自第三方库如 transformers) -# 需要在所有其他导入之前设置 +# Suppress multiprocessing resource_tracker warnings (emitted by third-party libs such as transformers). +# Must be configured before any other import. warnings.filterwarnings("ignore", message=".*resource_tracker.*") from flask import Flask, request @@ -17,64 +17,64 @@ from .utils.logger import setup_logger, get_logger def create_app(config_class=Config): - """Flask应用工厂函数""" + """Flask application factory.""" app = Flask(__name__) app.config.from_object(config_class) - - # 设置JSON编码:确保中文直接显示(而不是 \uXXXX 格式) - # Flask >= 2.3 使用 app.json.ensure_ascii,旧版本使用 JSON_AS_ASCII 配置 + + # Configure JSON encoding so Chinese characters are emitted as-is (not escaped to \\uXXXX). + # Flask >= 2.3 uses app.json.ensure_ascii; older versions use the JSON_AS_ASCII config. if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'): app.json.ensure_ascii = False - - # 设置日志 + + # Set up logging logger = setup_logger('mirofish') - - # 只在 reloader 子进程中打印启动信息(避免 debug 模式下打印两次) + + # Only print startup information in the reloader child process (avoids duplicate logs under debug). is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true' debug_mode = app.config.get('DEBUG', False) should_log_startup = not debug_mode or is_reloader_process - + if should_log_startup: logger.info("=" * 50) - logger.info("MiroFish Backend 启动中...") + logger.info("MiroFish Backend starting...") logger.info("=" * 50) - - # 启用CORS + + # Enable CORS CORS(app, resources={r"/api/*": {"origins": "*"}}) - - # 注册模拟进程清理函数(确保服务器关闭时终止所有模拟进程) + + # Register a cleanup hook so that all simulation child processes are terminated + # when the server shuts down. from .services.simulation_runner import SimulationRunner SimulationRunner.register_cleanup() if should_log_startup: - logger.info("已注册模拟进程清理函数") - - # 请求日志中间件 + logger.info("Registered simulation process cleanup hook") + + # Request logging middleware @app.before_request def log_request(): logger = get_logger('mirofish.request') - logger.debug(f"请求: {request.method} {request.path}") + logger.debug(f"Request: {request.method} {request.path}") if request.content_type and 'json' in request.content_type: - logger.debug(f"请求体: {request.get_json(silent=True)}") - + logger.debug(f"Request body: {request.get_json(silent=True)}") + @app.after_request def log_response(response): logger = get_logger('mirofish.request') - logger.debug(f"响应: {response.status_code}") + logger.debug(f"Response: {response.status_code}") return response - - # 注册蓝图 + + # Register blueprints from .api import graph_bp, simulation_bp, report_bp app.register_blueprint(graph_bp, url_prefix='/api/graph') app.register_blueprint(simulation_bp, url_prefix='/api/simulation') app.register_blueprint(report_bp, url_prefix='/api/report') - - # 健康检查 + + # Health check @app.route('/health') def health(): return {'status': 'ok', 'service': 'MiroFish Backend'} - - if should_log_startup: - logger.info("MiroFish Backend 启动完成") - - return app + if should_log_startup: + logger.info("MiroFish Backend startup complete") + + return app diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index ffda743a..4035aa00 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,5 +1,5 @@ """ -API路由模块 +API route module. """ from flask import Blueprint @@ -11,4 +11,3 @@ report_bp = Blueprint('report', __name__) from . import graph # noqa: E402, F401 from . import simulation # noqa: E402, F401 from . import report # noqa: E402, F401 - diff --git a/backend/app/api/graph.py b/backend/app/api/graph.py index 151d5041..024a13f4 100644 --- a/backend/app/api/graph.py +++ b/backend/app/api/graph.py @@ -1,6 +1,6 @@ """ -图谱相关API路由 -采用项目上下文机制,服务端持久化状态 +Graph API routes. +Uses the project context mechanism so the server persists state between calls. """ import os @@ -19,27 +19,27 @@ from ..utils.locale import t, get_locale, set_locale from ..models.task import TaskManager, TaskStatus from ..models.project import ProjectManager, ProjectStatus -# 获取日志器 +# Get logger logger = get_logger('mirofish.api') def allowed_file(filename: str) -> bool: - """检查文件扩展名是否允许""" + """Check whether the file extension is in the allow-list.""" if not filename or '.' not in filename: return False ext = os.path.splitext(filename)[1].lower().lstrip('.') return ext in Config.ALLOWED_EXTENSIONS -# ============== 项目管理接口 ============== +# ============== Project management endpoints ============== @graph_bp.route('/project/', methods=['GET']) def get_project(project_id: str): """ - 获取项目详情 + Retrieve project details. """ project = ProjectManager.get_project(project_id) - + if not project: return jsonify({ "success": False, @@ -55,11 +55,11 @@ def get_project(project_id: str): @graph_bp.route('/project/list', methods=['GET']) def list_projects(): """ - 列出所有项目 + List all projects. """ limit = request.args.get('limit', 50, type=int) projects = ProjectManager.list_projects(limit=limit) - + return jsonify({ "success": True, "data": [p.to_dict() for p in projects], @@ -70,10 +70,10 @@ def list_projects(): @graph_bp.route('/project/', methods=['DELETE']) def delete_project(project_id: str): """ - 删除项目 + Delete a project. """ success = ProjectManager.delete_project(project_id) - + if not success: return jsonify({ "success": False, @@ -89,27 +89,27 @@ def delete_project(project_id: str): @graph_bp.route('/project//reset', methods=['POST']) def reset_project(project_id: str): """ - 重置项目状态(用于重新构建图谱) + Reset project state (used to rebuild the graph). """ project = ProjectManager.get_project(project_id) - + if not project: return jsonify({ "success": False, "error": t('api.projectNotFound', id=project_id) }), 404 - # 重置到本体已生成状态 + # Roll back to the "ontology generated" state if project.ontology: project.status = ProjectStatus.ONTOLOGY_GENERATED else: project.status = ProjectStatus.CREATED - + project.graph_id = None project.graph_build_task_id = None project.error = None ProjectManager.save_project(project) - + return jsonify({ "success": True, "message": t('api.projectReset', id=project_id), @@ -117,22 +117,22 @@ def reset_project(project_id: str): }) -# ============== 接口1:上传文件并生成本体 ============== +# ============== Endpoint 1: upload files and generate the ontology ============== @graph_bp.route('/ontology/generate', methods=['POST']) def generate_ontology(): """ - 接口1:上传文件,分析生成本体定义 - - 请求方式:multipart/form-data - - 参数: - files: 上传的文件(PDF/MD/TXT),可多个 - simulation_requirement: 模拟需求描述(必填) - project_name: 项目名称(可选) - additional_context: 额外说明(可选) - - 返回: + Endpoint 1: upload files, analyse them and produce an ontology definition. + + Request: multipart/form-data. + + Parameters: + files: uploaded files (PDF/MD/TXT), one or many. + simulation_requirement: description of the simulation to run (required). + project_name: project name (optional). + additional_context: extra notes (optional). + + Returns: { "success": true, "data": { @@ -148,84 +148,84 @@ def generate_ontology(): } """ try: - logger.info("=== 开始生成本体定义 ===") - - # 获取参数 + logger.info("=== Generating ontology definition ===") + + # Read parameters simulation_requirement = request.form.get('simulation_requirement', '') project_name = request.form.get('project_name', 'Unnamed Project') additional_context = request.form.get('additional_context', '') - - logger.debug(f"项目名称: {project_name}") - logger.debug(f"模拟需求: {simulation_requirement[:100]}...") - + + logger.debug(f"Project name: {project_name}") + logger.debug(f"Simulation requirement: {simulation_requirement[:100]}...") + if not simulation_requirement: return jsonify({ "success": False, "error": t('api.requireSimulationRequirement') }), 400 - - # 获取上传的文件 + + # Read uploaded files uploaded_files = request.files.getlist('files') if not uploaded_files or all(not f.filename for f in uploaded_files): return jsonify({ "success": False, "error": t('api.requireFileUpload') }), 400 - - # 创建项目 + + # Create the project project = ProjectManager.create_project(name=project_name) project.simulation_requirement = simulation_requirement - logger.info(f"创建项目: {project.project_id}") - - # 保存文件并提取文本 + logger.info(f"Created project: {project.project_id}") + + # Save files and extract text document_texts = [] all_text = "" - + for file in uploaded_files: if file and file.filename and allowed_file(file.filename): - # 保存文件到项目目录 + # Save the file inside the project directory file_info = ProjectManager.save_file_to_project( - project.project_id, - file, + project.project_id, + file, file.filename ) project.files.append({ "filename": file_info["original_filename"], "size": file_info["size"] }) - - # 提取文本 + + # Extract text text = FileParser.extract_text(file_info["path"]) text = TextProcessor.preprocess_text(text) document_texts.append(text) all_text += f"\n\n=== {file_info['original_filename']} ===\n{text}" - + if not document_texts: ProjectManager.delete_project(project.project_id) return jsonify({ "success": False, "error": t('api.noDocProcessed') }), 400 - - # 保存提取的文本 + + # Persist the extracted text project.total_text_length = len(all_text) ProjectManager.save_extracted_text(project.project_id, all_text) - logger.info(f"文本提取完成,共 {len(all_text)} 字符") - - # 生成本体 - logger.info("调用 LLM 生成本体定义...") + logger.info(f"Text extraction complete: {len(all_text)} characters") + + # Generate the ontology + logger.info("Calling LLM to generate ontology definition...") generator = OntologyGenerator() ontology = generator.generate( document_texts=document_texts, simulation_requirement=simulation_requirement, additional_context=additional_context if additional_context else None ) - - # 保存本体到项目 + + # Save the ontology onto the project entity_count = len(ontology.get("entity_types", [])) edge_count = len(ontology.get("edge_types", [])) - logger.info(f"本体生成完成: {entity_count} 个实体类型, {edge_count} 个关系类型") - + logger.info(f"Ontology generation complete: {entity_count} entity types, {edge_count} edge types") + project.ontology = { "entity_types": ontology.get("entity_types", []), "edge_types": ontology.get("edge_types", []) @@ -233,8 +233,8 @@ def generate_ontology(): project.analysis_summary = ontology.get("analysis_summary", "") project.status = ProjectStatus.ONTOLOGY_GENERATED ProjectManager.save_project(project) - logger.info(f"=== 本体生成完成 === 项目ID: {project.project_id}") - + logger.info(f"=== Ontology generation complete === Project ID: {project.project_id}") + return jsonify({ "success": True, "data": { @@ -246,7 +246,7 @@ def generate_ontology(): "total_text_length": project.total_text_length } }) - + except Exception as e: return jsonify({ "success": False, @@ -255,57 +255,57 @@ def generate_ontology(): }), 500 -# ============== 接口2:构建图谱 ============== +# ============== Endpoint 2: build the graph ============== @graph_bp.route('/build', methods=['POST']) def build_graph(): """ - 接口2:根据project_id构建图谱 - - 请求(JSON): + Endpoint 2: build the graph for a given project_id. + + Request (JSON): { - "project_id": "proj_xxxx", // 必填,来自接口1 - "graph_name": "图谱名称", // 可选 - "chunk_size": 500, // 可选,默认500 - "chunk_overlap": 50 // 可选,默认50 + "project_id": "proj_xxxx", // required, returned by endpoint 1 + "graph_name": "graph name", // optional + "chunk_size": 500, // optional, default 500 + "chunk_overlap": 50 // optional, default 50 } - - 返回: + + Returns: { "success": true, "data": { "project_id": "proj_xxxx", "task_id": "task_xxxx", - "message": "图谱构建任务已启动" + "message": "Graph build task started" } } """ try: - logger.info("=== 开始构建图谱 ===") - - # 检查配置 + logger.info("=== Starting graph build ===") + + # Validate config errors = [] if not Config.FALKORDB_HOST: errors.append(t('api.zepApiKeyMissing')) if errors: - logger.error(f"配置错误: {errors}") + logger.error(f"Configuration error: {errors}") return jsonify({ "success": False, "error": t('api.configError', details="; ".join(errors)) }), 500 - - # 解析请求 + + # Parse request data = request.get_json() or {} project_id = data.get('project_id') - logger.debug(f"请求参数: project_id={project_id}") - + logger.debug(f"Request parameters: project_id={project_id}") + if not project_id: return jsonify({ "success": False, "error": t('api.requireProjectId') }), 400 - - # 获取项目 + + # Look up the project project = ProjectManager.get_project(project_id) if not project: return jsonify({ @@ -313,116 +313,116 @@ def build_graph(): "error": t('api.projectNotFound', id=project_id) }), 404 - # 检查项目状态 - force = data.get('force', False) # 强制重新构建 - + # Check project state + force = data.get('force', False) # force a rebuild + if project.status == ProjectStatus.CREATED: return jsonify({ "success": False, "error": t('api.ontologyNotGenerated') }), 400 - + if project.status == ProjectStatus.GRAPH_BUILDING and not force: return jsonify({ "success": False, "error": t('api.graphBuilding'), "task_id": project.graph_build_task_id }), 400 - - # 如果强制重建,重置状态 + + # If forced, roll the project back to the "ontology generated" state if force and project.status in [ProjectStatus.GRAPH_BUILDING, ProjectStatus.FAILED, ProjectStatus.GRAPH_COMPLETED]: project.status = ProjectStatus.ONTOLOGY_GENERATED project.graph_id = None project.graph_build_task_id = None project.error = None - - # 获取配置 + + # Read configuration graph_name = data.get('graph_name', project.name or 'MiroFish Graph') chunk_size = data.get('chunk_size', project.chunk_size or Config.DEFAULT_CHUNK_SIZE) chunk_overlap = data.get('chunk_overlap', project.chunk_overlap or Config.DEFAULT_CHUNK_OVERLAP) - - # 更新项目配置 + + # Persist configuration on the project project.chunk_size = chunk_size project.chunk_overlap = chunk_overlap - - # 获取提取的文本 + + # Read the extracted text text = ProjectManager.get_extracted_text(project_id) if not text: return jsonify({ "success": False, "error": t('api.textNotFound') }), 400 - - # 获取本体 + + # Read the ontology ontology = project.ontology if not ontology: return jsonify({ "success": False, "error": t('api.ontologyNotFound') }), 400 - - # 创建异步任务 + + # Create the async task task_manager = TaskManager() - task_id = task_manager.create_task(f"构建图谱: {graph_name}") - logger.info(f"创建图谱构建任务: task_id={task_id}, project_id={project_id}") - - # 更新项目状态 + task_id = task_manager.create_task(f"Build graph: {graph_name}") + logger.info(f"Created graph build task: task_id={task_id}, project_id={project_id}") + + # Update project state project.status = ProjectStatus.GRAPH_BUILDING project.graph_build_task_id = task_id ProjectManager.save_project(project) - + # Capture locale before spawning background thread current_locale = get_locale() - # 启动后台任务 + # Background worker def build_task(): set_locale(current_locale) build_logger = get_logger('mirofish.build') try: - build_logger.info(f"[{task_id}] 开始构建图谱...") + build_logger.info(f"[{task_id}] Starting graph build...") task_manager.update_task( - task_id, + task_id, status=TaskStatus.PROCESSING, message=t('progress.initGraphService') ) - - # 创建图谱构建服务 + + # Create the graph builder service builder = GraphBuilderService(api_key=None) - - # 分块 + + # Chunk the text task_manager.update_task( task_id, message=t('progress.textChunking'), progress=5 ) chunks = TextProcessor.split_text( - text, - chunk_size=chunk_size, + text, + chunk_size=chunk_size, overlap=chunk_overlap ) total_chunks = len(chunks) - - # 创建图谱 + + # Create the graph task_manager.update_task( task_id, message=t('progress.creatingZepGraph'), progress=10 ) graph_id = builder.create_graph(name=graph_name) - - # 更新项目的graph_id + + # Save the graph_id onto the project project.graph_id = graph_id ProjectManager.save_project(project) - - # 设置本体 + + # Apply the ontology task_manager.update_task( task_id, message=t('progress.settingOntology'), progress=15 ) builder.set_ontology(graph_id, ontology) - - # 添加文本(progress_callback 签名是 (msg, progress_ratio)) + + # progress_callback signature: (msg, progress_ratio) def add_progress_callback(msg, progress_ratio): progress = 15 + int(progress_ratio * 40) # 15% - 55% task_manager.update_task( @@ -430,27 +430,27 @@ def build_graph(): message=msg, progress=progress ) - + task_manager.update_task( task_id, message=t('progress.addingChunks', count=total_chunks), progress=15 ) - + episode_uuids = builder.add_text_batches( - graph_id, + graph_id, chunks, batch_size=3, progress_callback=add_progress_callback ) - - # 等待Zep处理完成(查询每个episode的processed状态) + + # Wait for Zep/Graphiti to finish processing (poll each episode's processed flag) task_manager.update_task( task_id, message=t('progress.waitingZepProcess'), progress=55 ) - + def wait_progress_callback(msg, progress_ratio): progress = 55 + int(progress_ratio * 35) # 55% - 90% task_manager.update_task( @@ -458,26 +458,26 @@ def build_graph(): message=msg, progress=progress ) - + builder._wait_for_episodes(episode_uuids, wait_progress_callback) - - # 获取图谱数据 + + # Fetch the final graph data task_manager.update_task( task_id, message=t('progress.fetchingGraphData'), progress=95 ) graph_data = builder.get_graph_data(graph_id) - - # 更新项目状态 + + # Update project state project.status = ProjectStatus.GRAPH_COMPLETED ProjectManager.save_project(project) - + node_count = graph_data.get("node_count", 0) edge_count = graph_data.get("edge_count", 0) - build_logger.info(f"[{task_id}] 图谱构建完成: graph_id={graph_id}, 节点={node_count}, 边={edge_count}") - - # 完成 + build_logger.info(f"[{task_id}] Graph build complete: graph_id={graph_id}, nodes={node_count}, edges={edge_count}") + + # Mark task as complete task_manager.update_task( task_id, status=TaskStatus.COMPLETED, @@ -491,27 +491,27 @@ def build_graph(): "chunk_count": total_chunks } ) - + except Exception as e: - # 更新项目状态为失败 - build_logger.error(f"[{task_id}] 图谱构建失败: {str(e)}") + # Mark project as failed + build_logger.error(f"[{task_id}] Graph build failed: {str(e)}") build_logger.debug(traceback.format_exc()) - + project.status = ProjectStatus.FAILED project.error = str(e) ProjectManager.save_project(project) - + task_manager.update_task( task_id, status=TaskStatus.FAILED, message=t('progress.buildFailed', error=str(e)), error=traceback.format_exc() ) - - # 启动后台线程 + + # Launch background thread thread = threading.Thread(target=build_task, daemon=True) thread.start() - + return jsonify({ "success": True, "data": { @@ -520,7 +520,7 @@ def build_graph(): "message": t('api.graphBuildStarted', taskId=task_id) } }) - + except Exception as e: return jsonify({ "success": False, @@ -530,8 +530,7 @@ def build_graph(): - -# ============== 一键摄入接口 ============== +# ============== One-shot ingest endpoint ============== import threading import traceback @@ -640,7 +639,7 @@ def ingest_text(): # 4. Kick off async graph build (same path /build uses) task_manager = TaskManager() - task_id = task_manager.create_task(f"构建图谱: {graph_name}") + task_id = task_manager.create_task(f"Build graph: {graph_name}") project.status = ProjectStatus.GRAPH_BUILDING project.graph_build_task_id = task_id ProjectManager.save_project(project) @@ -759,21 +758,21 @@ def ingest_text(): {"success": False, "error": str(e), "traceback": traceback.format_exc()} ), 500 -# ============== 任务查询接口 ============== +# ============== Task query endpoints ============== @graph_bp.route('/task/', methods=['GET']) def get_task(task_id: str): """ - 查询任务状态 + Query a task's state. """ task = TaskManager().get_task(task_id) - + if not task: return jsonify({ "success": False, "error": t('api.taskNotFound', id=task_id) }), 404 - + return jsonify({ "success": True, "data": task.to_dict() @@ -783,10 +782,10 @@ def get_task(task_id: str): @graph_bp.route('/tasks', methods=['GET']) def list_tasks(): """ - 列出所有任务 + List all tasks. """ tasks = TaskManager().list_tasks() - + return jsonify({ "success": True, "data": [t.to_dict() for t in tasks], @@ -794,12 +793,12 @@ def list_tasks(): }) -# ============== 图谱数据接口 ============== +# ============== Graph data endpoints ============== @graph_bp.route('/data/', methods=['GET']) def get_graph_data(graph_id: str): """ - 获取图谱数据(节点和边) + Get graph data (nodes and edges). """ try: if not Config.FALKORDB_HOST: @@ -807,15 +806,15 @@ def get_graph_data(graph_id: str): "success": False, "error": t('api.zepApiKeyMissing') }), 500 - + builder = GraphBuilderService(api_key=None) graph_data = builder.get_graph_data(graph_id) - + return jsonify({ "success": True, "data": graph_data }) - + except Exception as e: return jsonify({ "success": False, @@ -827,7 +826,7 @@ def get_graph_data(graph_id: str): @graph_bp.route('/delete/', methods=['DELETE']) def delete_graph(graph_id: str): """ - 删除Zep图谱 + Delete a Zep/Graphiti graph. """ try: if not Config.FALKORDB_HOST: @@ -835,15 +834,15 @@ def delete_graph(graph_id: str): "success": False, "error": t('api.zepApiKeyMissing') }), 500 - + builder = GraphBuilderService(api_key=None) builder.delete_graph(graph_id) - + return jsonify({ "success": True, "message": t('api.graphDeleted', id=graph_id) }) - + except Exception as e: return jsonify({ "success": False, diff --git a/backend/app/api/report.py b/backend/app/api/report.py index d7f2a4d0..90ccadf5 100644 --- a/backend/app/api/report.py +++ b/backend/app/api/report.py @@ -1,6 +1,7 @@ """ -Report API路由 -提供模拟报告生成、获取、对话等接口 +Report API routes. +Exposes endpoints for generating simulation reports, fetching them and chatting +with the report agent. """ import os @@ -20,36 +21,36 @@ from ..utils.locale import t, get_locale, set_locale logger = get_logger('mirofish.api.report') -# ============== 报告生成接口 ============== +# ============== Report generation endpoint ============== @report_bp.route('/generate', methods=['POST']) def generate_report(): """ - 生成模拟分析报告(异步任务) - - 这是一个耗时操作,接口会立即返回task_id, - 使用 GET /api/report/generate/status 查询进度 - - 请求(JSON): + Generate a simulation analysis report (async task). + + This is a long-running operation; the endpoint returns a task_id immediately + and progress can be queried via GET /api/report/generate/status. + + Request (JSON): { - "simulation_id": "sim_xxxx", // 必填,模拟ID - "force_regenerate": false // 可选,强制重新生成 + "simulation_id": "sim_xxxx", // required, simulation ID + "force_regenerate": false // optional, force regeneration } - - 返回: + + Returns: { "success": true, "data": { "simulation_id": "sim_xxxx", "task_id": "task_xxxx", "status": "generating", - "message": "报告生成任务已启动" + "message": "Report generation task started" } } """ try: data = request.get_json() or {} - + simulation_id = data.get('simulation_id') if not simulation_id: return jsonify({ @@ -58,18 +59,18 @@ def generate_report(): }), 400 force_regenerate = data.get('force_regenerate', False) - - # 获取模拟信息 + + # Look up the simulation manager = SimulationManager() state = manager.get_simulation(simulation_id) - + if not state: return jsonify({ "success": False, "error": t('api.simulationNotFound', id=simulation_id) }), 404 - # 检查是否已有报告 + # Check whether a report already exists if not force_regenerate: existing_report = ReportManager.get_report_by_simulation(simulation_id) if existing_report and existing_report.status == ReportStatus.COMPLETED: @@ -83,34 +84,34 @@ def generate_report(): "already_generated": True } }) - - # 获取项目信息 + + # Look up the project project = ProjectManager.get_project(state.project_id) if not project: return jsonify({ "success": False, "error": t('api.projectNotFound', id=state.project_id) }), 404 - + graph_id = state.graph_id or project.graph_id if not graph_id: return jsonify({ "success": False, "error": t('api.missingGraphIdEnsure') }), 400 - + simulation_requirement = project.simulation_requirement if not simulation_requirement: return jsonify({ "success": False, "error": t('api.missingSimRequirement') }), 400 - - # 提前生成 report_id,以便立即返回给前端 + + # Generate report_id up front so we can return it to the client immediately import uuid report_id = f"report_{uuid.uuid4().hex[:12]}" - - # 创建异步任务 + + # Create the async task task_manager = TaskManager() task_id = task_manager.create_task( task_type="report_generate", @@ -120,11 +121,11 @@ def generate_report(): "report_id": report_id } ) - + # Capture locale before spawning background thread current_locale = get_locale() - # 定义后台任务 + # Background worker def run_generate(): set_locale(current_locale) try: @@ -134,31 +135,31 @@ def generate_report(): progress=0, message=t('api.initReportAgent') ) - - # 创建Report Agent + + # Create the Report Agent agent = ReportAgent( graph_id=graph_id, simulation_id=simulation_id, simulation_requirement=simulation_requirement ) - - # 进度回调 + + # Progress callback def progress_callback(stage, progress, message): task_manager.update_task( task_id, progress=progress, message=f"[{stage}] {message}" ) - - # 生成报告(传入预先生成的 report_id) + + # Generate the report (passing in the pre-generated report_id) report = agent.generate_report( progress_callback=progress_callback, report_id=report_id ) - - # 保存报告 + + # Persist the report ReportManager.save_report(report) - + if report.status == ReportStatus.COMPLETED: task_manager.complete_task( task_id, @@ -170,15 +171,15 @@ def generate_report(): ) else: task_manager.fail_task(task_id, report.error or t('api.reportGenerateFailed')) - + except Exception as e: - logger.error(f"报告生成失败: {str(e)}") + logger.error(f"Report generation failed: {str(e)}") task_manager.fail_task(task_id, str(e)) - - # 启动后台线程 + + # Launch background thread thread = threading.Thread(target=run_generate, daemon=True) thread.start() - + return jsonify({ "success": True, "data": { @@ -190,9 +191,9 @@ def generate_report(): "already_generated": False } }) - + except Exception as e: - logger.error(f"启动报告生成任务失败: {str(e)}") + logger.error(f"Failed to start report generation task: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -203,15 +204,15 @@ def generate_report(): @report_bp.route('/generate/status', methods=['POST']) def get_generate_status(): """ - 查询报告生成任务进度 - - 请求(JSON): + Query report-generation progress. + + Request (JSON): { - "task_id": "task_xxxx", // 可选,generate返回的task_id - "simulation_id": "sim_xxxx" // 可选,模拟ID + "task_id": "task_xxxx", // optional, task_id returned by /generate + "simulation_id": "sim_xxxx" // optional, simulation ID } - - 返回: + + Returns: { "success": true, "data": { @@ -224,11 +225,11 @@ def get_generate_status(): """ try: data = request.get_json() or {} - + task_id = data.get('task_id') simulation_id = data.get('simulation_id') - - # 如果提供了simulation_id,先检查是否已有完成的报告 + + # If a simulation_id was provided, first check whether a completed report already exists if simulation_id: existing_report = ReportManager.get_report_by_simulation(simulation_id) if existing_report and existing_report.status == ReportStatus.COMPLETED: @@ -243,43 +244,43 @@ def get_generate_status(): "already_completed": True } }) - + if not task_id: return jsonify({ "success": False, "error": t('api.requireTaskOrSimId') }), 400 - + task_manager = TaskManager() task = task_manager.get_task(task_id) - + if not task: return jsonify({ "success": False, "error": t('api.taskNotFound', id=task_id) }), 404 - + return jsonify({ "success": True, "data": task.to_dict() }) - + except Exception as e: - logger.error(f"查询任务状态失败: {str(e)}") + logger.error(f"Failed to query task status: {str(e)}") return jsonify({ "success": False, "error": str(e) }), 500 -# ============== 报告获取接口 ============== +# ============== Report retrieval endpoints ============== @report_bp.route('/', methods=['GET']) def get_report(report_id: str): """ - 获取报告详情 - - 返回: + Fetch report details. + + Returns: { "success": true, "data": { @@ -295,20 +296,20 @@ def get_report(report_id: str): """ try: report = ReportManager.get_report(report_id) - + if not report: return jsonify({ "success": False, "error": t('api.reportNotFound', id=report_id) }), 404 - + return jsonify({ "success": True, "data": report.to_dict() }) - + except Exception as e: - logger.error(f"获取报告失败: {str(e)}") + logger.error(f"Failed to fetch report: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -319,9 +320,9 @@ def get_report(report_id: str): @report_bp.route('/by-simulation/', methods=['GET']) def get_report_by_simulation(simulation_id: str): """ - 根据模拟ID获取报告 - - 返回: + Fetch the report for a given simulation ID. + + Returns: { "success": true, "data": { @@ -332,22 +333,22 @@ def get_report_by_simulation(simulation_id: str): """ try: report = ReportManager.get_report_by_simulation(simulation_id) - + if not report: return jsonify({ "success": False, "error": t('api.noReportForSim', id=simulation_id), "has_report": False }), 404 - + return jsonify({ "success": True, "data": report.to_dict(), "has_report": True }) - + except Exception as e: - logger.error(f"获取报告失败: {str(e)}") + logger.error(f"Failed to fetch report: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -358,13 +359,13 @@ def get_report_by_simulation(simulation_id: str): @report_bp.route('/list', methods=['GET']) def list_reports(): """ - 列出所有报告 - - Query参数: - simulation_id: 按模拟ID过滤(可选) - limit: 返回数量限制(默认50) - - 返回: + List all reports. + + Query parameters: + simulation_id: filter by simulation ID (optional). + limit: max number of results to return (default 50). + + Returns: { "success": true, "data": [...], @@ -374,20 +375,20 @@ def list_reports(): try: simulation_id = request.args.get('simulation_id') limit = request.args.get('limit', 50, type=int) - + reports = ReportManager.list_reports( simulation_id=simulation_id, limit=limit ) - + return jsonify({ "success": True, "data": [r.to_dict() for r in reports], "count": len(reports) }) - + except Exception as e: - logger.error(f"列出报告失败: {str(e)}") + logger.error(f"Failed to list reports: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -398,42 +399,42 @@ def list_reports(): @report_bp.route('//download', methods=['GET']) def download_report(report_id: str): """ - 下载报告(Markdown格式) - - 返回Markdown文件 + Download a report (Markdown format). + + Returns the report as a Markdown file. """ try: report = ReportManager.get_report(report_id) - + if not report: return jsonify({ "success": False, "error": t('api.reportNotFound', id=report_id) }), 404 - + md_path = ReportManager._get_report_markdown_path(report_id) - + if not os.path.exists(md_path): - # 如果MD文件不存在,生成一个临时文件 + # If the markdown file is missing, create a temporary one on the fly import tempfile with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: f.write(report.markdown_content) temp_path = f.name - + return send_file( temp_path, as_attachment=True, download_name=f"{report_id}.md" ) - + return send_file( md_path, as_attachment=True, download_name=f"{report_id}.md" ) - + except Exception as e: - logger.error(f"下载报告失败: {str(e)}") + logger.error(f"Failed to download report: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -443,23 +444,23 @@ def download_report(report_id: str): @report_bp.route('/', methods=['DELETE']) def delete_report(report_id: str): - """删除报告""" + """Delete a report.""" try: success = ReportManager.delete_report(report_id) - + if not success: return jsonify({ "success": False, "error": t('api.reportNotFound', id=report_id) }), 404 - + return jsonify({ "success": True, "message": t('api.reportDeleted', id=report_id) }) - + except Exception as e: - logger.error(f"删除报告失败: {str(e)}") + logger.error(f"Failed to delete report: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -467,42 +468,43 @@ def delete_report(report_id: str): }), 500 -# ============== Report Agent对话接口 ============== +# ============== Report Agent chat endpoint ============== @report_bp.route('/chat', methods=['POST']) def chat_with_report_agent(): """ - 与Report Agent对话 - - Report Agent可以在对话中自主调用检索工具来回答问题 - - 请求(JSON): + Chat with the Report Agent. + + The Report Agent can autonomously call retrieval tools to answer questions + while it converses with the user. + + Request (JSON): { - "simulation_id": "sim_xxxx", // 必填,模拟ID - "message": "请解释一下舆情走向", // 必填,用户消息 - "chat_history": [ // 可选,对话历史 + "simulation_id": "sim_xxxx", // required, simulation ID + "message": "Please explain the public-opinion trend", // required, user message + "chat_history": [ // optional, conversation history {"role": "user", "content": "..."}, {"role": "assistant", "content": "..."} ] } - - 返回: + + Returns: { "success": true, "data": { - "response": "Agent回复...", - "tool_calls": [调用的工具列表], - "sources": [信息来源] + "response": "Agent reply ...", + "tool_calls": [list of tools invoked], + "sources": [information sources] } } """ try: data = request.get_json() or {} - + simulation_id = data.get('simulation_id') message = data.get('message') chat_history = data.get('chat_history', []) - + if not simulation_id: return jsonify({ "success": False, @@ -514,11 +516,11 @@ def chat_with_report_agent(): "success": False, "error": t('api.requireMessage') }), 400 - - # 获取模拟和项目信息 + + # Look up the simulation and the project manager = SimulationManager() state = manager.get_simulation(simulation_id) - + if not state: return jsonify({ "success": False, @@ -531,32 +533,32 @@ def chat_with_report_agent(): "success": False, "error": t('api.projectNotFound', id=state.project_id) }), 404 - + graph_id = state.graph_id or project.graph_id if not graph_id: return jsonify({ "success": False, "error": t('api.missingGraphId') }), 400 - + simulation_requirement = project.simulation_requirement or "" - - # 创建Agent并进行对话 + + # Create the agent and run the chat agent = ReportAgent( graph_id=graph_id, simulation_id=simulation_id, simulation_requirement=simulation_requirement ) - + result = agent.chat(message=message, chat_history=chat_history) - + return jsonify({ "success": True, "data": result }) - + except Exception as e: - logger.error(f"对话失败: {str(e)}") + logger.error(f"Chat failed: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -564,42 +566,42 @@ def chat_with_report_agent(): }), 500 -# ============== 报告进度与分章节接口 ============== +# ============== Report progress and per-section endpoints ============== @report_bp.route('//progress', methods=['GET']) def get_report_progress(report_id: str): """ - 获取报告生成进度(实时) - - 返回: + Get the (live) report-generation progress. + + Returns: { "success": true, "data": { "status": "generating", "progress": 45, - "message": "正在生成章节: 关键发现", - "current_section": "关键发现", - "completed_sections": ["执行摘要", "模拟背景"], + "message": "Generating section: Key Findings", + "current_section": "Key Findings", + "completed_sections": ["Executive Summary", "Simulation Background"], "updated_at": "2025-12-09T..." } } """ try: progress = ReportManager.get_progress(report_id) - + if not progress: return jsonify({ "success": False, "error": t('api.reportProgressNotAvail', id=report_id) }), 404 - + return jsonify({ "success": True, "data": progress }) - + except Exception as e: - logger.error(f"获取报告进度失败: {str(e)}") + logger.error(f"Failed to fetch report progress: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -610,11 +612,12 @@ def get_report_progress(report_id: str): @report_bp.route('//sections', methods=['GET']) def get_report_sections(report_id: str): """ - 获取已生成的章节列表(分章节输出) - - 前端可以轮询此接口获取已生成的章节内容,无需等待整个报告完成 - - 返回: + Get the list of already-generated sections (per-section output). + + The frontend can poll this endpoint to stream section content without + waiting for the whole report to finish. + + Returns: { "success": true, "data": { @@ -623,7 +626,7 @@ def get_report_sections(report_id: str): { "filename": "section_01.md", "section_index": 1, - "content": "## 执行摘要\\n\\n..." + "content": "## Executive Summary\n\n..." }, ... ], @@ -634,11 +637,11 @@ def get_report_sections(report_id: str): """ try: sections = ReportManager.get_generated_sections(report_id) - - # 获取报告状态 + + # Read the report status report = ReportManager.get_report(report_id) is_complete = report is not None and report.status == ReportStatus.COMPLETED - + return jsonify({ "success": True, "data": { @@ -648,9 +651,9 @@ def get_report_sections(report_id: str): "is_complete": is_complete } }) - + except Exception as e: - logger.error(f"获取章节列表失败: {str(e)}") + logger.error(f"Failed to list sections: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -661,29 +664,29 @@ def get_report_sections(report_id: str): @report_bp.route('//section/', methods=['GET']) def get_single_section(report_id: str, section_index: int): """ - 获取单个章节内容 - - 返回: + Get the content of a single section. + + Returns: { "success": true, "data": { "filename": "section_01.md", - "content": "## 执行摘要\\n\\n..." + "content": "## Executive Summary\n\n..." } } """ try: section_path = ReportManager._get_section_path(report_id, section_index) - + if not os.path.exists(section_path): return jsonify({ "success": False, "error": t('api.sectionNotFound', index=f"{section_index:02d}") }), 404 - + with open(section_path, 'r', encoding='utf-8') as f: content = f.read() - + return jsonify({ "success": True, "data": { @@ -692,9 +695,9 @@ def get_single_section(report_id: str, section_index: int): "content": content } }) - + except Exception as e: - logger.error(f"获取章节内容失败: {str(e)}") + logger.error(f"Failed to fetch section content: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -702,16 +705,16 @@ def get_single_section(report_id: str, section_index: int): }), 500 -# ============== 报告状态检查接口 ============== +# ============== Report status check endpoint ============== @report_bp.route('/check/', methods=['GET']) def check_report_status(simulation_id: str): """ - 检查模拟是否有报告,以及报告状态 - - 用于前端判断是否解锁Interview功能 - - 返回: + Check whether a simulation has a report, and its status. + + Used by the frontend to decide whether the Interview feature is unlocked. + + Returns: { "success": true, "data": { @@ -725,14 +728,14 @@ def check_report_status(simulation_id: str): """ try: report = ReportManager.get_report_by_simulation(simulation_id) - + has_report = report is not None report_status = report.status.value if report else None report_id = report.report_id if report else None - - # 只有报告完成后才解锁interview + + # The Interview feature unlocks only when the report is complete interview_unlocked = has_report and report.status == ReportStatus.COMPLETED - + return jsonify({ "success": True, "data": { @@ -743,9 +746,9 @@ def check_report_status(simulation_id: str): "interview_unlocked": interview_unlocked } }) - + except Exception as e: - logger.error(f"检查报告状态失败: {str(e)}") + logger.error(f"Failed to check report status: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -753,22 +756,22 @@ def check_report_status(simulation_id: str): }), 500 -# ============== Agent 日志接口 ============== +# ============== Agent log endpoint ============== @report_bp.route('//agent-log', methods=['GET']) def get_agent_log(report_id: str): """ - 获取 Report Agent 的详细执行日志 - - 实时获取报告生成过程中的每一步动作,包括: - - 报告开始、规划开始/完成 - - 每个章节的开始、工具调用、LLM响应、完成 - - 报告完成或失败 - - Query参数: - from_line: 从第几行开始读取(可选,默认0,用于增量获取) - - 返回: + Fetch the Report Agent's detailed execution log. + + Streams every step of the report-generation process, including: + - report start, planning start/finish + - each section's start, tool calls, LLM responses, finish + - report completion or failure + + Query parameters: + from_line: line number to start reading from (optional, default 0, used for incremental reads) + + Returns: { "success": true, "data": { @@ -779,7 +782,7 @@ def get_agent_log(report_id: str): "report_id": "report_xxxx", "action": "tool_call", "stage": "generating", - "section_title": "执行摘要", + "section_title": "Executive Summary", "section_index": 1, "details": { "tool_name": "insight_forge", @@ -797,16 +800,16 @@ def get_agent_log(report_id: str): """ try: from_line = request.args.get('from_line', 0, type=int) - + log_data = ReportManager.get_agent_log(report_id, from_line=from_line) - + return jsonify({ "success": True, "data": log_data }) - + except Exception as e: - logger.error(f"获取Agent日志失败: {str(e)}") + logger.error(f"Failed to fetch agent log: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -817,9 +820,9 @@ def get_agent_log(report_id: str): @report_bp.route('//agent-log/stream', methods=['GET']) def stream_agent_log(report_id: str): """ - 获取完整的 Agent 日志(一次性获取全部) - - 返回: + Get the full agent log (one-shot dump). + + Returns: { "success": true, "data": { @@ -830,7 +833,7 @@ def stream_agent_log(report_id: str): """ try: logs = ReportManager.get_agent_log_stream(report_id) - + return jsonify({ "success": True, "data": { @@ -838,9 +841,9 @@ def stream_agent_log(report_id: str): "count": len(logs) } }) - + except Exception as e: - logger.error(f"获取Agent日志失败: {str(e)}") + logger.error(f"Failed to fetch agent log: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -848,27 +851,27 @@ def stream_agent_log(report_id: str): }), 500 -# ============== 控制台日志接口 ============== +# ============== Console log endpoint ============== @report_bp.route('//console-log', methods=['GET']) def get_console_log(report_id: str): """ - 获取 Report Agent 的控制台输出日志 - - 实时获取报告生成过程中的控制台输出(INFO、WARNING等), - 这与 agent-log 接口返回的结构化 JSON 日志不同, - 是纯文本格式的控制台风格日志。 - - Query参数: - from_line: 从第几行开始读取(可选,默认0,用于增量获取) - - 返回: + Fetch the Report Agent's console-style log output. + + Streams the console output (INFO, WARNING, etc.) emitted during report + generation. Unlike the structured JSON returned by the agent-log endpoint, + this is plain text in console format. + + Query parameters: + from_line: line number to start reading from (optional, default 0, used for incremental reads) + + Returns: { "success": true, "data": { "logs": [ - "[19:46:14] INFO: 搜索完成: 找到 15 条相关事实", - "[19:46:14] INFO: 图谱搜索: graph_id=xxx, query=...", + "[19:46:14] INFO: Search complete: found 15 related facts", + "[19:46:14] INFO: Graph search: graph_id=xxx, query=...", ... ], "total_lines": 100, @@ -879,16 +882,16 @@ def get_console_log(report_id: str): """ try: from_line = request.args.get('from_line', 0, type=int) - + log_data = ReportManager.get_console_log(report_id, from_line=from_line) - + return jsonify({ "success": True, "data": log_data }) - + except Exception as e: - logger.error(f"获取控制台日志失败: {str(e)}") + logger.error(f"Failed to fetch console log: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -899,9 +902,9 @@ def get_console_log(report_id: str): @report_bp.route('//console-log/stream', methods=['GET']) def stream_console_log(report_id: str): """ - 获取完整的控制台日志(一次性获取全部) - - 返回: + Get the full console log (one-shot dump). + + Returns: { "success": true, "data": { @@ -912,7 +915,7 @@ def stream_console_log(report_id: str): """ try: logs = ReportManager.get_console_log_stream(report_id) - + return jsonify({ "success": True, "data": { @@ -920,9 +923,9 @@ def stream_console_log(report_id: str): "count": len(logs) } }) - + except Exception as e: - logger.error(f"获取控制台日志失败: {str(e)}") + logger.error(f"Failed to fetch console log: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -930,49 +933,49 @@ def stream_console_log(report_id: str): }), 500 -# ============== 工具调用接口(供调试使用)============== +# ============== Tool-call endpoints (for debugging) ============== @report_bp.route('/tools/search', methods=['POST']) def search_graph_tool(): """ - 图谱搜索工具接口(供调试使用) - - 请求(JSON): + Graph search tool endpoint (for debugging). + + Request (JSON): { "graph_id": "mirofish_xxxx", - "query": "搜索查询", + "query": "search query", "limit": 10 } """ try: data = request.get_json() or {} - + graph_id = data.get('graph_id') query = data.get('query') limit = data.get('limit', 10) - + if not graph_id or not query: return jsonify({ "success": False, "error": t('api.requireGraphIdAndQuery') }), 400 - + from ..services.zep_tools import ZepToolsService - + tools = ZepToolsService() result = tools.search_graph( graph_id=graph_id, query=query, limit=limit ) - + return jsonify({ "success": True, "data": result.to_dict() }) - + except Exception as e: - logger.error(f"图谱搜索失败: {str(e)}") + logger.error(f"Graph search failed: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -983,36 +986,36 @@ def search_graph_tool(): @report_bp.route('/tools/statistics', methods=['POST']) def get_graph_statistics_tool(): """ - 图谱统计工具接口(供调试使用) - - 请求(JSON): + Graph statistics tool endpoint (for debugging). + + Request (JSON): { "graph_id": "mirofish_xxxx" } """ 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 - + from ..services.zep_tools import ZepToolsService - + tools = ZepToolsService() result = tools.get_graph_statistics(graph_id) - + return jsonify({ "success": True, "data": result }) - + except Exception as e: - logger.error(f"获取图谱统计失败: {str(e)}") + logger.error(f"Failed to fetch graph statistics: {str(e)}") return jsonify({ "success": False, "error": str(e), diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index d52d7030..73f556af 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -1,6 +1,6 @@ """ -模拟相关API路由 -Step2: Zep实体读取与过滤、OASIS模拟准备与运行(全程自动化) +Simulation-related API routes +Step2: Zep entity reading & filtering, OASIS simulation prep & run (fully automated) """ import os @@ -20,41 +20,42 @@ from ..models.project import ProjectManager logger = get_logger('mirofish.api.simulation') -# Interview prompt 优化前缀 -# 添加此前缀可以避免Agent调用工具,直接用文本回复 -INTERVIEW_PROMPT_PREFIX = "结合你的人设、所有的过往记忆与行动,不调用任何工具直接用文本回复我:" +# Interview prompt optimization prefix +# Adding this prefix prevents the Agent from calling tools and makes it reply in plain text +INTERVIEW_PROMPT_PREFIX = "Combine your persona, all past memories and actions, and reply directly in text without calling any tools: " def optimize_interview_prompt(prompt: str) -> str: """ - 优化Interview提问,添加前缀避免Agent调用工具 - + Optimize the interview prompt by adding a prefix that prevents the Agent from calling tools + Args: - prompt: 原始提问 - + prompt: Original prompt + Returns: - 优化后的提问 + Optimized prompt """ if not prompt: return prompt - # 避免重复添加前缀 + # Avoid adding the prefix repeatedly if prompt.startswith(INTERVIEW_PROMPT_PREFIX): return prompt return f"{INTERVIEW_PROMPT_PREFIX}{prompt}" -# ============== 实体读取接口 ============== +# ============== Entity reading endpoints ============== @simulation_bp.route('/entities/', methods=['GET']) def get_graph_entities(graph_id: str): """ - 获取图谱中的所有实体(已过滤) - - 只返回符合预定义实体类型的节点(Labels不只是Entity的节点) - - Query参数: - entity_types: 逗号分隔的实体类型列表(可选,用于进一步过滤) - enrich: 是否获取相关边信息(默认true) + Fetch all entities in the graph (already filtered) + + Only returns nodes whose type matches one of the predefined entity types + (nodes whose labels are not just "Entity") + + Query params: + entity_types: Comma-separated list of entity types to filter by (optional, further narrows the result) + enrich: Whether to also fetch related edge info (default true) """ try: if not Config.FALKORDB_HOST: @@ -67,7 +68,7 @@ def get_graph_entities(graph_id: str): 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"获取图谱实体: graph_id={graph_id}, entity_types={entity_types}, enrich={enrich}") + logger.info(f"Fetching graph entities: graph_id={graph_id}, entity_types={entity_types}, enrich={enrich}") reader = ZepEntityReader() result = reader.filter_defined_entities( @@ -82,7 +83,7 @@ def get_graph_entities(graph_id: str): }) except Exception as e: - logger.error(f"获取图谱实体失败: {str(e)}") + logger.error(f"Failed to fetch graph entities: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -92,7 +93,7 @@ def get_graph_entities(graph_id: str): @simulation_bp.route('/entities//', methods=['GET']) def get_entity_detail(graph_id: str, entity_uuid: str): - """获取单个实体的详细信息""" + """Fetch the detailed information of a single entity""" try: if not Config.FALKORDB_HOST: return jsonify({ @@ -115,7 +116,7 @@ def get_entity_detail(graph_id: str, entity_uuid: str): }) except Exception as e: - logger.error(f"获取实体详情失败: {str(e)}") + logger.error(f"Failed to fetch entity details: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -125,7 +126,7 @@ def get_entity_detail(graph_id: str, entity_uuid: str): @simulation_bp.route('/entities//by-type/', methods=['GET']) def get_entities_by_type(graph_id: str, entity_type: str): - """获取指定类型的所有实体""" + """Fetch all entities of a specified type""" try: if not Config.FALKORDB_HOST: return jsonify({ @@ -152,7 +153,7 @@ def get_entities_by_type(graph_id: str, entity_type: str): }) except Exception as e: - logger.error(f"获取实体失败: {str(e)}") + logger.error(f"Failed to fetch entities: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -160,24 +161,24 @@ def get_entities_by_type(graph_id: str, entity_type: str): }), 500 -# ============== 模拟管理接口 ============== +# ============== Simulation management endpoints ============== @simulation_bp.route('/create', methods=['POST']) def create_simulation(): """ - 创建新的模拟 - - 注意:max_rounds等参数由LLM智能生成,无需手动设置 - - 请求(JSON): + Create a new simulation + + Note: parameters like max_rounds are generated intelligently by the LLM, no manual setup required + + Request (JSON): { - "project_id": "proj_xxxx", // 必填 - "graph_id": "mirofish_xxxx", // 可选,如不提供则从project获取 - "enable_twitter": true, // 可选,默认true - "enable_reddit": true // 可选,默认true + "project_id": "proj_xxxx", // required + "graph_id": "mirofish_xxxx", // optional, falls back to the project's value + "enable_twitter": true, // optional, default true + "enable_reddit": true // optional, default true } - - 返回: + + Response: { "success": true, "data": { @@ -229,7 +230,7 @@ def create_simulation(): }) except Exception as e: - logger.error(f"创建模拟失败: {str(e)}") + logger.error(f"Failed to create simulation: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -239,38 +240,39 @@ def create_simulation(): def _check_simulation_prepared(simulation_id: str) -> tuple: """ - 检查模拟是否已经准备完成 - - 检查条件: - 1. state.json 存在且 status 为 "ready" - 2. 必要文件存在:reddit_profiles.json, twitter_profiles.csv, simulation_config.json - - 注意:运行脚本(run_*.py)保留在 backend/scripts/ 目录,不再复制到模拟目录 - + Check whether a simulation has finished preparation + + Conditions to consider prepared: + 1. state.json exists and status is "ready" + 2. Required files exist: reddit_profiles.json, twitter_profiles.csv, simulation_config.json + + Note: the runner scripts (run_*.py) live in backend/scripts/ and are no + longer copied into each simulation directory + Args: - simulation_id: 模拟ID - + simulation_id: Simulation ID + Returns: (is_prepared: bool, info: dict) """ import os from ..config import Config - + simulation_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) - - # 检查目录是否存在 + + # Check that the directory exists if not os.path.exists(simulation_dir): - return False, {"reason": "模拟目录不存在"} - - # 必要文件列表(不包括脚本,脚本位于 backend/scripts/) + return False, {"reason": "Simulation directory does not exist"} + + # Required files (scripts live in backend/scripts/, not here) required_files = [ "state.json", "simulation_config.json", "reddit_profiles.json", "twitter_profiles.csv" ] - - # 检查文件是否存在 + + # Check each file existing_files = [] missing_files = [] for f in required_files: @@ -279,48 +281,48 @@ def _check_simulation_prepared(simulation_id: str) -> tuple: existing_files.append(f) else: missing_files.append(f) - + if missing_files: return False, { - "reason": "缺少必要文件", + "reason": "Missing required files", "missing_files": missing_files, "existing_files": existing_files } - - # 检查state.json中的状态 + + # Inspect state.json state_file = os.path.join(simulation_dir, "state.json") try: import json with open(state_file, 'r', encoding='utf-8') as f: state_data = json.load(f) - + status = state_data.get("status", "") config_generated = state_data.get("config_generated", False) - - # 详细日志 - logger.debug(f"检测模拟准备状态: {simulation_id}, status={status}, config_generated={config_generated}") - - # 如果 config_generated=True 且文件存在,认为准备完成 - # 以下状态都说明准备工作已完成: - # - ready: 准备完成,可以运行 - # - preparing: 如果 config_generated=True 说明已完成 - # - running: 正在运行,说明准备早就完成了 - # - completed: 运行完成,说明准备早就完成了 - # - stopped: 已停止,说明准备早就完成了 - # - failed: 运行失败(但准备是完成的) + + # Detailed log + logger.debug(f"Checking simulation prep status: {simulation_id}, status={status}, config_generated={config_generated}") + + # If config_generated=True and the files exist, treat the simulation as prepared. + # The following statuses all imply prep is done: + # - ready: prep complete, ready to run + # - preparing: prep is still running but config_generated=True means it's done + # - running: already running, so prep was completed a while ago + # - completed: run finished, so prep was completed a while ago + # - stopped: stopped, so prep was completed a while ago + # - failed: run failed, but prep itself was completed prepared_statuses = ["ready", "preparing", "running", "completed", "stopped", "failed"] if status in prepared_statuses and config_generated: - # 获取文件统计信息 + # Collect a few file stats profiles_file = os.path.join(simulation_dir, "reddit_profiles.json") config_file = os.path.join(simulation_dir, "simulation_config.json") - + profiles_count = 0 if os.path.exists(profiles_file): with open(profiles_file, 'r', encoding='utf-8') as f: profiles_data = json.load(f) profiles_count = len(profiles_data) if isinstance(profiles_data, list) else 0 - - # 如果状态是preparing但文件已完成,自动更新状态为ready + + # If status is preparing but files are complete, auto-promote to ready if status == "preparing": try: state_data["status"] = "ready" @@ -328,12 +330,12 @@ def _check_simulation_prepared(simulation_id: str) -> tuple: 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"自动更新模拟状态: {simulation_id} preparing -> ready") + logger.info(f"Auto-promoting simulation status: {simulation_id} preparing -> ready") status = "ready" except Exception as e: - logger.warning(f"自动更新状态失败: {e}") - - logger.info(f"模拟 {simulation_id} 检测结果: 已准备完成 (status={status}, config_generated={config_generated})") + logger.warning(f"Auto-promote of status failed: {e}") + + logger.info(f"Simulation {simulation_id} check result: prep complete (status={status}, config_generated={config_generated})") return True, { "status": status, "entities_count": state_data.get("entities_count", 0), @@ -345,55 +347,55 @@ def _check_simulation_prepared(simulation_id: str) -> tuple: "existing_files": existing_files } else: - logger.warning(f"模拟 {simulation_id} 检测结果: 未准备完成 (status={status}, config_generated={config_generated})") + logger.warning(f"Simulation {simulation_id} check result: prep not complete (status={status}, config_generated={config_generated})") return False, { - "reason": f"状态不在已准备列表中或config_generated为false: status={status}, config_generated={config_generated}", + "reason": f"Status is not in the prepared set or config_generated is false: status={status}, config_generated={config_generated}", "status": status, "config_generated": config_generated } - + except Exception as e: - return False, {"reason": f"读取状态文件失败: {str(e)}"} + return False, {"reason": f"Failed to read state file: {str(e)}"} @simulation_bp.route('/prepare', methods=['POST']) def prepare_simulation(): """ - 准备模拟环境(异步任务,LLM智能生成所有参数) - - 这是一个耗时操作,接口会立即返回task_id, - 使用 GET /api/simulation/prepare/status 查询进度 - - 特性: - - 自动检测已完成的准备工作,避免重复生成 - - 如果已准备完成,直接返回已有结果 - - 支持强制重新生成(force_regenerate=true) - - 步骤: - 1. 检查是否已有完成的准备工作 - 2. 从Zep图谱读取并过滤实体 - 3. 为每个实体生成OASIS Agent Profile(带重试机制) - 4. LLM智能生成模拟配置(带重试机制) - 5. 保存配置文件和预设脚本 - - 请求(JSON): + Prepare the simulation environment (async task, LLM generates every parameter) + + This is a long-running operation, the endpoint immediately returns a task_id; + use GET /api/simulation/prepare/status to poll progress. + + Features: + - Auto-detects existing prep work to avoid regenerating + - Returns the existing prep info directly if it is already complete + - Supports force regeneration via force_regenerate=true + + Steps: + 1. Check whether prep has already been completed + 2. Read and filter entities from the Zep graph + 3. Generate an OASIS Agent profile for each entity (with retry) + 4. LLM-generate the simulation config (with retry) + 5. Persist the config files and prebuilt scripts + + Request (JSON): { - "simulation_id": "sim_xxxx", // 必填,模拟ID - "entity_types": ["Student", "PublicFigure"], // 可选,指定实体类型 - "use_llm_for_profiles": true, // 可选,是否用LLM生成人设 - "parallel_profile_count": 5, // 可选,并行生成人设数量,默认5 - "force_regenerate": false // 可选,强制重新生成,默认false + "simulation_id": "sim_xxxx", // required, simulation ID + "entity_types": ["Student", "PublicFigure"], // optional, restrict entity types + "use_llm_for_profiles": true, // optional, whether to use the LLM to build personas + "parallel_profile_count": 5, // optional, number of personas to generate in parallel, default 5 + "force_regenerate": false // optional, force regeneration, default false } - - 返回: + + Response: { "success": true, "data": { "simulation_id": "sim_xxxx", - "task_id": "task_xxxx", // 新任务时返回 + "task_id": "task_xxxx", // returned for a new task "status": "preparing|ready", - "message": "准备任务已启动|已有完成的准备工作", - "already_prepared": true|false // 是否已准备完成 + "message": "Prepare task started|Existing prep already complete", + "already_prepared": true|false // whether prep is already done } } """ @@ -421,17 +423,17 @@ def prepare_simulation(): "error": t('api.simulationNotFound', id=simulation_id) }), 404 - # 检查是否强制重新生成 + # Check whether regeneration is forced force_regenerate = data.get('force_regenerate', False) - logger.info(f"开始处理 /prepare 请求: simulation_id={simulation_id}, force_regenerate={force_regenerate}") - - # 检查是否已经准备完成(避免重复生成) + logger.info(f"Processing /prepare request: simulation_id={simulation_id}, force_regenerate={force_regenerate}") + + # Check whether prep is already complete (to avoid regenerating) if not force_regenerate: - logger.debug(f"检查模拟 {simulation_id} 是否已准备完成...") + logger.debug(f"Checking whether simulation {simulation_id} is already prepared...") is_prepared, prepare_info = _check_simulation_prepared(simulation_id) - logger.debug(f"检查结果: is_prepared={is_prepared}, prepare_info={prepare_info}") + logger.debug(f"Check result: is_prepared={is_prepared}, prepare_info={prepare_info}") if is_prepared: - logger.info(f"模拟 {simulation_id} 已准备完成,跳过重复生成") + logger.info(f"Simulation {simulation_id} is already prepared, skipping regeneration") return jsonify({ "success": True, "data": { @@ -443,51 +445,51 @@ def prepare_simulation(): } }) else: - logger.info(f"模拟 {simulation_id} 未准备完成,将启动准备任务") - - # 从项目获取必要信息 + logger.info(f"Simulation {simulation_id} is not prepared yet, starting prep task") + + # Pull the required info from the project project = ProjectManager.get_project(state.project_id) if not project: return jsonify({ "success": False, "error": t('api.projectNotFound', id=state.project_id) }), 404 - - # 获取模拟需求 + + # Get the simulation requirement simulation_requirement = project.simulation_requirement or "" if not simulation_requirement: return jsonify({ "success": False, "error": t('api.projectMissingRequirement') }), 400 - - # 获取文档文本 + + # Get the document text document_text = ProjectManager.get_extracted_text(state.project_id) or "" - + entity_types_list = data.get('entity_types') use_llm_for_profiles = data.get('use_llm_for_profiles', True) parallel_profile_count = data.get('parallel_profile_count', 5) - - # ========== 同步获取实体数量(在后台任务启动前) ========== - # 这样前端在调用prepare后立即就能获取到预期Agent总数 + + # ========== Fetch entity count synchronously (before the background task starts) ========== + # This way the frontend can read the expected total agent count immediately after calling /prepare try: - logger.info(f"同步获取实体数量: graph_id={state.graph_id}") + logger.info(f"Fetching entity count synchronously: graph_id={state.graph_id}") reader = ZepEntityReader() - # 快速读取实体(不需要边信息,只统计数量) + # Read entities quickly (no edge info, just a count) filtered_preview = reader.filter_defined_entities( graph_id=state.graph_id, defined_entity_types=entity_types_list, - enrich_with_edges=False # 不获取边信息,加快速度 + enrich_with_edges=False # skip edge info for speed ) - # 保存实体数量到状态(供前端立即获取) + # Save entity count to state (so the frontend can grab it immediately) state.entities_count = filtered_preview.filtered_count state.entity_types = list(filtered_preview.entity_types) - logger.info(f"预期实体数量: {filtered_preview.filtered_count}, 类型: {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"同步获取实体数量失败(将在后台任务中重试): {e}") - # 失败不影响后续流程,后台任务会重新获取 - - # 创建异步任务 + logger.warning(f"Synchronous entity count fetch failed (will retry in the background task): {e}") + # A failure here does not block the flow; the background task will retry + + # Create the async task task_manager = TaskManager() task_id = task_manager.create_task( task_type="simulation_prepare", @@ -496,15 +498,15 @@ def prepare_simulation(): "project_id": state.project_id } ) - - # 更新模拟状态(包含预先获取的实体数量) + + # Update the simulation state (with the pre-fetched entity count) state.status = SimulationStatus.PREPARING manager._save_simulation_state(state) - + # Capture locale before spawning background thread current_locale = get_locale() - # 定义后台任务 + # Define the background task def run_prepare(): set_locale(current_locale) try: @@ -514,35 +516,35 @@ def prepare_simulation(): progress=0, message=t('progress.startPreparingEnv') ) - - # 准备模拟(带进度回调) - # 存储阶段进度详情 + + # Prepare the simulation (with progress callback) + # Storage for per-stage progress details stage_details = {} - + def progress_callback(stage, progress, message, **kwargs): - # 计算总进度 + # Compute the overall progress stage_weights = { "reading": (0, 20), # 0-20% "generating_profiles": (20, 70), # 20-70% "generating_config": (70, 90), # 70-90% "copying_scripts": (90, 100) # 90-100% } - + start, end = stage_weights.get(stage, (0, 100)) current_progress = int(start + (end - start) * progress / 100) - - # 构建详细进度信息 + + # Build the detailed progress payload stage_names = { "reading": t('progress.readingGraphEntities'), "generating_profiles": t('progress.generatingProfiles'), "generating_config": t('progress.generatingSimConfig'), "copying_scripts": t('progress.preparingScripts') } - + stage_index = list(stage_weights.keys()).index(stage) + 1 if stage in stage_weights else 1 total_stages = len(stage_weights) - - # 更新阶段详情 + + # Update per-stage detail stage_details[stage] = { "stage_name": stage_names.get(stage, stage), "stage_progress": progress, @@ -550,8 +552,8 @@ def prepare_simulation(): "total": kwargs.get("total", 0), "item_name": kwargs.get("item_name", "") } - - # 构建详细进度信息 + + # Build the detailed progress info detail = stage_details[stage] progress_detail_data = { "current_stage": stage, @@ -563,8 +565,8 @@ def prepare_simulation(): "total_items": detail["total"], "item_description": message } - - # 构建简洁消息 + + # Build a compact status message if detail["total"] > 0: detailed_message = ( f"[{stage_index}/{total_stages}] {stage_names.get(stage, stage)}: " @@ -572,14 +574,14 @@ def prepare_simulation(): ) 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, @@ -589,28 +591,28 @@ def prepare_simulation(): 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"准备模拟失败: {str(e)}") + logger.error(f"Failed to prepare simulation: {str(e)}") task_manager.fail_task(task_id, str(e)) - - # 更新模拟状态为失败 + + # Mark the simulation as failed state = manager.get_simulation(simulation_id) if state: state.status = SimulationStatus.FAILED state.error = str(e) manager._save_simulation_state(state) - - # 启动后台线程 + + # Start the background thread thread = threading.Thread(target=run_prepare, daemon=True) thread.start() - + return jsonify({ "success": True, "data": { @@ -619,19 +621,19 @@ def prepare_simulation(): "status": "preparing", "message": t('api.prepareStarted'), "already_prepared": False, - "expected_entities_count": state.entities_count, # 预期的Agent总数 - "entity_types": state.entity_types # 实体类型列表 + "expected_entities_count": state.entities_count, # expected total agent count + "entity_types": state.entity_types # list of entity types } }) - + except ValueError as e: return jsonify({ "success": False, "error": str(e) }), 404 - + except Exception as e: - logger.error(f"启动准备任务失败: {str(e)}") + logger.error(f"Failed to start prep task: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -642,19 +644,19 @@ def prepare_simulation(): @simulation_bp.route('/prepare/status', methods=['POST']) def get_prepare_status(): """ - 查询准备任务进度 - - 支持两种查询方式: - 1. 通过task_id查询正在进行的任务进度 - 2. 通过simulation_id检查是否已有完成的准备工作 - - 请求(JSON): + Query the prep task progress + + Supports two query modes: + 1. By task_id to follow the in-flight task progress + 2. By simulation_id to check whether prep is already complete + + Request (JSON): { - "task_id": "task_xxxx", // 可选,prepare返回的task_id - "simulation_id": "sim_xxxx" // 可选,模拟ID(用于检查已完成的准备) + "task_id": "task_xxxx", // optional, the task_id returned by /prepare + "simulation_id": "sim_xxxx" // optional, simulation ID (used to check for completed prep) } - - 返回: + + Response: { "success": true, "data": { @@ -662,8 +664,8 @@ def get_prepare_status(): "status": "processing|completed|ready", "progress": 45, "message": "...", - "already_prepared": true|false, // 是否已有完成的准备 - "prepare_info": {...} // 已准备完成时的详细信息 + "already_prepared": true|false, // whether prep is already done + "prepare_info": {...} // detailed info when prep is already done } } """ @@ -675,7 +677,7 @@ def get_prepare_status(): task_id = data.get('task_id') simulation_id = data.get('simulation_id') - # 如果提供了simulation_id,先检查是否已准备完成 + # If simulation_id is provided, first check whether prep is already complete if simulation_id: is_prepared, prepare_info = _check_simulation_prepared(simulation_id) if is_prepared: @@ -690,11 +692,11 @@ def get_prepare_status(): "prepare_info": prepare_info } }) - - # 如果没有task_id,返回错误 + + # Without a task_id, return an error if not task_id: if simulation_id: - # 有simulation_id但未准备完成 + # simulation_id given but prep is not done return jsonify({ "success": True, "data": { @@ -709,12 +711,12 @@ def get_prepare_status(): "success": False, "error": t('api.requireTaskOrSimId') }), 400 - + task_manager = TaskManager() task = task_manager.get_task(task_id) - + if not task: - # 任务不存在,但如果有simulation_id,检查是否已准备完成 + # Task does not exist, but if simulation_id is given, check for completed prep if simulation_id: is_prepared, prepare_info = _check_simulation_prepared(simulation_id) if is_prepared: @@ -730,22 +732,22 @@ def get_prepare_status(): "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"查询任务状态失败: {str(e)}") + logger.error(f"Failed to query task status: {str(e)}") return jsonify({ "success": False, "error": str(e) @@ -754,7 +756,7 @@ def get_prepare_status(): @simulation_bp.route('/', methods=['GET']) def get_simulation(simulation_id: str): - """获取模拟状态""" + """Fetch the simulation status""" try: manager = SimulationManager() state = manager.get_simulation(simulation_id) @@ -766,18 +768,18 @@ def get_simulation(simulation_id: str): }), 404 result = state.to_dict() - - # 如果模拟已准备好,附加运行说明 + + # If the simulation is ready, also attach run instructions if state.status == SimulationStatus.READY: result["run_instructions"] = manager.get_run_instructions(simulation_id) - + return jsonify({ "success": True, "data": result }) - + except Exception as e: - logger.error(f"获取模拟状态失败: {str(e)}") + logger.error(f"Failed to fetch simulation status: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -788,10 +790,10 @@ def get_simulation(simulation_id: str): @simulation_bp.route('/list', methods=['GET']) def list_simulations(): """ - 列出所有模拟 - - Query参数: - project_id: 按项目ID过滤(可选) + List all simulations + + Query params: + project_id: Filter by project ID (optional) """ try: project_id = request.args.get('project_id') @@ -806,7 +808,7 @@ def list_simulations(): }) except Exception as e: - logger.error(f"列出模拟失败: {str(e)}") + logger.error(f"Failed to list simulations: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -816,42 +818,42 @@ def list_simulations(): def _get_report_id_for_simulation(simulation_id: str) -> str: """ - 获取 simulation 对应的最新 report_id - - 遍历 reports 目录,找出 simulation_id 匹配的 report, - 如果有多个则返回最新的(按 created_at 排序) - + Get the latest report_id for the given simulation + + Walks the reports directory, collects every report whose simulation_id + matches, and returns the newest one (sorted by created_at). + Args: - simulation_id: 模拟ID - + simulation_id: Simulation ID + Returns: - report_id 或 None + The report_id, or None """ import json from datetime import datetime - - # reports 目录路径:backend/uploads/reports - # __file__ 是 app/api/simulation.py,需要向上两级到 backend/ + + # reports directory: backend/uploads/reports + # __file__ is app/api/simulation.py, so go up two levels to reach backend/ reports_dir = os.path.join(os.path.dirname(__file__), '../../uploads/reports') if not os.path.exists(reports_dir): return None - + matching_reports = [] - + try: for report_folder in os.listdir(reports_dir): report_path = os.path.join(reports_dir, report_folder) if not os.path.isdir(report_path): continue - + meta_file = os.path.join(report_path, "meta.json") if not os.path.exists(meta_file): continue - + try: with open(meta_file, 'r', encoding='utf-8') as f: meta = json.load(f) - + if meta.get("simulation_id") == simulation_id: matching_reports.append({ "report_id": meta.get("report_id"), @@ -860,38 +862,39 @@ def _get_report_id_for_simulation(simulation_id: str) -> str: }) except Exception: continue - + if not matching_reports: return None - - # 按创建时间倒序排序,返回最新的 + + # Sort by created_at descending, return the newest matching_reports.sort(key=lambda x: x.get("created_at", ""), reverse=True) return matching_reports[0].get("report_id") - + except Exception as e: - logger.warning(f"查找 simulation {simulation_id} 的 report 失败: {e}") + logger.warning(f"Failed to find report for simulation {simulation_id}: {e}") return None @simulation_bp.route('/history', methods=['GET']) def get_simulation_history(): """ - 获取历史模拟列表(带项目详情) - - 用于首页历史项目展示,返回包含项目名称、描述等丰富信息的模拟列表 - - Query参数: - limit: 返回数量限制(默认20) - - 返回: + Fetch the historical simulation list (with project details) + + Used by the home page to show recent projects; returns simulations enriched + with project name, description and other useful fields. + + Query params: + limit: Maximum number of items to return (default 20) + + Response: { "success": true, "data": [ { "simulation_id": "sim_xxxx", "project_id": "proj_xxxx", - "project_name": "武大舆情分析", - "simulation_requirement": "如果武汉大学发布...", + "project_name": "WHU Public Sentiment Analysis", + "simulation_requirement": "If Wuhan University announces ...", "status": "completed", "entities_count": 68, "profiles_count": 68, @@ -914,72 +917,72 @@ def get_simulation_history(): manager = SimulationManager() simulations = manager.list_simulations()[:limit] - # 增强模拟数据,只从 Simulation 文件读取 + # Enrich simulation data, reading only from the Simulation files enriched_simulations = [] for sim in simulations: sim_dict = sim.to_dict() - - # 获取模拟配置信息(从 simulation_config.json 读取 simulation_requirement) + + # Get the simulation config (read simulation_requirement from simulation_config.json) config = manager.get_simulation_config(sim.simulation_id) if config: sim_dict["simulation_requirement"] = config.get("simulation_requirement", "") time_config = config.get("time_config", {}) sim_dict["total_simulation_hours"] = time_config.get("total_simulation_hours", 0) - # 推荐轮数(后备值) + # Recommended round count (fallback) recommended_rounds = int( - time_config.get("total_simulation_hours", 0) * 60 / + 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 - - # 获取运行状态(从 run_state.json 读取用户设置的实际轮数) + + # Get the running state (read the user-configured total_rounds from run_state.json) run_state = SimulationRunner.get_run_state(sim.simulation_id) if run_state: sim_dict["current_round"] = run_state.current_round sim_dict["runner_status"] = run_state.runner_status.value - # 使用用户设置的 total_rounds,若无则使用推荐轮数 + # Use the user-configured total_rounds; fall back to the recommended value sim_dict["total_rounds"] = run_state.total_rounds if run_state.total_rounds > 0 else recommended_rounds else: sim_dict["current_round"] = 0 sim_dict["runner_status"] = "idle" sim_dict["total_rounds"] = recommended_rounds - - # 获取关联项目的文件列表(最多3个) + + # Get the file list of the linked project (up to 3) project = ProjectManager.get_project(sim.project_id) if project and hasattr(project, 'files') and project.files: sim_dict["files"] = [ - {"filename": f.get("filename", "未知文件")} + {"filename": f.get("filename", "Unknown file")} for f in project.files[:3] ] else: sim_dict["files"] = [] - - # 获取关联的 report_id(查找该 simulation 最新的 report) + + # Get the associated report_id (find the newest report for this simulation) sim_dict["report_id"] = _get_report_id_for_simulation(sim.simulation_id) - - # 添加版本号 + + # Add the version number sim_dict["version"] = "v1.0.2" - - # 格式化日期 + + # Format the date try: created_date = sim_dict.get("created_at", "")[:10] sim_dict["created_date"] = created_date except: sim_dict["created_date"] = "" - + enriched_simulations.append(sim_dict) - + return jsonify({ "success": True, "data": enriched_simulations, "count": len(enriched_simulations) }) - + except Exception as e: - logger.error(f"获取历史模拟失败: {str(e)}") + logger.error(f"Failed to fetch historical simulations: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -990,10 +993,10 @@ def get_simulation_history(): @simulation_bp.route('//profiles', methods=['GET']) def get_simulation_profiles(simulation_id: str): """ - 获取模拟的Agent Profile - - Query参数: - platform: 平台类型(reddit/twitter,默认reddit) + Fetch the Agent profiles for a simulation + + Query params: + platform: Platform type (reddit/twitter, default reddit) """ try: platform = request.args.get('platform', 'reddit') @@ -1017,7 +1020,7 @@ def get_simulation_profiles(simulation_id: str): }), 404 except Exception as e: - logger.error(f"获取Profile失败: {str(e)}") + logger.error(f"Failed to fetch profiles: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1028,25 +1031,25 @@ def get_simulation_profiles(simulation_id: str): @simulation_bp.route('//profiles/realtime', methods=['GET']) def get_simulation_profiles_realtime(simulation_id: str): """ - 实时获取模拟的Agent Profile(用于在生成过程中实时查看进度) - - 与 /profiles 接口的区别: - - 直接读取文件,不经过 SimulationManager - - 适用于生成过程中的实时查看 - - 返回额外的元数据(如文件修改时间、是否正在生成等) - - Query参数: - platform: 平台类型(reddit/twitter,默认reddit) - - 返回: + Real-time fetch of Agent profiles (for live progress while generating) + + Differences from /profiles: + - Reads files directly, bypassing the SimulationManager + - Designed for live progress while generation is in progress + - Returns extra metadata (file mtime, whether generation is in progress, ...) + + Query params: + platform: Platform type (reddit/twitter, default reddit) + + Response: { "success": true, "data": { "simulation_id": "sim_xxxx", "platform": "reddit", "count": 15, - "total_expected": 93, // 预期总数(如果有) - "is_generating": true, // 是否正在生成 + "total_expected": 93, // expected total (if known) + "is_generating": true, // whether generation is in progress "file_exists": true, "file_modified_at": "2025-12-04T18:20:00", "profiles": [...] @@ -1059,32 +1062,32 @@ def get_simulation_profiles_realtime(simulation_id: str): try: platform = request.args.get('platform', 'reddit') - - # 获取模拟目录 + + # Locate the simulation directory sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) - + if not os.path.exists(sim_dir): return jsonify({ "success": False, "error": t('api.simulationNotFound', id=simulation_id) }), 404 - - # 确定文件路径 + + # Pick the right file if platform == "reddit": profiles_file = os.path.join(sim_dir, "reddit_profiles.json") else: profiles_file = os.path.join(sim_dir, "twitter_profiles.csv") - - # 检查文件是否存在 + + # Check that the file exists file_exists = os.path.exists(profiles_file) profiles = [] file_modified_at = None - + if file_exists: - # 获取文件修改时间 + # Read the file mtime file_stat = os.stat(profiles_file) file_modified_at = datetime.fromtimestamp(file_stat.st_mtime).isoformat() - + try: if platform == "reddit": with open(profiles_file, 'r', encoding='utf-8') as f: @@ -1094,13 +1097,13 @@ def get_simulation_profiles_realtime(simulation_id: str): reader = csv.DictReader(f) profiles = list(reader) except (json.JSONDecodeError, Exception) as e: - logger.warning(f"读取 profiles 文件失败(可能正在写入中): {e}") + logger.warning(f"Failed to read profiles file (may be mid-write): {e}") profiles = [] - - # 检查是否正在生成(通过 state.json 判断) + + # Detect whether generation is in progress (via state.json) is_generating = False total_expected = None - + state_file = os.path.join(sim_dir, "state.json") if os.path.exists(state_file): try: @@ -1111,7 +1114,7 @@ def get_simulation_profiles_realtime(simulation_id: str): total_expected = state_data.get("entities_count") except Exception: pass - + return jsonify({ "success": True, "data": { @@ -1125,9 +1128,9 @@ def get_simulation_profiles_realtime(simulation_id: str): "profiles": profiles } }) - + except Exception as e: - logger.error(f"实时获取Profile失败: {str(e)}") + logger.error(f"Failed to fetch realtime profiles: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1138,24 +1141,24 @@ def get_simulation_profiles_realtime(simulation_id: str): @simulation_bp.route('//config/realtime', methods=['GET']) def get_simulation_config_realtime(simulation_id: str): """ - 实时获取模拟配置(用于在生成过程中实时查看进度) - - 与 /config 接口的区别: - - 直接读取文件,不经过 SimulationManager - - 适用于生成过程中的实时查看 - - 返回额外的元数据(如文件修改时间、是否正在生成等) - - 即使配置还没生成完也能返回部分信息 - - 返回: + Real-time fetch of the simulation config (for live progress while generating) + + Differences from /config: + - Reads the file directly, bypassing the SimulationManager + - Designed for live progress while generation is in progress + - Returns extra metadata (file mtime, whether generation is in progress, ...) + - Returns partial info even if config generation isn't finished yet + + Response: { "success": true, "data": { "simulation_id": "sim_xxxx", "file_exists": true, "file_modified_at": "2025-12-04T18:20:00", - "is_generating": true, // 是否正在生成 - "generation_stage": "generating_config", // 当前生成阶段 - "config": {...} // 配置内容(如果存在) + "is_generating": true, // whether generation is in progress + "generation_stage": "generating_config", // current generation stage + "config": {...} // config payload (if it exists) } } """ @@ -1163,40 +1166,40 @@ def get_simulation_config_realtime(simulation_id: str): from datetime import datetime try: - # 获取模拟目录 + # Locate the simulation directory sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) - + if not os.path.exists(sim_dir): return jsonify({ "success": False, "error": t('api.simulationNotFound', id=simulation_id) }), 404 - - # 配置文件路径 + + # Path to the config file config_file = os.path.join(sim_dir, "simulation_config.json") - - # 检查文件是否存在 + + # Check that the file exists file_exists = os.path.exists(config_file) config = None file_modified_at = None - + if file_exists: - # 获取文件修改时间 + # Read the file mtime file_stat = os.stat(config_file) file_modified_at = datetime.fromtimestamp(file_stat.st_mtime).isoformat() - + try: with open(config_file, 'r', encoding='utf-8') as f: config = json.load(f) except (json.JSONDecodeError, Exception) as e: - logger.warning(f"读取 config 文件失败(可能正在写入中): {e}") + logger.warning(f"Failed to read config file (may be mid-write): {e}") config = None - - # 检查是否正在生成(通过 state.json 判断) + + # Detect whether generation is in progress (via state.json) is_generating = False generation_stage = None config_generated = False - + state_file = os.path.join(sim_dir, "state.json") if os.path.exists(state_file): try: @@ -1205,8 +1208,8 @@ def get_simulation_config_realtime(simulation_id: str): status = state_data.get("status", "") is_generating = status == "preparing" config_generated = state_data.get("config_generated", False) - - # 判断当前阶段 + + # Decide the current stage if is_generating: if state_data.get("profiles_generated", False): generation_stage = "generating_config" @@ -1216,8 +1219,8 @@ def get_simulation_config_realtime(simulation_id: str): generation_stage = "completed" except Exception: pass - - # 构建返回数据 + + # Build the response payload response_data = { "simulation_id": simulation_id, "file_exists": file_exists, @@ -1227,8 +1230,8 @@ def get_simulation_config_realtime(simulation_id: str): "config_generated": config_generated, "config": config } - - # 如果配置存在,提取一些关键统计信息 + + # If config exists, surface a few key statistics if config: response_data["summary"] = { "total_agents": len(config.get("agent_configs", [])), @@ -1240,14 +1243,14 @@ def get_simulation_config_realtime(simulation_id: str): "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"实时获取Config失败: {str(e)}") + logger.error(f"Failed to fetch realtime config: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1258,14 +1261,14 @@ def get_simulation_config_realtime(simulation_id: str): @simulation_bp.route('//config', methods=['GET']) def get_simulation_config(simulation_id: str): """ - 获取模拟配置(LLM智能生成的完整配置) - - 返回包含: - - time_config: 时间配置(模拟时长、轮次、高峰/低谷时段) - - agent_configs: 每个Agent的活动配置(活跃度、发言频率、立场等) - - event_config: 事件配置(初始帖子、热点话题) - - platform_configs: 平台配置 - - generation_reasoning: LLM的配置推理说明 + Fetch the full LLM-generated simulation config + + Returns: + - time_config: time config (duration, rounds, peak/off-peak hours) + - agent_configs: per-agent activity config (activity level, frequency, stance, ...) + - event_config: event config (initial posts, hot topics) + - platform_configs: platform-specific config + - generation_reasoning: the LLM's reasoning notes """ try: manager = SimulationManager() @@ -1283,7 +1286,7 @@ def get_simulation_config(simulation_id: str): }) except Exception as e: - logger.error(f"获取配置失败: {str(e)}") + logger.error(f"Failed to fetch config: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1293,7 +1296,7 @@ def get_simulation_config(simulation_id: str): @simulation_bp.route('//config/download', methods=['GET']) def download_simulation_config(simulation_id: str): - """下载模拟配置文件""" + """Download the simulation config file""" try: manager = SimulationManager() sim_dir = manager._get_simulation_dir(simulation_id) @@ -1312,7 +1315,7 @@ def download_simulation_config(simulation_id: str): ) except Exception as e: - logger.error(f"下载配置失败: {str(e)}") + logger.error(f"Failed to download config: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1323,48 +1326,48 @@ def download_simulation_config(simulation_id: str): @simulation_bp.route('/script//download', methods=['GET']) def download_simulation_script(script_name: str): """ - 下载模拟运行脚本文件(通用脚本,位于 backend/scripts/) - - script_name可选值: + Download a simulation runner script (shared script, lives in backend/scripts/) + + Allowed script_name values: - run_twitter_simulation.py - run_reddit_simulation.py - run_parallel_simulation.py - action_logger.py """ try: - # 脚本位于 backend/scripts/ 目录 + # The script lives under backend/scripts/ scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts')) - - # 验证脚本名称 + + # Whitelist of allowed script names allowed_scripts = [ "run_twitter_simulation.py", - "run_reddit_simulation.py", + "run_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"下载脚本失败: {str(e)}") + logger.error(f"Failed to download script: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1372,19 +1375,19 @@ def download_simulation_script(script_name: str): }), 500 -# ============== Profile生成接口(独立使用) ============== +# ============== Standalone profile generation endpoint ============== @simulation_bp.route('/generate-profiles', methods=['POST']) def generate_profiles(): """ - 直接从图谱生成OASIS Agent Profile(不创建模拟) - - 请求(JSON): + Generate OASIS Agent profiles directly from the graph (without creating a simulation) + + Request (JSON): { - "graph_id": "mirofish_xxxx", // 必填 - "entity_types": ["Student"], // 可选 - "use_llm": true, // 可选 - "platform": "reddit" // 可选 + "graph_id": "mirofish_xxxx", // required + "entity_types": ["Student"], // optional + "use_llm": true, // optional + "platform": "reddit" // optional } """ try: @@ -1438,7 +1441,7 @@ def generate_profiles(): }) except Exception as e: - logger.error(f"生成Profile失败: {str(e)}") + logger.error(f"Failed to generate profiles: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1446,35 +1449,35 @@ def generate_profiles(): }), 500 -# ============== 模拟运行控制接口 ============== +# ============== Simulation Run Control Endpoints ============== @simulation_bp.route('/start', methods=['POST']) def start_simulation(): """ - 开始运行模拟 + Start running a simulation - 请求(JSON): + Request (JSON): { - "simulation_id": "sim_xxxx", // 必填,模拟ID - "platform": "parallel", // 可选: twitter / reddit / parallel (默认) - "max_rounds": 100, // 可选: 最大模拟轮数,用于截断过长的模拟 - "enable_graph_memory_update": false, // 可选: 是否将Agent活动动态更新到Zep图谱记忆 - "force": false // 可选: 强制重新开始(会停止运行中的模拟并清理日志) + "simulation_id": "sim_xxxx", // required, simulation ID + "platform": "parallel", // optional: twitter / reddit / parallel (default) + "max_rounds": 100, // optional: maximum number of simulation rounds, used to truncate overlong simulations + "enable_graph_memory_update": false, // optional: whether to dynamically update Agent activity to the Zep graph memory + "force": false // optional: force a restart (will stop a running simulation and clean up logs) } - 关于 force 参数: - - 启用后,如果模拟正在运行或已完成,会先停止并清理运行日志 - - 清理的内容包括:run_state.json, actions.jsonl, simulation.log 等 - - 不会清理配置文件(simulation_config.json)和 profile 文件 - - 适用于需要重新运行模拟的场景 + About the force parameter: + - When enabled, if the simulation is running or has completed, the running logs will first be stopped and cleaned up + - Files cleaned up include: run_state.json, actions.jsonl, simulation.log, etc. + - Configuration files (simulation_config.json) and profile files will NOT be cleaned up + - Suitable for scenarios where the simulation needs to be re-run - 关于 enable_graph_memory_update: - - 启用后,模拟中所有Agent的活动(发帖、评论、点赞等)都会实时更新到Zep图谱 - - 这可以让图谱"记住"模拟过程,用于后续分析或AI对话 - - 需要模拟关联的项目有有效的 graph_id - - 采用批量更新机制,减少API调用次数 + About enable_graph_memory_update: + - When enabled, all Agent activities (posting, commenting, liking, etc.) in the simulation are updated in real time to the Zep graph + - This lets the graph "remember" the simulation process for later analysis or AI conversations + - Requires the project associated with the simulation to have a valid graph_id + - Uses a batch-update mechanism to reduce the number of API calls - 返回: + Returns: { "success": true, "data": { @@ -1484,8 +1487,8 @@ def start_simulation(): "twitter_running": true, "reddit_running": true, "started_at": "2025-12-01T10:00:00", - "graph_memory_update_enabled": true, // 是否启用了图谱记忆更新 - "force_restarted": true // 是否是强制重新开始 + "graph_memory_update_enabled": true, // whether graph memory update is enabled + "force_restarted": true // whether this is a forced restart } } """ @@ -1500,11 +1503,11 @@ def start_simulation(): }), 400 platform = data.get('platform', 'parallel') - max_rounds = data.get('max_rounds') # 可选:最大模拟轮数 - enable_graph_memory_update = data.get('enable_graph_memory_update', False) # 可选:是否启用图谱记忆更新 - force = data.get('force', False) # 可选:强制重新开始 + max_rounds = data.get('max_rounds') # optional: maximum number of simulation rounds + enable_graph_memory_update = data.get('enable_graph_memory_update', False) # optional: whether to enable graph memory update + force = data.get('force', False) # optional: force restart - # 验证 max_rounds 参数 + # Validate the max_rounds parameter if max_rounds is not None: try: max_rounds = int(max_rounds) @@ -1525,7 +1528,7 @@ def start_simulation(): "error": t('api.invalidPlatform', platform=platform) }), 400 - # 检查模拟是否已准备好 + # Check whether the simulation is ready manager = SimulationManager() state = manager.get_simulation(simulation_id) @@ -1536,71 +1539,71 @@ def start_simulation(): }), 404 force_restarted = False - - # 智能处理状态:如果准备工作已完成,允许重新启动 + + # Smart status handling: if prep work is already complete, allow a restart if state.status != SimulationStatus.READY: - # 检查准备工作是否已完成 + # Check whether prep work is already complete is_prepared, prepare_info = _check_simulation_prepared(simulation_id) if is_prepared: - # 准备工作已完成,检查是否有正在运行的进程 + # Prep work is complete, check whether a process is still running if state.status == SimulationStatus.RUNNING: - # 检查模拟进程是否真的在运行 + # Check whether the simulation process is really running run_state = SimulationRunner.get_run_state(simulation_id) if run_state and run_state.runner_status.value == "running": - # 进程确实在运行 + # Process is really running if force: - # 强制模式:停止运行中的模拟 - logger.info(f"强制模式:停止运行中的模拟 {simulation_id}") + # Force mode: stop the running simulation + logger.info(f"Force mode: stopping running simulation {simulation_id}") try: SimulationRunner.stop_simulation(simulation_id) except Exception as e: - logger.warning(f"停止模拟时出现警告: {str(e)}") + logger.warning(f"Warning when stopping simulation: {str(e)}") else: return jsonify({ "success": False, "error": t('api.simRunningForceHint') }), 400 - # 如果是强制模式,清理运行日志 + # If in force mode, clean up the run logs if force: - logger.info(f"强制模式:清理模拟日志 {simulation_id}") + logger.info(f"Force mode: cleaning up simulation logs {simulation_id}") cleanup_result = SimulationRunner.cleanup_simulation_logs(simulation_id) if not cleanup_result.get("success"): - logger.warning(f"清理日志时出现警告: {cleanup_result.get('errors')}") + logger.warning(f"Warning when cleaning up logs: {cleanup_result.get('errors')}") force_restarted = True - # 进程不存在或已结束,重置状态为 ready - logger.info(f"模拟 {simulation_id} 准备工作已完成,重置状态为 ready(原状态: {state.status.value})") + # Process does not exist or has ended, reset status to ready + logger.info(f"Simulation {simulation_id} prep work is complete, resetting status to ready (previous status: {state.status.value})") state.status = SimulationStatus.READY manager._save_simulation_state(state) else: - # 准备工作未完成 + # Prep work is not complete return jsonify({ "success": False, "error": t('api.simNotReady', status=state.status.value) }), 400 - - # 获取图谱ID(用于图谱记忆更新) + + # Get the graph_id (used for graph memory update) graph_id = None if enable_graph_memory_update: - # 从模拟状态或项目中获取 graph_id + # Get graph_id from the simulation state or the project graph_id = state.graph_id if not graph_id: - # 尝试从项目中获取 + # Try to get it from the project project = ProjectManager.get_project(state.project_id) if project: graph_id = project.graph_id - + if not graph_id: return jsonify({ "success": False, "error": t('api.graphIdRequiredForMemory') }), 400 - - logger.info(f"启用图谱记忆更新: simulation_id={simulation_id}, graph_id={graph_id}") - - # 启动模拟 + + logger.info(f"Graph memory update enabled: simulation_id={simulation_id}, graph_id={graph_id}") + + # Start the simulation run_state = SimulationRunner.start_simulation( simulation_id=simulation_id, platform=platform, @@ -1608,11 +1611,11 @@ def start_simulation(): enable_graph_memory_update=enable_graph_memory_update, graph_id=graph_id ) - - # 更新模拟状态 + + # Update the simulation status state.status = SimulationStatus.RUNNING manager._save_simulation_state(state) - + response_data = run_state.to_dict() if max_rounds: response_data['max_rounds_applied'] = max_rounds @@ -1620,20 +1623,20 @@ def start_simulation(): 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"启动模拟失败: {str(e)}") + logger.error(f"Failed to start simulation: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1644,14 +1647,14 @@ def start_simulation(): @simulation_bp.route('/stop', methods=['POST']) def stop_simulation(): """ - 停止模拟 - - 请求(JSON): + Stop the simulation + + Request (JSON): { - "simulation_id": "sim_xxxx" // 必填,模拟ID + "simulation_id": "sim_xxxx" // required, simulation ID } - - 返回: + + Returns: { "success": true, "data": { @@ -1663,36 +1666,36 @@ def stop_simulation(): """ try: data = request.get_json() or {} - + simulation_id = data.get('simulation_id') if not simulation_id: return jsonify({ "success": False, "error": t('api.requireSimulationId') }), 400 - + run_state = SimulationRunner.stop_simulation(simulation_id) - - # 更新模拟状态 + + # Update the simulation status manager = SimulationManager() state = manager.get_simulation(simulation_id) if state: state.status = SimulationStatus.PAUSED manager._save_simulation_state(state) - + return jsonify({ "success": True, "data": run_state.to_dict() }) - + except ValueError as e: return jsonify({ "success": False, "error": str(e) }), 400 - + except Exception as e: - logger.error(f"停止模拟失败: {str(e)}") + logger.error(f"Failed to stop simulation: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1700,14 +1703,14 @@ def stop_simulation(): }), 500 -# ============== 实时状态监控接口 ============== +# ============== Real-time Status Monitoring Endpoints ============== @simulation_bp.route('//run-status', methods=['GET']) def get_run_status(simulation_id: str): """ - 获取模拟运行实时状态(用于前端轮询) - - 返回: + Get the real-time run status of a simulation (used for frontend polling) + + Returns: { "success": true, "data": { @@ -1730,7 +1733,7 @@ def get_run_status(simulation_id: str): """ try: run_state = SimulationRunner.get_run_state(simulation_id) - + if not run_state: return jsonify({ "success": True, @@ -1745,14 +1748,14 @@ def get_run_status(simulation_id: str): "total_actions_count": 0, } }) - + return jsonify({ "success": True, "data": run_state.to_dict() }) - + except Exception as e: - logger.error(f"获取运行状态失败: {str(e)}") + logger.error(f"Failed to fetch run status: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1763,14 +1766,14 @@ def get_run_status(simulation_id: str): @simulation_bp.route('//run-status/detail', methods=['GET']) def get_run_status_detail(simulation_id: str): """ - 获取模拟运行详细状态(包含所有动作) - - 用于前端展示实时动态 - - Query参数: - platform: 过滤平台(twitter/reddit,可选) - - 返回: + Get the detailed run status of a simulation (including all actions) + + Used by the frontend to display real-time dynamics + + Query params: + platform: filter platform (twitter/reddit, optional) + + Returns: { "success": true, "data": { @@ -1792,15 +1795,15 @@ def get_run_status_detail(simulation_id: str): }, ... ], - "twitter_actions": [...], # Twitter 平台的所有动作 - "reddit_actions": [...] # Reddit 平台的所有动作 + "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, @@ -1812,48 +1815,48 @@ def get_run_status_detail(simulation_id: str): "reddit_actions": [] } }) - - # 获取完整的动作列表 + + # Get the full action list all_actions = SimulationRunner.get_all_actions( simulation_id=simulation_id, platform=platform_filter ) - - # 分平台获取动作 + + # Get actions by platform twitter_actions = SimulationRunner.get_all_actions( simulation_id=simulation_id, platform="twitter" ) if not platform_filter or platform_filter == "twitter" else [] - + reddit_actions = SimulationRunner.get_all_actions( simulation_id=simulation_id, platform="reddit" ) if not platform_filter or platform_filter == "reddit" else [] - - # 获取当前轮次的动作(recent_actions 只展示最新一轮) + + # Get actions for the current round (recent_actions only shows the latest round) current_round = run_state.current_round recent_actions = SimulationRunner.get_all_actions( simulation_id=simulation_id, platform=platform_filter, round_num=current_round ) if current_round > 0 else [] - - # 获取基础状态信息 + + # Get the basic status info result = run_state.to_dict() result["all_actions"] = [a.to_dict() for a in all_actions] result["twitter_actions"] = [a.to_dict() for a in twitter_actions] result["reddit_actions"] = [a.to_dict() for a in reddit_actions] result["rounds_count"] = len(run_state.rounds) - # recent_actions 只展示当前最新一轮两个平台的内容 + # recent_actions only shows the latest round's content across both platforms result["recent_actions"] = [a.to_dict() for a in recent_actions] - + return jsonify({ "success": True, "data": result }) - + except Exception as e: - logger.error(f"获取详细状态失败: {str(e)}") + logger.error(f"Failed to fetch detailed status: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1864,16 +1867,16 @@ def get_run_status_detail(simulation_id: str): @simulation_bp.route('//actions', methods=['GET']) def get_simulation_actions(simulation_id: str): """ - 获取模拟中的Agent动作历史 - - Query参数: - limit: 返回数量(默认100) - offset: 偏移量(默认0) - platform: 过滤平台(twitter/reddit) - agent_id: 过滤Agent ID - round_num: 过滤轮次 - - 返回: + Get the Agent action history of a simulation + + Query params: + limit: return count (default 100) + offset: offset (default 0) + platform: filter platform (twitter/reddit) + agent_id: filter Agent ID + round_num: filter round + + Returns: { "success": true, "data": { @@ -1888,7 +1891,7 @@ def get_simulation_actions(simulation_id: str): 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, @@ -1897,7 +1900,7 @@ def get_simulation_actions(simulation_id: str): agent_id=agent_id, round_num=round_num ) - + return jsonify({ "success": True, "data": { @@ -1905,9 +1908,9 @@ def get_simulation_actions(simulation_id: str): "actions": [a.to_dict() for a in actions] } }) - + except Exception as e: - logger.error(f"获取动作历史失败: {str(e)}") + logger.error(f"Failed to fetch action history: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1918,26 +1921,26 @@ def get_simulation_actions(simulation_id: str): @simulation_bp.route('//timeline', methods=['GET']) def get_simulation_timeline(simulation_id: str): """ - 获取模拟时间线(按轮次汇总) - - 用于前端展示进度条和时间线视图 - - Query参数: - start_round: 起始轮次(默认0) - end_round: 结束轮次(默认全部) - - 返回每轮的汇总信息 + Get the simulation timeline (summarized by round) + + Used by the frontend to display the progress bar and timeline view + + Query params: + start_round: starting round (default 0) + end_round: ending round (default all) + + Returns the summary info for each round """ try: start_round = request.args.get('start_round', 0, type=int) end_round = request.args.get('end_round', type=int) - + timeline = SimulationRunner.get_timeline( simulation_id=simulation_id, start_round=start_round, end_round=end_round ) - + return jsonify({ "success": True, "data": { @@ -1945,9 +1948,9 @@ def get_simulation_timeline(simulation_id: str): "timeline": timeline } }) - + except Exception as e: - logger.error(f"获取时间线失败: {str(e)}") + logger.error(f"Failed to fetch timeline: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1958,13 +1961,13 @@ def get_simulation_timeline(simulation_id: str): @simulation_bp.route('//agent-stats', methods=['GET']) def get_agent_stats(simulation_id: str): """ - 获取每个Agent的统计信息 - - 用于前端展示Agent活跃度排行、动作分布等 + Get per-Agent statistics + + Used by the frontend to display Agent activity rankings, action distribution, etc. """ try: stats = SimulationRunner.get_agent_stats(simulation_id) - + return jsonify({ "success": True, "data": { @@ -1972,9 +1975,9 @@ def get_agent_stats(simulation_id: str): "stats": stats } }) - + except Exception as e: - logger.error(f"获取Agent统计失败: {str(e)}") + logger.error(f"Failed to fetch Agent statistics: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -1982,33 +1985,33 @@ def get_agent_stats(simulation_id: str): }), 500 -# ============== 数据库查询接口 ============== +# ============== Database Query Endpoints ============== @simulation_bp.route('//posts', methods=['GET']) def get_simulation_posts(simulation_id: str): """ - 获取模拟中的帖子 - - Query参数: - platform: 平台类型(twitter/reddit) - limit: 返回数量(默认50) - offset: 偏移量 - - 返回帖子列表(从SQLite数据库读取) + Get the posts in a simulation + + Query params: + platform: platform type (twitter/reddit) + limit: return count (default 50) + offset: offset + + Returns the post list (read from the SQLite database) """ try: platform = request.args.get('platform', 'reddit') limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) - + sim_dir = os.path.join( os.path.dirname(__file__), f'../../uploads/simulations/{simulation_id}' ) - + db_file = f"{platform}_simulation.db" db_path = os.path.join(sim_dir, db_file) - + if not os.path.exists(db_path): return jsonify({ "success": True, @@ -2019,30 +2022,30 @@ def get_simulation_posts(simulation_id: str): "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 + 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": { @@ -2052,9 +2055,9 @@ def get_simulation_posts(simulation_id: str): "posts": posts } }) - + except Exception as e: - logger.error(f"获取帖子失败: {str(e)}") + logger.error(f"Failed to fetch posts: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -2065,25 +2068,25 @@ def get_simulation_posts(simulation_id: str): @simulation_bp.route('//comments', methods=['GET']) def get_simulation_comments(simulation_id: str): """ - 获取模拟中的评论(仅Reddit) - - Query参数: - post_id: 过滤帖子ID(可选) - limit: 返回数量 - offset: 偏移量 + Get the comments in a simulation (Reddit only) + + Query params: + post_id: filter post ID (optional) + limit: return count + offset: offset """ try: post_id = request.args.get('post_id') limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) - + sim_dir = os.path.join( os.path.dirname(__file__), f'../../uploads/simulations/{simulation_id}' ) - + db_path = os.path.join(sim_dir, "reddit_simulation.db") - + if not os.path.exists(db_path): return jsonify({ "success": True, @@ -2092,34 +2095,34 @@ def get_simulation_comments(simulation_id: str): "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 + SELECT * FROM comment WHERE post_id = ? - ORDER BY created_at DESC + ORDER BY created_at DESC LIMIT ? OFFSET ? """, (post_id, limit, offset)) else: cursor.execute(""" - SELECT * FROM comment - ORDER BY created_at DESC + 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": { @@ -2127,9 +2130,9 @@ def get_simulation_comments(simulation_id: str): "comments": comments } }) - + except Exception as e: - logger.error(f"获取评论失败: {str(e)}") + logger.error(f"Failed to fetch comments: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -2137,31 +2140,32 @@ def get_simulation_comments(simulation_id: str): }), 500 -# ============== Interview 采访接口 ============== +# ============== Interview Endpoints ============== @simulation_bp.route('/interview', methods=['POST']) def interview_agent(): """ - 采访单个Agent + Interview a single Agent - 注意:此功能需要模拟环境处于运行状态(完成模拟循环后进入等待命令模式) + Note: this feature requires the simulation environment to be running + (it enters the wait-for-command mode after finishing a simulation loop) - 请求(JSON): + Request (JSON): { - "simulation_id": "sim_xxxx", // 必填,模拟ID - "agent_id": 0, // 必填,Agent ID - "prompt": "你对这件事有什么看法?", // 必填,采访问题 - "platform": "twitter", // 可选,指定平台(twitter/reddit) - // 不指定时:双平台模拟同时采访两个平台 - "timeout": 60 // 可选,超时时间(秒),默认60 + "simulation_id": "sim_xxxx", // required, simulation ID + "agent_id": 0, // required, Agent ID + "prompt": "What do you think about this?", // required, interview question + "platform": "twitter", // optional, specify platform (twitter/reddit) + // when not specified: in dual-platform simulations, both platforms are interviewed simultaneously + "timeout": 60 // optional, timeout in seconds, default 60 } - 返回(不指定platform,双平台模式): + Returns (no platform specified, dual-platform mode): { "success": true, "data": { "agent_id": 0, - "prompt": "你对这件事有什么看法?", + "prompt": "What do you think about this?", "result": { "agent_id": 0, "prompt": "...", @@ -2174,15 +2178,15 @@ def interview_agent(): } } - 返回(指定platform): + Returns (platform specified): { "success": true, "data": { "agent_id": 0, - "prompt": "你对这件事有什么看法?", + "prompt": "What do you think about this?", "result": { "agent_id": 0, - "response": "我认为...", + "response": "I think...", "platform": "twitter", "timestamp": "2025-12-08T10:00:00" }, @@ -2192,48 +2196,48 @@ def interview_agent(): """ 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') # 可选:twitter/reddit/None + 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 - - # 验证platform参数 + + # Validate the platform parameter if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, "error": t('api.invalidInterviewPlatform') }), 400 - - # 检查环境状态 + + # Check the environment status if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, "error": t('api.envNotRunning') }), 400 - - # 优化prompt,添加前缀避免Agent调用工具 + + # Optimize the prompt by adding a prefix to prevent the Agent from calling tools optimized_prompt = optimize_interview_prompt(prompt) - + result = SimulationRunner.interview_agent( simulation_id=simulation_id, agent_id=agent_id, @@ -2246,21 +2250,21 @@ def interview_agent(): "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失败: {str(e)}") + logger.error(f"Interview failed: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -2271,30 +2275,30 @@ def interview_agent(): @simulation_bp.route('/interview/batch', methods=['POST']) def interview_agents_batch(): """ - 批量采访多个Agent + Batch-interview multiple Agents - 注意:此功能需要模拟环境处于运行状态 + Note: this feature requires the simulation environment to be running - 请求(JSON): + Request (JSON): { - "simulation_id": "sim_xxxx", // 必填,模拟ID - "interviews": [ // 必填,采访列表 + "simulation_id": "sim_xxxx", // required, simulation ID + "interviews": [ // required, list of interviews { "agent_id": 0, - "prompt": "你对A有什么看法?", - "platform": "twitter" // 可选,指定该Agent的采访平台 + "prompt": "What do you think of A?", + "platform": "twitter" // optional, specify the interview platform for this Agent }, { "agent_id": 1, - "prompt": "你对B有什么看法?" // 不指定platform则使用默认值 + "prompt": "What do you think of B?" // when platform is not specified, the default is used } ], - "platform": "reddit", // 可选,默认平台(被每项的platform覆盖) - // 不指定时:双平台模拟每个Agent同时采访两个平台 - "timeout": 120 // 可选,超时时间(秒),默认120 + "platform": "reddit", // optional, default platform (overridden by each item's platform) + // when not specified: in dual-platform simulations, both platforms are interviewed simultaneously for each Agent + "timeout": 120 // optional, timeout in seconds, default 120 } - 返回: + Returns: { "success": true, "data": { @@ -2317,7 +2321,7 @@ def interview_agents_batch(): simulation_id = data.get('simulation_id') interviews = data.get('interviews') - platform = data.get('platform') # 可选:twitter/reddit/None + platform = data.get('platform') # optional: twitter/reddit/None timeout = data.get('timeout', 120) if not simulation_id: @@ -2332,14 +2336,14 @@ def interview_agents_batch(): "error": t('api.requireInterviews') }), 400 - # 验证platform参数 + # Validate the platform parameter if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, "error": t('api.invalidInterviewPlatform') }), 400 - # 验证每个采访项 + # Validate every interview item for i, interview in enumerate(interviews): if 'agent_id' not in interview: return jsonify({ @@ -2351,7 +2355,7 @@ def interview_agents_batch(): "success": False, "error": t('api.interviewListMissingPrompt', index=i+1) }), 400 - # 验证每项的platform(如果有) + # Validate each item's platform (if any) item_platform = interview.get('platform') if item_platform and item_platform not in ("twitter", "reddit"): return jsonify({ @@ -2359,14 +2363,14 @@ def interview_agents_batch(): "error": t('api.interviewListInvalidPlatform', index=i+1) }), 400 - # 检查环境状态 + # Check the environment status if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, "error": t('api.envNotRunning') }), 400 - # 优化每个采访项的prompt,添加前缀避免Agent调用工具 + # Optimize each interview item's prompt by adding a prefix to prevent the Agent from calling tools optimized_interviews = [] for interview in interviews: optimized_interview = interview.copy() @@ -2398,7 +2402,7 @@ def interview_agents_batch(): }), 504 except Exception as e: - logger.error(f"批量Interview失败: {str(e)}") + logger.error(f"Batch Interview failed: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -2409,20 +2413,20 @@ def interview_agents_batch(): @simulation_bp.route('/interview/all', methods=['POST']) def interview_all_agents(): """ - 全局采访 - 使用相同问题采访所有Agent + Global interview - use the same question to interview all Agents - 注意:此功能需要模拟环境处于运行状态 + Note: this feature requires the simulation environment to be running - 请求(JSON): + Request (JSON): { - "simulation_id": "sim_xxxx", // 必填,模拟ID - "prompt": "你对这件事整体有什么看法?", // 必填,采访问题(所有Agent使用相同问题) - "platform": "reddit", // 可选,指定平台(twitter/reddit) - // 不指定时:双平台模拟每个Agent同时采访两个平台 - "timeout": 180 // 可选,超时时间(秒),默认180 + "simulation_id": "sim_xxxx", // required, simulation ID + "prompt": "What is your overall take on this?", // required, interview question (all Agents use the same question) + "platform": "reddit", // optional, specify platform (twitter/reddit) + // when not specified: in dual-platform simulations, both platforms are interviewed simultaneously for each Agent + "timeout": 180 // optional, timeout in seconds, default 180 } - 返回: + Returns: { "success": true, "data": { @@ -2444,7 +2448,7 @@ def interview_all_agents(): simulation_id = data.get('simulation_id') prompt = data.get('prompt') - platform = data.get('platform') # 可选:twitter/reddit/None + platform = data.get('platform') # optional: twitter/reddit/None timeout = data.get('timeout', 180) if not simulation_id: @@ -2459,21 +2463,21 @@ def interview_all_agents(): "error": t('api.requirePrompt') }), 400 - # 验证platform参数 + # Validate the platform parameter if platform and platform not in ("twitter", "reddit"): return jsonify({ "success": False, "error": t('api.invalidInterviewPlatform') }), 400 - # 检查环境状态 + # Check the environment status if not SimulationRunner.check_env_alive(simulation_id): return jsonify({ "success": False, "error": t('api.envNotRunning') }), 400 - # 优化prompt,添加前缀避免Agent调用工具 + # Optimize the prompt by adding a prefix to prevent the Agent from calling tools optimized_prompt = optimize_interview_prompt(prompt) result = SimulationRunner.interview_all_agents( @@ -2501,7 +2505,7 @@ def interview_all_agents(): }), 504 except Exception as e: - logger.error(f"全局Interview失败: {str(e)}") + logger.error(f"Global Interview failed: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -2512,20 +2516,20 @@ def interview_all_agents(): @simulation_bp.route('/interview/history', methods=['POST']) def get_interview_history(): """ - 获取Interview历史记录 + Get the Interview history - 从模拟数据库中读取所有Interview记录 + Reads all Interview records from the simulation database - 请求(JSON): + Request (JSON): { - "simulation_id": "sim_xxxx", // 必填,模拟ID - "platform": "reddit", // 可选,平台类型(reddit/twitter) - // 不指定则返回两个平台的所有历史 - "agent_id": 0, // 可选,只获取该Agent的采访历史 - "limit": 100 // 可选,返回数量,默认100 + "simulation_id": "sim_xxxx", // required, simulation ID + "platform": "reddit", // optional, platform type (reddit/twitter) + // when not specified, history for both platforms is returned + "agent_id": 0, // optional, only fetch this Agent's interview history + "limit": 100 // optional, return count, default 100 } - 返回: + Returns: { "success": true, "data": { @@ -2533,8 +2537,8 @@ def get_interview_history(): "history": [ { "agent_id": 0, - "response": "我认为...", - "prompt": "你对这件事有什么看法?", + "response": "I think...", + "prompt": "What do you think about this?", "timestamp": "2025-12-08T10:00:00", "platform": "reddit" }, @@ -2545,12 +2549,12 @@ def get_interview_history(): """ try: data = request.get_json() or {} - + simulation_id = data.get('simulation_id') - platform = data.get('platform') # 不指定则返回两个平台的历史 + platform = data.get('platform') # when not specified, history for both platforms is returned agent_id = data.get('agent_id') limit = data.get('limit', 100) - + if not simulation_id: return jsonify({ "success": False, @@ -2573,7 +2577,7 @@ def get_interview_history(): }) except Exception as e: - logger.error(f"获取Interview历史失败: {str(e)}") + logger.error(f"Failed to fetch Interview history: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -2584,16 +2588,16 @@ def get_interview_history(): @simulation_bp.route('/env-status', methods=['POST']) def get_env_status(): """ - 获取模拟环境状态 + Get the simulation environment status - 检查模拟环境是否存活(可以接收Interview命令) + Check whether the simulation environment is alive (able to receive Interview commands) - 请求(JSON): + Request (JSON): { - "simulation_id": "sim_xxxx" // 必填,模拟ID + "simulation_id": "sim_xxxx" // required, simulation ID } - 返回: + Returns: { "success": true, "data": { @@ -2601,15 +2605,15 @@ def get_env_status(): "env_alive": true, "twitter_available": true, "reddit_available": true, - "message": "环境正在运行,可以接收Interview命令" + "message": "Environment is running and ready to receive Interview commands" } } """ try: data = request.get_json() or {} - + simulation_id = data.get('simulation_id') - + if not simulation_id: return jsonify({ "success": False, @@ -2617,8 +2621,8 @@ def get_env_status(): }), 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: @@ -2638,7 +2642,7 @@ def get_env_status(): }) except Exception as e: - logger.error(f"获取环境状态失败: {str(e)}") + logger.error(f"Failed to fetch environment status: {str(e)}") return jsonify({ "success": False, "error": str(e), @@ -2649,24 +2653,26 @@ def get_env_status(): @simulation_bp.route('/close-env', methods=['POST']) def close_simulation_env(): """ - 关闭模拟环境 - - 向模拟发送关闭环境命令,使其优雅退出等待命令模式。 - - 注意:这不同于 /stop 接口,/stop 会强制终止进程, - 而此接口会让模拟优雅地关闭环境并退出。 - - 请求(JSON): + Close the simulation environment + + Sends a close-environment command to the simulation so that it gracefully + exits the wait-for-command mode. + + Note: this differs from the /stop endpoint - /stop forcefully terminates + the process, while this endpoint lets the simulation gracefully close the + environment and exit. + + Request (JSON): { - "simulation_id": "sim_xxxx", // 必填,模拟ID - "timeout": 30 // 可选,超时时间(秒),默认30 + "simulation_id": "sim_xxxx", // required, simulation ID + "timeout": 30 // optional, timeout in seconds, default 30 } - - 返回: + + Returns: { "success": true, "data": { - "message": "环境关闭命令已发送", + "message": "Environment close command has been sent", "result": {...}, "timestamp": "2025-12-08T10:00:01" } @@ -2674,41 +2680,41 @@ def close_simulation_env(): """ try: data = request.get_json() or {} - + simulation_id = data.get('simulation_id') timeout = data.get('timeout', 30) - + if not simulation_id: return jsonify({ "success": False, "error": t('api.requireSimulationId') }), 400 - + result = SimulationRunner.close_simulation_env( simulation_id=simulation_id, timeout=timeout ) - - # 更新模拟状态 + + # Update the simulation status manager = SimulationManager() state = manager.get_simulation(simulation_id) if state: state.status = SimulationStatus.COMPLETED manager._save_simulation_state(state) - + return jsonify({ "success": result.get("success", False), "data": result }) - + except ValueError as e: return jsonify({ "success": False, "error": str(e) }), 400 - + except Exception as e: - logger.error(f"关闭环境失败: {str(e)}") + logger.error(f"Failed to close environment: {str(e)}") return jsonify({ "success": False, "error": str(e), diff --git a/backend/app/config.py b/backend/app/config.py index deb8c1cf..acb51367 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,63 +1,64 @@ """ -配置管理 -统一从项目根目录的 .env 文件加载配置 +Configuration management. +Loads configuration from the ``.env`` file at the project root. """ import os from dotenv import load_dotenv -# 加载项目根目录的 .env 文件 -# 路径: MiroFish/.env (相对于 backend/app/config.py) +# Load the project-root .env file. +# Path: MiroFish/.env (relative to backend/app/config.py) project_root_env = os.path.join(os.path.dirname(__file__), '../../.env') if os.path.exists(project_root_env): load_dotenv(project_root_env, override=True) else: - # 如果根目录没有 .env,尝试加载环境变量(用于生产环境) + # If no .env at the root, fall back to environment variables (production-style). load_dotenv(override=True) class Config: - """Flask配置类""" - - # Flask配置 + """Flask configuration class.""" + + # Flask configuration SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key') DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true' - - # JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式) + + # JSON configuration - disable ASCII escaping so Chinese characters are emitted + # directly instead of being encoded as \\uXXXX. JSON_AS_ASCII = False - - # LLM配置(统一使用OpenAI格式) + + # LLM configuration (uniformly using the OpenAI-compatible interface) LLM_API_KEY = os.environ.get('LLM_API_KEY') LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1') LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini') - - # FalkorDB配置(replaces Zep) + + # FalkorDB configuration (replaces Zep) FALKORDB_HOST = os.environ.get('FALKORDB_HOST', 'localhost') FALKORDB_PORT = int(os.environ.get('FALKORDB_PORT', '6379')) FALKORDB_USERNAME = os.environ.get('FALKORDB_USERNAME', None) or None FALKORDB_PASSWORD = os.environ.get('FALKORDB_PASSWORD', None) or None - - # 嵌入模型:本地 sentence-transformers(pre-downloaded in image) + + # Embedding model: local sentence-transformers (pre-downloaded in image) EMBEDDING_MODEL = os.environ.get( 'EMBEDDING_MODEL', 'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2', ) - - # 文件上传配置 + + # File upload configuration MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), '../uploads') ALLOWED_EXTENSIONS = {'pdf', 'md', 'txt', 'markdown'} - - # 文本处理配置 - DEFAULT_CHUNK_SIZE = 500 # 默认切块大小 - DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小 - - # OASIS模拟配置 + + # Text processing configuration + DEFAULT_CHUNK_SIZE = 500 # default chunk size + DEFAULT_CHUNK_OVERLAP = 50 # default overlap + + # OASIS simulation configuration OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10')) OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations') - - # OASIS平台可用动作配置 + + # OASIS platform available actions OASIS_TWITTER_ACTIONS = [ 'CREATE_POST', 'LIKE_POST', 'REPOST', 'FOLLOW', 'DO_NOTHING', 'QUOTE_POST' ] @@ -66,19 +67,19 @@ class Config: 'LIKE_COMMENT', 'DISLIKE_COMMENT', 'SEARCH_POSTS', 'SEARCH_USER', 'TREND', 'REFRESH', 'DO_NOTHING', 'FOLLOW', 'MUTE' ] - - # Report Agent配置 + + # Report Agent configuration REPORT_AGENT_MAX_TOOL_CALLS = int(os.environ.get('REPORT_AGENT_MAX_TOOL_CALLS', '5')) REPORT_AGENT_MAX_REFLECTION_ROUNDS = int(os.environ.get('REPORT_AGENT_MAX_REFLECTION_ROUNDS', '2')) REPORT_AGENT_TEMPERATURE = float(os.environ.get('REPORT_AGENT_TEMPERATURE', '0.5')) - + @classmethod def validate(cls) -> list[str]: - """验证必要配置""" + """Validate required configuration.""" errors: list[str] = [] if not cls.LLM_API_KEY: - errors.append("LLM_API_KEY 未配置") + errors.append("LLM_API_KEY is not configured") # FalkorDB host defaults to localhost; only fail if explicitly empty if not cls.FALKORDB_HOST: - errors.append("FALKORDB_HOST 未配置") + errors.append("FALKORDB_HOST is not configured") return errors diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 55bec619..b0ef22df 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,9 +1,8 @@ """ -数据模型模块 +Data models module. """ from .task import TaskManager, TaskStatus from .project import Project, ProjectStatus, ProjectManager __all__ = ['TaskManager', 'TaskStatus', 'Project', 'ProjectStatus', 'ProjectManager'] - diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 08978937..527be435 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -1,6 +1,7 @@ """ -项目上下文管理 -用于在服务端持久化项目状态,避免前端在接口间传递大量数据 +Project context management. +Persists project state on the server side so the frontend does not need to +shuttle large amounts of data between API calls. """ import os @@ -15,45 +16,45 @@ from ..config import Config class ProjectStatus(str, Enum): - """项目状态""" - CREATED = "created" # 刚创建,文件已上传 - ONTOLOGY_GENERATED = "ontology_generated" # 本体已生成 - GRAPH_BUILDING = "graph_building" # 图谱构建中 - GRAPH_COMPLETED = "graph_completed" # 图谱构建完成 - FAILED = "failed" # 失败 + """Project lifecycle status.""" + CREATED = "created" # just created, files uploaded + ONTOLOGY_GENERATED = "ontology_generated" # ontology has been generated + GRAPH_BUILDING = "graph_building" # graph is being built + GRAPH_COMPLETED = "graph_completed" # graph build complete + FAILED = "failed" # failed @dataclass class Project: - """项目数据模型""" + """Project data model.""" project_id: str name: str status: ProjectStatus created_at: str updated_at: str - - # 文件信息 + + # File metadata files: List[Dict[str, str]] = field(default_factory=list) # [{filename, path, size}] total_text_length: int = 0 - - # 本体信息(接口1生成后填充) + + # Ontology info (populated after endpoint 1) ontology: Optional[Dict[str, Any]] = None analysis_summary: Optional[str] = None - - # 图谱信息(接口2完成后填充) + + # Graph info (populated after endpoint 2 completes) graph_id: Optional[str] = None graph_build_task_id: Optional[str] = None - - # 配置 + + # Configuration simulation_requirement: Optional[str] = None chunk_size: int = 500 chunk_overlap: int = 50 - - # 错误信息 + + # Error information error: Optional[str] = None - + def to_dict(self) -> Dict[str, Any]: - """转换为字典""" + """Convert to a plain dictionary.""" return { "project_id": self.project_id, "name": self.name, @@ -71,14 +72,14 @@ class Project: "chunk_overlap": self.chunk_overlap, "error": self.error } - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'Project': - """从字典创建""" + """Create a Project from a plain dictionary.""" status = data.get('status', 'created') if isinstance(status, str): status = ProjectStatus(status) - + return cls( project_id=data['project_id'], name=data.get('name', 'Unnamed Project'), @@ -99,52 +100,52 @@ class Project: class ProjectManager: - """项目管理器 - 负责项目的持久化存储和检索""" - - # 项目存储根目录 + """Project manager - responsible for persistent storage and retrieval of projects.""" + + # Project storage root directory PROJECTS_DIR = os.path.join(Config.UPLOAD_FOLDER, 'projects') - + @classmethod def _ensure_projects_dir(cls): - """确保项目目录存在""" + """Make sure the projects directory exists.""" os.makedirs(cls.PROJECTS_DIR, exist_ok=True) - + @classmethod def _get_project_dir(cls, project_id: str) -> str: - """获取项目目录路径""" + """Get the directory path for a project.""" return os.path.join(cls.PROJECTS_DIR, project_id) - + @classmethod def _get_project_meta_path(cls, project_id: str) -> str: - """获取项目元数据文件路径""" + """Get the path to a project's metadata file.""" return os.path.join(cls._get_project_dir(project_id), 'project.json') - + @classmethod def _get_project_files_dir(cls, project_id: str) -> str: - """获取项目文件存储目录""" + """Get the directory where a project's files are stored.""" return os.path.join(cls._get_project_dir(project_id), 'files') - + @classmethod def _get_project_text_path(cls, project_id: str) -> str: - """获取项目提取文本存储路径""" + """Get the path to a project's extracted text.""" return os.path.join(cls._get_project_dir(project_id), 'extracted_text.txt') - + @classmethod def create_project(cls, name: str = "Unnamed Project") -> Project: """ - 创建新项目 - + Create a new project. + Args: - name: 项目名称 - + name: project name. + Returns: - 新创建的Project对象 + The newly created Project object. """ cls._ensure_projects_dir() - + project_id = f"proj_{uuid.uuid4().hex[:12]}" now = datetime.now().isoformat() - + project = Project( project_id=project_id, name=name, @@ -152,154 +153,153 @@ class ProjectManager: created_at=now, updated_at=now ) - - # 创建项目目录结构 + + # Create the project directory structure project_dir = cls._get_project_dir(project_id) files_dir = cls._get_project_files_dir(project_id) os.makedirs(project_dir, exist_ok=True) os.makedirs(files_dir, exist_ok=True) - - # 保存项目元数据 + + # Persist the project metadata cls.save_project(project) - + return project - + @classmethod def save_project(cls, project: Project) -> None: - """保存项目元数据""" + """Persist project metadata to disk.""" project.updated_at = datetime.now().isoformat() meta_path = cls._get_project_meta_path(project.project_id) - + with open(meta_path, 'w', encoding='utf-8') as f: json.dump(project.to_dict(), f, ensure_ascii=False, indent=2) - + @classmethod def get_project(cls, project_id: str) -> Optional[Project]: """ - 获取项目 - + Retrieve a project by ID. + Args: - project_id: 项目ID - + project_id: project ID. + Returns: - Project对象,如果不存在返回None + The Project object, or None if it does not exist. """ meta_path = cls._get_project_meta_path(project_id) - + if not os.path.exists(meta_path): return None - + with open(meta_path, 'r', encoding='utf-8') as f: data = json.load(f) - + return Project.from_dict(data) - + @classmethod def list_projects(cls, limit: int = 50) -> List[Project]: """ - 列出所有项目 - + List all projects. + Args: - limit: 返回数量限制 - + limit: maximum number of results to return. + Returns: - 项目列表,按创建时间倒序 + A list of projects, sorted newest-first by creation time. """ cls._ensure_projects_dir() - + projects = [] for project_id in os.listdir(cls.PROJECTS_DIR): project = cls.get_project(project_id) if project: projects.append(project) - - # 按创建时间倒序排序 + + # Sort newest-first by creation time projects.sort(key=lambda p: p.created_at, reverse=True) - + return projects[:limit] - + @classmethod def delete_project(cls, project_id: str) -> bool: """ - 删除项目及其所有文件 - + Delete a project and all of its files. + Args: - project_id: 项目ID - + project_id: project ID. + Returns: - 是否删除成功 + True if the project was deleted, False otherwise. """ project_dir = cls._get_project_dir(project_id) - + if not os.path.exists(project_dir): return False - + shutil.rmtree(project_dir) return True - + @classmethod def save_file_to_project(cls, project_id: str, file_storage, original_filename: str) -> Dict[str, str]: """ - 保存上传的文件到项目目录 - + Save an uploaded file into the project's directory. + Args: - project_id: 项目ID - file_storage: Flask的FileStorage对象 - original_filename: 原始文件名 - + project_id: project ID. + file_storage: Flask FileStorage object. + original_filename: original filename. + Returns: - 文件信息字典 {filename, path, size} + Dictionary with file metadata: {filename, path, size}. """ files_dir = cls._get_project_files_dir(project_id) os.makedirs(files_dir, exist_ok=True) - - # 生成安全的文件名 + + # Build a safe filename ext = os.path.splitext(original_filename)[1].lower() safe_filename = f"{uuid.uuid4().hex[:8]}{ext}" file_path = os.path.join(files_dir, safe_filename) - - # 保存文件 + + # Save the file file_storage.save(file_path) - - # 获取文件大小 + + # Read back its size file_size = os.path.getsize(file_path) - + return { "original_filename": original_filename, "saved_filename": safe_filename, "path": file_path, "size": file_size } - + @classmethod def save_extracted_text(cls, project_id: str, text: str) -> None: - """保存提取的文本""" + """Persist extracted text to disk.""" text_path = cls._get_project_text_path(project_id) with open(text_path, 'w', encoding='utf-8') as f: f.write(text) - + @classmethod def get_extracted_text(cls, project_id: str) -> Optional[str]: - """获取提取的文本""" + """Read extracted text from disk.""" text_path = cls._get_project_text_path(project_id) - + if not os.path.exists(text_path): return None - + with open(text_path, 'r', encoding='utf-8') as f: return f.read() - + @classmethod def get_project_files(cls, project_id: str) -> List[str]: - """获取项目的所有文件路径""" + """Return the absolute paths of all files in the project.""" files_dir = cls._get_project_files_dir(project_id) - + if not os.path.exists(files_dir): return [] - + return [ - os.path.join(files_dir, f) - for f in os.listdir(files_dir) + os.path.join(files_dir, f) + for f in os.listdir(files_dir) if os.path.isfile(os.path.join(files_dir, f)) ] - diff --git a/backend/app/models/task.py b/backend/app/models/task.py index dfebed23..9334bb7c 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -1,6 +1,6 @@ """ -任务状态管理 -用于跟踪长时间运行的任务(如图谱构建) +Task status management. +Tracks long-running tasks (such as graph building) and their progress. """ import uuid @@ -14,30 +14,30 @@ from ..utils.locale import t class TaskStatus(str, Enum): - """任务状态枚举""" - PENDING = "pending" # 等待中 - PROCESSING = "processing" # 处理中 - COMPLETED = "completed" # 已完成 - FAILED = "failed" # 失败 + """Task status enum.""" + PENDING = "pending" # waiting + PROCESSING = "processing" # in progress + COMPLETED = "completed" # finished + FAILED = "failed" # failed @dataclass class Task: - """任务数据类""" + """Task data class.""" task_id: str task_type: str status: TaskStatus created_at: datetime updated_at: datetime - progress: int = 0 # 总进度百分比 0-100 - message: str = "" # 状态消息 - result: Optional[Dict] = None # 任务结果 - error: Optional[str] = None # 错误信息 - metadata: Dict = field(default_factory=dict) # 额外元数据 - progress_detail: Dict = field(default_factory=dict) # 详细进度信息 - + progress: int = 0 # overall progress percentage 0-100 + message: str = "" # status message + result: Optional[Dict] = None # task result payload + error: Optional[str] = None # error message + metadata: Dict = field(default_factory=dict) # extra metadata + progress_detail: Dict = field(default_factory=dict) # detailed progress + def to_dict(self) -> Dict[str, Any]: - """转换为字典""" + """Convert to a plain dictionary.""" return { "task_id": self.task_id, "task_type": self.task_type, @@ -55,15 +55,15 @@ class Task: class TaskManager: """ - 任务管理器 - 线程安全的任务状态管理 + Task manager. + Thread-safe tracking of task state and progress. """ - + _instance = None _lock = threading.Lock() - + def __new__(cls): - """单例模式""" + """Singleton accessor.""" if cls._instance is None: with cls._lock: if cls._instance is None: @@ -71,21 +71,21 @@ class TaskManager: cls._instance._tasks: Dict[str, Task] = {} cls._instance._task_lock = threading.Lock() return cls._instance - + def create_task(self, task_type: str, metadata: Optional[Dict] = None) -> str: """ - 创建新任务 - + Create a new task. + Args: - task_type: 任务类型 - metadata: 额外元数据 - + task_type: task type. + metadata: extra metadata. + Returns: - 任务ID + The new task ID. """ task_id = str(uuid.uuid4()) now = datetime.now() - + task = Task( task_id=task_id, task_type=task_type, @@ -94,17 +94,17 @@ class TaskManager: updated_at=now, metadata=metadata or {} ) - + with self._task_lock: self._tasks[task_id] = task - + return task_id - + def get_task(self, task_id: str) -> Optional[Task]: - """获取任务""" + """Fetch a task by ID.""" with self._task_lock: return self._tasks.get(task_id) - + def update_task( self, task_id: str, @@ -116,16 +116,16 @@ class TaskManager: progress_detail: Optional[Dict] = None ): """ - 更新任务状态 - + Update task state. + Args: - task_id: 任务ID - status: 新状态 - progress: 进度 - message: 消息 - result: 结果 - error: 错误信息 - progress_detail: 详细进度信息 + task_id: task ID. + status: new status. + progress: progress percentage. + message: status message. + result: result payload. + error: error message. + progress_detail: detailed progress information. """ with self._task_lock: task = self._tasks.get(task_id) @@ -143,9 +143,9 @@ class TaskManager: task.error = error if progress_detail is not None: task.progress_detail = progress_detail - + def complete_task(self, task_id: str, result: Dict): - """标记任务完成""" + """Mark a task as completed.""" self.update_task( task_id, status=TaskStatus.COMPLETED, @@ -153,29 +153,29 @@ class TaskManager: message=t('progress.taskComplete'), result=result ) - + def fail_task(self, task_id: str, error: str): - """标记任务失败""" + """Mark a task as failed.""" self.update_task( task_id, status=TaskStatus.FAILED, message=t('progress.taskFailed'), error=error ) - + def list_tasks(self, task_type: Optional[str] = None) -> list: - """列出任务""" + """List tasks, optionally filtered by type.""" with self._task_lock: tasks = list(self._tasks.values()) if task_type: tasks = [t for t in tasks if t.task_type == task_type] return [t.to_dict() for t in sorted(tasks, key=lambda x: x.created_at, reverse=True)] - + def cleanup_old_tasks(self, max_age_hours: int = 24): - """清理旧任务""" + """Remove tasks older than the given threshold.""" from datetime import timedelta cutoff = datetime.now() - timedelta(hours=max_age_hours) - + with self._task_lock: old_ids = [ tid for tid, task in self._tasks.items() @@ -183,4 +183,3 @@ class TaskManager: ] for tid in old_ids: del self._tasks[tid] - diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 8db85d86..a2e729da 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,5 +1,5 @@ """ -业务服务模块 +Business services module. """ from .ontology_generator import OntologyGenerator @@ -9,7 +9,7 @@ from .zep_entity_reader import ZepEntityReader, EntityNode, FilteredEntities from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile from .simulation_manager import SimulationManager, SimulationState, SimulationStatus from .simulation_config_generator import ( - SimulationConfigGenerator, + SimulationConfigGenerator, SimulationParameters, AgentActivityConfig, TimeSimulationConfig, @@ -38,8 +38,8 @@ from .simulation_ipc import ( ) __all__ = [ - 'OntologyGenerator', - 'GraphBuilderService', + 'OntologyGenerator', + 'GraphBuilderService', 'TextProcessor', 'ZepEntityReader', 'EntityNode', @@ -70,4 +70,3 @@ __all__ = [ 'CommandType', 'CommandStatus', ] - diff --git a/backend/app/services/graph_builder.py b/backend/app/services/graph_builder.py index a5bb95fb..f12244ac 100644 --- a/backend/app/services/graph_builder.py +++ b/backend/app/services/graph_builder.py @@ -1,6 +1,6 @@ """ -图谱构建服务 -接口2:使用Zep API构建Standalone Graph +Graph build service +Interface 2: build a Standalone Graph via the Zep API. """ import os @@ -40,12 +40,12 @@ from ..utils.locale import t, get_locale, set_locale @dataclass class GraphInfo: - """图谱信息""" + """Graph info""" graph_id: str node_count: int edge_count: int entity_types: List[str] - + def to_dict(self) -> Dict[str, Any]: return { "graph_id": self.graph_id, @@ -57,17 +57,17 @@ class GraphInfo: class GraphBuilderService: """ - 图谱构建服务 - 负责调用Zep API构建知识图谱 + Graph build service + Responsible for calling the Zep API to build the knowledge graph. """ - + def __init__(self, api_key: Optional[str] = None): # Zep's per-project graph becomes Graphiti's group_id (a partition). # The single shared GraphitiAdapter handles all projects. self.api_key = api_key # kept for signature compat; no longer required self.client = get_graphiti_adapter() self.task_manager = TaskManager() - + def build_graph_async( self, text: str, @@ -78,20 +78,20 @@ class GraphBuilderService: batch_size: int = 3 ) -> str: """ - 异步构建图谱 - + Build the graph asynchronously. + Args: - text: 输入文本 - ontology: 本体定义(来自接口1的输出) - graph_name: 图谱名称 - chunk_size: 文本块大小 - chunk_overlap: 块重叠大小 - batch_size: 每批发送的块数量 - + text: Input text + ontology: Ontology definition (output of interface 1) + graph_name: Graph name + chunk_size: Text chunk size + chunk_overlap: Chunk overlap size + batch_size: Number of chunks sent per batch + Returns: - 任务ID + Task ID """ - # 创建任务 + # Create task task_id = self.task_manager.create_task( task_type="graph_build", metadata={ @@ -100,20 +100,20 @@ class GraphBuilderService: "text_length": len(text), } ) - + # Capture locale before spawning background thread current_locale = get_locale() - # 在后台线程中执行构建 + # Execute the build in a background thread thread = threading.Thread( target=self._build_graph_worker, args=(task_id, text, ontology, graph_name, chunk_size, chunk_overlap, batch_size, current_locale) ) thread.daemon = True thread.start() - + return task_id - + def _build_graph_worker( self, task_id: str, @@ -125,7 +125,7 @@ class GraphBuilderService: batch_size: int, locale: str = 'zh' ): - """图谱构建工作线程""" + """Graph build worker thread""" set_locale(locale) try: self.task_manager.update_task( @@ -134,24 +134,24 @@ class GraphBuilderService: progress=5, message=t('progress.startBuildingGraph') ) - - # 1. 创建图谱 + + # 1. Create the graph graph_id = self.create_graph(graph_name) self.task_manager.update_task( task_id, progress=10, message=t('progress.graphCreated', graphId=graph_id) ) - - # 2. 设置本体 + + # 2. Set the ontology self.set_ontology(graph_id, ontology) self.task_manager.update_task( task_id, progress=15, message=t('progress.ontologySet') ) - - # 3. 文本分块 + + # 3. Split the text into chunks chunks = TextProcessor.split_text(text, chunk_size, chunk_overlap) total_chunks = len(chunks) self.task_manager.update_task( @@ -159,8 +159,8 @@ class GraphBuilderService: progress=20, message=t('progress.textSplit', count=total_chunks) ) - - # 4. 分批发送数据 + + # 4. Send data in batches episode_uuids = self.add_text_batches( graph_id, chunks, batch_size, lambda msg, prog: self.task_manager.update_task( @@ -169,14 +169,14 @@ class GraphBuilderService: message=msg ) ) - - # 5. 等待Zep处理完成 + + # 5. Wait for Zep processing to finish self.task_manager.update_task( task_id, progress=60, message=t('progress.waitingZepProcess') ) - + self._wait_for_episodes( episode_uuids, lambda msg, prog: self.task_manager.update_task( @@ -185,30 +185,30 @@ class GraphBuilderService: message=msg ) ) - - # 6. 获取图谱信息 + + # 6. Fetch graph info self.task_manager.update_task( task_id, progress=90, message=t('progress.fetchingGraphInfo') ) - + graph_info = self._get_graph_info(graph_id) - - # 完成 + + # Complete self.task_manager.complete_task(task_id, { "graph_id": graph_id, "graph_info": graph_info.to_dict(), "chunks_processed": total_chunks, }) - + except Exception as e: import traceback error_msg = f"{str(e)}\n{traceback.format_exc()}" self.task_manager.fail_task(task_id, error_msg) - + def create_graph(self, name: str) -> str: - """创建图谱(公开方法)""" + """Create a graph (public method)""" graph_id = f"mirofish_{uuid.uuid4().hex[:16]}" # Graphiti creates the partition implicitly on first add_episode; this # call just reserves the group_id. @@ -217,11 +217,11 @@ class GraphBuilderService: name=name, description="MiroFish Social Simulation Graph", ) - + return graph_id - + def set_ontology(self, graph_id: str, ontology: Dict[str, Any]): - """设置图谱本体(公开方法)""" + """Set the graph ontology (public method)""" import warnings from typing import Optional from pydantic import Field @@ -242,69 +242,70 @@ class GraphBuilderService: from pydantic import BaseModel as EntityModel # type: ignore[assignment,misc] from pydantic import BaseModel as EdgeModel # type: ignore[assignment,misc] EntityText = str # type: ignore[assignment,misc] - - # 抑制 Pydantic v2 关于 Field(default=None) 的警告 - # 这是 Zep SDK 要求的用法,警告来自动态类创建,可以安全忽略 + + # Suppress Pydantic v2 warning about Field(default=None) + # This is required by the Zep SDK; the warning comes from dynamic + # class creation and can safely be ignored. warnings.filterwarnings('ignore', category=UserWarning, module='pydantic') - - # Zep 保留名称,不能作为属性名 + + # Zep reserved names; cannot be used as attribute names RESERVED_NAMES = {'uuid', 'name', 'group_id', 'name_embedding', 'summary', 'created_at'} - + def safe_attr_name(attr_name: str) -> str: - """将保留名称转换为安全名称""" + """Convert a reserved name into a safe name""" if attr_name.lower() in RESERVED_NAMES: return f"entity_{attr_name}" return attr_name - - # 动态创建实体类型 + + # Dynamically create entity types entity_types = {} for entity_def in ontology.get("entity_types", []): name = entity_def["name"] description = entity_def.get("description", f"A {name} entity.") - - # 创建属性字典和类型注解(Pydantic v2 需要) + + # Build the attribute dict and type annotations (required by Pydantic v2) attrs = {"__doc__": description} annotations = {} - + for attr_def in entity_def.get("attributes", []): - attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称 + attr_name = safe_attr_name(attr_def["name"]) # Use the safe name attr_desc = attr_def.get("description", attr_name) - # Zep API 需要 Field 的 description,这是必需的 + # Zep API requires a description on Field; this is required attrs[attr_name] = Field(description=attr_desc, default=None) - annotations[attr_name] = Optional[EntityText] # 类型注解 - + annotations[attr_name] = Optional[EntityText] # type annotation + attrs["__annotations__"] = annotations - - # 动态创建类 + + # Dynamically create the class entity_class = type(name, (EntityModel,), attrs) entity_class.__doc__ = description entity_types[name] = entity_class - - # 动态创建边类型 + + # Dynamically create edge types edge_definitions = {} for edge_def in ontology.get("edge_types", []): name = edge_def["name"] description = edge_def.get("description", f"A {name} relationship.") - - # 创建属性字典和类型注解 + + # Build the attribute dict and type annotations attrs = {"__doc__": description} annotations = {} - + for attr_def in edge_def.get("attributes", []): - attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称 + attr_name = safe_attr_name(attr_def["name"]) # Use the safe name attr_desc = attr_def.get("description", attr_name) - # Zep API 需要 Field 的 description,这是必需的 + # Zep API requires a description on Field; this is required attrs[attr_name] = Field(description=attr_desc, default=None) - annotations[attr_name] = Optional[str] # 边属性用str类型 - + annotations[attr_name] = Optional[str] # Edge attributes use str type + attrs["__annotations__"] = annotations - - # 动态创建类 + + # Dynamically create the class class_name = ''.join(word.capitalize() for word in name.split('_')) edge_class = type(class_name, (EdgeModel,), attrs) edge_class.__doc__ = description - - # 构建source_targets + + # Build source_targets source_targets = [] for st in edge_def.get("source_targets", []): source_targets.append( @@ -313,10 +314,10 @@ class GraphBuilderService: target=st.get("target", "Entity") ) ) - + if source_targets: edge_definitions[name] = (edge_class, source_targets) - + # Graphiti accepts the original JSON ontology directly. The Pydantic # class construction above was a Zep-specific quirk; we can skip it # for Graphiti and just forward the input. Keep the code path the same @@ -325,7 +326,7 @@ class GraphBuilderService: # uses the adapter with the source-of-truth JSON. if entity_types or edge_definitions: self.client.set_ontology(graph_id=graph_id, ontology=ontology) - + def add_text_batches( self, graph_id: str, @@ -333,56 +334,56 @@ class GraphBuilderService: batch_size: int = 3, progress_callback: Optional[Callable] = None ) -> List[str]: - """分批添加文本到图谱,返回所有 episode 的 uuid 列表""" + """Add text to the graph in batches, returning a list of episode uuids""" episode_uuids = [] total_chunks = len(chunks) - + for i in range(0, total_chunks, batch_size): batch_chunks = chunks[i:i + batch_size] batch_num = i // batch_size + 1 total_batches = (total_chunks + batch_size - 1) // batch_size - + if progress_callback: progress = (i + len(batch_chunks)) / total_chunks progress_callback( t('progress.sendingBatch', current=batch_num, total=total_batches, chunks=len(batch_chunks)), progress ) - - # 构建episode数据 + + # Build episode data episodes = [ EpisodeData(data=chunk, type="text") for chunk in batch_chunks ] - - # 发送到图谱(Graphiti via adapter — returns after sync extraction) + + # Send to the graph (Graphiti via adapter — returns after sync extraction) try: batch_result = self.client.add_batch( graph_id=graph_id, episodes=episodes, ) - # 收集返回的 episode uuid + # Collect returned episode uuids if batch_result and isinstance(batch_result, list): for ep in batch_result: ep_uuid = getattr(ep, 'uuid_', None) or getattr(ep, 'uuid', None) if ep_uuid: episode_uuids.append(ep_uuid) - + except Exception as e: if progress_callback: progress_callback(t('progress.batchFailed', batch=batch_num, error=str(e)), 0) raise - + return episode_uuids - + def _wait_for_episodes( self, episode_uuids: List[str], progress_callback: Optional[Callable] = None, timeout: int = 600 ): - """等待所有 episode 处理完成。 + """Wait for all episodes to finish processing. Graphiti's add_episode is synchronous (extraction completes before the call returns), so all episodes returned by add_batch are already done. @@ -397,16 +398,16 @@ class GraphBuilderService: if progress_callback: progress_callback(t('progress.waitingEpisodes', count=len(episode_uuids)), 0) progress_callback(t('progress.processingComplete', completed=len(episode_uuids), total=len(episode_uuids)), 1.0) - + def _get_graph_info(self, graph_id: str) -> GraphInfo: - """获取图谱信息""" - # 获取节点(分页) + """Get graph info""" + # Get nodes (paginated) nodes = fetch_all_nodes(self.client, graph_id) - # 获取边(分页) + # Get edges (paginated) edges = fetch_all_edges(self.client, graph_id) - # 统计实体类型 + # Aggregate entity types entity_types = set() for node in nodes: if node.labels: @@ -420,32 +421,33 @@ class GraphBuilderService: edge_count=len(edges), entity_types=list(entity_types) ) - + def get_graph_data(self, graph_id: str) -> Dict[str, Any]: """ - 获取完整图谱数据(包含详细信息) - + Get the complete graph data (including detailed info). + Args: - graph_id: 图谱ID - + graph_id: Graph ID + Returns: - 包含nodes和edges的字典,包括时间信息、属性等详细数据 + A dict containing nodes and edges, including timing info, + attributes, and other detailed data. """ nodes = fetch_all_nodes(self.client, graph_id) edges = fetch_all_edges(self.client, graph_id) - # 创建节点映射用于获取节点名称 + # Build a node-name map node_map = {} for node in nodes: node_map[node.uuid_] = node.name or "" - + nodes_data = [] for node in nodes: - # 获取创建时间 + # Get creation time created_at = getattr(node, 'created_at', None) if created_at: created_at = str(created_at) - + nodes_data.append({ "uuid": node.uuid_, "name": node.name, @@ -454,25 +456,25 @@ class GraphBuilderService: "attributes": node.attributes or {}, "created_at": created_at, }) - + edges_data = [] for edge in edges: - # 获取时间信息 + # Get time info created_at = getattr(edge, 'created_at', None) valid_at = getattr(edge, 'valid_at', None) invalid_at = getattr(edge, 'invalid_at', None) expired_at = getattr(edge, 'expired_at', None) - - # 获取 episodes + + # Get episodes episodes = getattr(edge, 'episodes', None) or getattr(edge, 'episode_ids', None) if episodes and not isinstance(episodes, list): episodes = [str(episodes)] elif episodes: episodes = [str(e) for e in episodes] - - # 获取 fact_type + + # Get fact_type fact_type = getattr(edge, 'fact_type', None) or edge.name or "" - + edges_data.append({ "uuid": edge.uuid_, "name": edge.name or "", @@ -489,7 +491,7 @@ class GraphBuilderService: "expired_at": str(expired_at) if expired_at else None, "episodes": episodes or [], }) - + return { "graph_id": graph_id, "nodes": nodes_data, @@ -499,6 +501,6 @@ class GraphBuilderService: } def delete_graph(self, graph_id: str): - """删除图谱""" + """Delete a graph""" self.client.delete_graph(graph_id=graph_id) diff --git a/backend/app/services/oasis_profile_generator.py b/backend/app/services/oasis_profile_generator.py index 77ed0630..781c6c21 100644 --- a/backend/app/services/oasis_profile_generator.py +++ b/backend/app/services/oasis_profile_generator.py @@ -1,11 +1,11 @@ """ -OASIS Agent Profile生成器 -将Zep图谱中的实体转换为OASIS模拟平台所需的Agent Profile格式 +OASIS Agent Profile generator +Convert entities from the Zep graph into the Agent Profile format required by the OASIS simulation platform -优化改进: -1. 调用Zep检索功能二次丰富节点信息 -2. 优化提示词生成非常详细的人设 -3. 区分个人实体和抽象群体实体 +Optimisations: +1. Use Zep retrieval to enrich node information +2. Refine the prompt to produce very detailed personas +3. Distinguish between individual and abstract-group entities """ import json @@ -34,23 +34,23 @@ logger = get_logger('mirofish.oasis_profile') @dataclass class OasisAgentProfile: - """OASIS Agent Profile数据结构""" - # 通用字段 + """OASIS Agent Profile data structure""" + # Common fields user_id: int user_name: str name: str bio: str persona: str - # 可选字段 - Reddit风格 + # Optional fields — Reddit style karma: int = 1000 - # 可选字段 - Twitter风格 + # Optional fields — Twitter style friend_count: int = 100 follower_count: int = 150 statuses_count: int = 500 - # 额外人设信息 + # Extra persona information age: Optional[int] = None gender: Optional[str] = None mbti: Optional[str] = None @@ -58,17 +58,17 @@ class OasisAgentProfile: profession: Optional[str] = None interested_topics: List[str] = field(default_factory=list) - # 来源实体信息 + # Source entity information source_entity_uuid: Optional[str] = None source_entity_type: Optional[str] = None created_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d")) def to_reddit_format(self) -> Dict[str, Any]: - """转换为Reddit平台格式""" + """Convert to the Reddit platform format""" profile = { "user_id": self.user_id, - "username": self.user_name, # OASIS 库要求字段名为 username(无下划线) + "username": self.user_name, # OASIS expects the field name 'username' (no underscore) "name": self.name, "bio": self.bio, "persona": self.persona, @@ -76,7 +76,7 @@ class OasisAgentProfile: "created_at": self.created_at, } - # 添加额外人设信息(如果有) + # Add extra persona information (if any) if self.age: profile["age"] = self.age if self.gender: @@ -93,10 +93,10 @@ class OasisAgentProfile: return profile def to_twitter_format(self) -> Dict[str, Any]: - """转换为Twitter平台格式""" + """Convert to the Twitter platform format""" profile = { "user_id": self.user_id, - "username": self.user_name, # OASIS 库要求字段名为 username(无下划线) + "username": self.user_name, # OASIS expects the field name 'username' (no underscore) "name": self.name, "bio": self.bio, "persona": self.persona, @@ -106,7 +106,7 @@ class OasisAgentProfile: "created_at": self.created_at, } - # 添加额外人设信息 + # Add extra persona information if self.age: profile["age"] = self.age if self.gender: @@ -123,7 +123,7 @@ class OasisAgentProfile: return profile def to_dict(self) -> Dict[str, Any]: - """转换为完整字典格式""" + """Convert to the full dict format""" return { "user_id": self.user_id, "user_name": self.user_name, @@ -148,17 +148,17 @@ class OasisAgentProfile: class OasisProfileGenerator: """ - OASIS Profile生成器 + OASIS Profile generator - 将Zep图谱中的实体转换为OASIS模拟所需的Agent Profile + Convert entities from the Zep graph into the Agent Profile format required by OASIS simulation. - 优化特性: - 1. 调用Zep图谱检索功能获取更丰富的上下文 - 2. 生成非常详细的人设(包括基本信息、职业经历、性格特征、社交媒体行为等) - 3. 区分个人实体和抽象群体实体 + Optimisation features: + 1. Use the Zep graph retrieval function to fetch richer context. + 2. Generate very detailed personas (including basic info, career history, personality traits, social-media behaviour, etc.). + 3. Distinguish between individual and abstract-group entities. """ - # MBTI类型列表 + # MBTI type list MBTI_TYPES = [ "INTJ", "INTP", "ENTJ", "ENTP", "INFJ", "INFP", "ENFJ", "ENFP", @@ -166,19 +166,19 @@ class OasisProfileGenerator: "ISTP", "ISFP", "ESTP", "ESFP" ] - # 常见国家列表 + # Common country list COUNTRIES = [ "China", "US", "UK", "Japan", "Germany", "France", "Canada", "Australia", "Brazil", "India", "South Korea" ] - # 个人类型实体(需要生成具体人设) + # Individual-type entities (require a concrete persona) INDIVIDUAL_ENTITY_TYPES = [ "student", "alumni", "professor", "person", "publicfigure", "expert", "faculty", "official", "journalist", "activist" ] - # 群体/机构类型实体(需要生成群体代表人设) + # Group/institutional-type entities (require a representative persona) GROUP_ENTITY_TYPES = [ "university", "governmentagency", "organization", "ngo", "mediaoutlet", "company", "institution", "group", "community" @@ -197,7 +197,7 @@ class OasisProfileGenerator: self.model_name = model_name or Config.LLM_MODEL_NAME if not self.api_key: - raise ValueError("LLM_API_KEY 未配置") + raise ValueError("LLM_API_KEY is not configured") self.client = OpenAI( api_key=self.api_key, @@ -217,27 +217,27 @@ class OasisProfileGenerator: use_llm: bool = True ) -> OasisAgentProfile: """ - 从Zep实体生成OASIS Agent Profile + Generate an OASIS Agent Profile from a Zep entity. Args: - entity: Zep实体节点 - user_id: 用户ID(用于OASIS) - use_llm: 是否使用LLM生成详细人设 + entity: Zep entity node. + user_id: User ID (used by OASIS). + use_llm: Whether to use LLM to generate a detailed persona. Returns: OasisAgentProfile """ entity_type = entity.get_entity_type() or "Entity" - # 基础信息 + # Basic info name = entity.name user_name = self._generate_username(name) - # 构建上下文信息 + # Build context information context = self._build_entity_context(entity) if use_llm: - # 使用LLM生成详细人设 + # Use LLM to generate a detailed persona profile_data = self._generate_profile_with_llm( entity_name=name, entity_type=entity_type, @@ -246,7 +246,7 @@ class OasisProfileGenerator: context=context ) else: - # 使用规则生成基础人设 + # Use rule-based generation for a basic persona profile_data = self._generate_profile_rule_based( entity_name=name, entity_type=entity_type, @@ -275,27 +275,27 @@ class OasisProfileGenerator: ) def _generate_username(self, name: str) -> str: - """生成用户名""" - # 移除特殊字符,转换为小写 + """Generate a username""" + # Strip special characters and lower-case username = name.lower().replace(" ", "_") username = ''.join(c for c in username if c.isalnum() or c == '_') - # 添加随机后缀避免重复 + # Add a random suffix to avoid duplicates suffix = random.randint(100, 999) return f"{username}_{suffix}" def _search_zep_for_entity(self, entity: EntityNode) -> Dict[str, Any]: """ - 使用Zep图谱混合搜索功能获取实体相关的丰富信息 + Use Zep's graph hybrid search to fetch rich information related to the entity. - Zep没有内置混合搜索接口,需要分别搜索edges和nodes然后合并结果。 - 使用并行请求同时搜索,提高效率。 + Zep has no built-in hybrid-search endpoint, so we search edges and nodes separately + and merge the results. The two requests run in parallel for efficiency. Args: - entity: 实体节点对象 + entity: Entity node object. Returns: - 包含facts, node_summaries, context的字典 + A dictionary containing facts, node_summaries, and context. """ import concurrent.futures @@ -310,15 +310,15 @@ class OasisProfileGenerator: "context": "" } - # 必须有graph_id才能进行搜索 + # A graph_id is required to perform a search if not self.graph_id: - logger.debug(f"跳过Zep检索:未设置graph_id") + logger.debug(f"Skipping Zep retrieval: graph_id not set") return results comprehensive_query = t('progress.zepSearchQuery', name=entity_name) def search_edges(): - """搜索边(事实/关系)- 带重试机制""" + """Search edges (facts/relationships) — with retry mechanism.""" max_retries = 3 last_exception = None delay = 2.0 @@ -334,15 +334,15 @@ class OasisProfileGenerator: except Exception as e: last_exception = e if attempt < max_retries - 1: - logger.debug(f"Zep边搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...") + logger.debug(f"Zep edge search attempt {attempt + 1} failed: {str(e)[:80]}, retrying...") time.sleep(delay) delay *= 2 else: - logger.debug(f"Zep边搜索在 {max_retries} 次尝试后仍失败: {e}") + logger.debug(f"Zep edge search failed after {max_retries} attempts: {e}") return None def search_nodes(): - """搜索节点(实体摘要)- 带重试机制""" + """Search nodes (entity summaries) — with retry mechanism.""" max_retries = 3 last_exception = None delay = 2.0 @@ -358,24 +358,24 @@ class OasisProfileGenerator: except Exception as e: last_exception = e if attempt < max_retries - 1: - logger.debug(f"Zep节点搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...") + logger.debug(f"Zep node search attempt {attempt + 1} failed: {str(e)[:80]}, retrying...") time.sleep(delay) delay *= 2 else: - logger.debug(f"Zep节点搜索在 {max_retries} 次尝试后仍失败: {e}") + logger.debug(f"Zep node search failed after {max_retries} attempts: {e}") return None try: - # 并行执行edges和nodes搜索 + # Run edges and nodes searches in parallel with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: edge_future = executor.submit(search_edges) node_future = executor.submit(search_nodes) - # 获取结果 + # Collect results edge_result = edge_future.result(timeout=30) node_result = node_future.result(timeout=30) - # 处理边搜索结果 + # Process edge-search results all_facts = set() if edge_result and hasattr(edge_result, 'edges') and edge_result.edges: for edge in edge_result.edges: @@ -383,58 +383,58 @@ class OasisProfileGenerator: all_facts.add(edge.fact) results["facts"] = list(all_facts) - # 处理节点搜索结果 + # Process node-search results all_summaries = set() if node_result and hasattr(node_result, 'nodes') and node_result.nodes: for node in node_result.nodes: if hasattr(node, 'summary') and node.summary: all_summaries.add(node.summary) if hasattr(node, 'name') and node.name and node.name != entity_name: - all_summaries.add(f"相关实体: {node.name}") + all_summaries.add(f"Related entity: {node.name}") results["node_summaries"] = list(all_summaries) - # 构建综合上下文 + # Build combined context context_parts = [] if results["facts"]: - context_parts.append("事实信息:\n" + "\n".join(f"- {f}" for f in results["facts"][:20])) + context_parts.append("Facts:\n" + "\n".join(f"- {f}" for f in results["facts"][:20])) if results["node_summaries"]: - context_parts.append("相关实体:\n" + "\n".join(f"- {s}" for s in results["node_summaries"][:10])) + context_parts.append("Related entities:\n" + "\n".join(f"- {s}" for s in results["node_summaries"][:10])) results["context"] = "\n\n".join(context_parts) - logger.info(f"Zep混合检索完成: {entity_name}, 获取 {len(results['facts'])} 条事实, {len(results['node_summaries'])} 个相关节点") + logger.info(f"Zep hybrid retrieval done: {entity_name}, fetched {len(results['facts'])} facts and {len(results['node_summaries'])} related nodes") except concurrent.futures.TimeoutError: - logger.warning(f"Zep检索超时 ({entity_name})") + logger.warning(f"Zep retrieval timed out ({entity_name})") except Exception as e: - logger.warning(f"Zep检索失败 ({entity_name}): {e}") + logger.warning(f"Zep retrieval failed ({entity_name}): {e}") return results def _build_entity_context(self, entity: EntityNode) -> str: """ - 构建实体的完整上下文信息 + Build the full context for an entity. - 包括: - 1. 实体本身的边信息(事实) - 2. 关联节点的详细信息 - 3. Zep混合检索到的丰富信息 + Includes: + 1. The entity's own edges (facts). + 2. Detailed information about related nodes. + 3. Rich information from Zep hybrid retrieval. """ context_parts = [] - # 1. 添加实体属性信息 + # 1. Add entity attribute information if entity.attributes: attrs = [] for key, value in entity.attributes.items(): if value and str(value).strip(): attrs.append(f"- {key}: {value}") if attrs: - context_parts.append("### 实体属性\n" + "\n".join(attrs)) + context_parts.append("### Entity attributes\n" + "\n".join(attrs)) - # 2. 添加相关边信息(事实/关系) + # 2. Add related edge information (facts/relationships) existing_facts = set() if entity.related_edges: relationships = [] - for edge in entity.related_edges: # 不限制数量 + for edge in entity.related_edges: # no count limit fact = edge.get("fact", "") edge_name = edge.get("edge_name", "") direction = edge.get("direction", "") @@ -444,22 +444,22 @@ class OasisProfileGenerator: existing_facts.add(fact) elif edge_name: if direction == "outgoing": - relationships.append(f"- {entity.name} --[{edge_name}]--> (相关实体)") + relationships.append(f"- {entity.name} --[{edge_name}]--> (related entity)") else: - relationships.append(f"- (相关实体) --[{edge_name}]--> {entity.name}") + relationships.append(f"- (related entity) --[{edge_name}]--> {entity.name}") if relationships: - context_parts.append("### 相关事实和关系\n" + "\n".join(relationships)) + context_parts.append("### Related facts and relationships\n" + "\n".join(relationships)) - # 3. 添加关联节点的详细信息 + # 3. Add detailed information about related nodes if entity.related_nodes: related_info = [] - for node in entity.related_nodes: # 不限制数量 + for node in entity.related_nodes: # no count limit node_name = node.get("name", "") node_labels = node.get("labels", []) node_summary = node.get("summary", "") - # 过滤掉默认标签 + # Filter out default labels custom_labels = [l for l in node_labels if l not in ["Entity", "Node"]] label_str = f" ({', '.join(custom_labels)})" if custom_labels else "" @@ -469,28 +469,28 @@ class OasisProfileGenerator: related_info.append(f"- **{node_name}**{label_str}") if related_info: - context_parts.append("### 关联实体信息\n" + "\n".join(related_info)) + context_parts.append("### Related entity information\n" + "\n".join(related_info)) - # 4. 使用Zep混合检索获取更丰富的信息 + # 4. Use Zep hybrid retrieval to fetch richer information zep_results = self._search_zep_for_entity(entity) if zep_results.get("facts"): - # 去重:排除已存在的事实 + # Deduplicate: exclude facts already present new_facts = [f for f in zep_results["facts"] if f not in existing_facts] if new_facts: - context_parts.append("### Zep检索到的事实信息\n" + "\n".join(f"- {f}" for f in new_facts[:15])) + context_parts.append("### Facts retrieved from Zep\n" + "\n".join(f"- {f}" for f in new_facts[:15])) if zep_results.get("node_summaries"): - context_parts.append("### Zep检索到的相关节点\n" + "\n".join(f"- {s}" for s in zep_results["node_summaries"][:10])) + context_parts.append("### Related nodes retrieved from Zep\n" + "\n".join(f"- {s}" for s in zep_results["node_summaries"][:10])) return "\n\n".join(context_parts) def _is_individual_entity(self, entity_type: str) -> bool: - """判断是否是个人类型实体""" + """Determine whether the entity is an individual-type entity.""" return entity_type.lower() in self.INDIVIDUAL_ENTITY_TYPES def _is_group_entity(self, entity_type: str) -> bool: - """判断是否是群体/机构类型实体""" + """Determine whether the entity is a group/institutional-type entity.""" return entity_type.lower() in self.GROUP_ENTITY_TYPES def _generate_profile_with_llm( @@ -502,11 +502,11 @@ class OasisProfileGenerator: context: str ) -> Dict[str, Any]: """ - 使用LLM生成非常详细的人设 + Use LLM to generate a very detailed persona. - 根据实体类型区分: - - 个人实体:生成具体的人物设定 - - 群体/机构实体:生成代表性账号设定 + Differs by entity type: + - Individual entity: produce a concrete character profile. + - Group/institutional entity: produce a representative account profile. """ is_individual = self._is_individual_entity(entity_type) @@ -520,7 +520,7 @@ class OasisProfileGenerator: entity_name, entity_type, entity_summary, entity_attributes, context ) - # 尝试多次生成,直到成功或达到最大重试次数 + # Try multiple times until success or max retries reached max_attempts = 3 last_error = None @@ -533,34 +533,34 @@ class OasisProfileGenerator: {"role": "user", "content": prompt} ], response_format={"type": "json_object"}, - temperature=0.7 - (attempt * 0.1) # 每次重试降低温度 - # 不设置max_tokens,让LLM自由发挥 + temperature=0.7 - (attempt * 0.1) # Lower temperature on each retry + # Do not set max_tokens; let the LLM decide. ) content = response.choices[0].message.content - # 检查是否被截断(finish_reason不是'stop') + # Check whether the output was truncated (finish_reason is not 'stop') finish_reason = response.choices[0].finish_reason if finish_reason == 'length': - logger.warning(f"LLM输出被截断 (attempt {attempt+1}), 尝试修复...") + logger.warning(f"LLM output was truncated (attempt {attempt+1}), attempting to repair...") content = self._fix_truncated_json(content) - # 尝试解析JSON + # Try to parse JSON try: result = json.loads(content) - # 验证必需字段 + # Validate required fields if "bio" not in result or not result["bio"]: result["bio"] = entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}" if "persona" not in result or not result["persona"]: - result["persona"] = entity_summary or f"{entity_name}是一个{entity_type}。" + result["persona"] = entity_summary or f"{entity_name} is a {entity_type}." return result except json.JSONDecodeError as je: - logger.warning(f"JSON解析失败 (attempt {attempt+1}): {str(je)[:80]}") + logger.warning(f"JSON parse failed (attempt {attempt+1}): {str(je)[:80]}") - # 尝试修复JSON + # Try to repair the JSON result = self._try_fix_json(content, entity_name, entity_type, entity_summary) if result.get("_fixed"): del result["_fixed"] @@ -569,75 +569,75 @@ class OasisProfileGenerator: last_error = je except Exception as e: - logger.warning(f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}") + logger.warning(f"LLM call failed (attempt {attempt+1}): {str(e)[:80]}") last_error = e import time - time.sleep(1 * (attempt + 1)) # 指数退避 + time.sleep(1 * (attempt + 1)) # Exponential backoff - logger.warning(f"LLM生成人设失败({max_attempts}次尝试): {last_error}, 使用规则生成") + logger.warning(f"LLM persona generation failed (after {max_attempts} attempts): {last_error}, falling back to rule-based") return self._generate_profile_rule_based( entity_name, entity_type, entity_summary, entity_attributes ) def _fix_truncated_json(self, content: str) -> str: - """修复被截断的JSON(输出被max_tokens限制截断)""" + """Repair truncated JSON (output cut off by max_tokens limit).""" import re - # 如果JSON被截断,尝试闭合它 + # If the JSON was truncated, try to close it content = content.strip() - # 计算未闭合的括号 + # Count unclosed brackets open_braces = content.count('{') - content.count('}') open_brackets = content.count('[') - content.count(']') - # 检查是否有未闭合的字符串 - # 简单检查:如果最后一个引号后没有逗号或闭合括号,可能是字符串被截断 + # Check whether a string is unclosed + # Simple check: if the last character is not a quote, comma, or closing bracket, the string is likely truncated if content and content[-1] not in '",}]': - # 尝试闭合字符串 + # Try to close the string content += '"' - # 闭合括号 + # Close brackets content += ']' * open_brackets content += '}' * open_braces return content def _try_fix_json(self, content: str, entity_name: str, entity_type: str, entity_summary: str = "") -> Dict[str, Any]: - """尝试修复损坏的JSON""" + """Try to repair broken JSON.""" import re - # 1. 首先尝试修复被截断的情况 + # 1. First try to handle the truncated case content = self._fix_truncated_json(content) - # 2. 尝试提取JSON部分 + # 2. Try to extract the JSON portion json_match = re.search(r'\{[\s\S]*\}', content) if json_match: json_str = json_match.group() - # 3. 处理字符串中的换行符问题 - # 找到所有字符串值并替换其中的换行符 + # 3. Handle embedded newlines in strings + # Find all string values and replace embedded newlines def fix_string_newlines(match): s = match.group(0) - # 替换字符串内的实际换行符为空格 + # Replace actual newlines inside strings with spaces s = s.replace('\n', ' ').replace('\r', ' ') - # 替换多余空格 + # Collapse extra whitespace s = re.sub(r'\s+', ' ', s) return s - # 匹配JSON字符串值 + # Match JSON string values json_str = re.sub(r'"[^"\\]*(?:\\.[^"\\]*)*"', fix_string_newlines, json_str) - # 4. 尝试解析 + # 4. Try to parse try: result = json.loads(json_str) result["_fixed"] = True return result except json.JSONDecodeError as e: - # 5. 如果还是失败,尝试更激进的修复 + # 5. If it still fails, try more aggressive repair try: - # 移除所有控制字符 + # Remove all control characters json_str = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', json_str) - # 替换所有连续空白 + # Collapse consecutive whitespace json_str = re.sub(r'\s+', ' ', json_str) result = json.loads(json_str) result["_fixed"] = True @@ -645,32 +645,32 @@ class OasisProfileGenerator: except: pass - # 6. 尝试从内容中提取部分信息 + # 6. Try to extract partial information from the content bio_match = re.search(r'"bio"\s*:\s*"([^"]*)"', content) - persona_match = re.search(r'"persona"\s*:\s*"([^"]*)', content) # 可能被截断 + persona_match = re.search(r'"persona"\s*:\s*"([^"]*)', content) # possibly truncated bio = bio_match.group(1) if bio_match else (entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}") - persona = persona_match.group(1) if persona_match else (entity_summary or f"{entity_name}是一个{entity_type}。") + persona = persona_match.group(1) if persona_match else (entity_summary or f"{entity_name} is a {entity_type}.") - # 如果提取到了有意义的内容,标记为已修复 + # If we extracted any meaningful content, mark as repaired if bio_match or persona_match: - logger.info(f"从损坏的JSON中提取了部分信息") + logger.info(f"Extracted partial information from broken JSON") return { "bio": bio, "persona": persona, "_fixed": True } - # 7. 完全失败,返回基础结构 - logger.warning(f"JSON修复失败,返回基础结构") + # 7. Total failure: return a basic structure + logger.warning(f"JSON repair failed, returning basic structure") return { "bio": entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}", - "persona": entity_summary or f"{entity_name}是一个{entity_type}。" + "persona": entity_summary or f"{entity_name} is a {entity_type}." } def _get_system_prompt(self, is_individual: bool) -> str: - """获取系统提示词""" - base_prompt = "你是社交媒体用户画像生成专家。生成详细、真实的人设用于舆论模拟,最大程度还原已有现实情况。必须返回有效的JSON格式,所有字符串值不能包含未转义的换行符。" + """Get the system prompt.""" + base_prompt = "You are an expert at generating social media user personas. Generate detailed, realistic personas for public opinion simulation, faithfully reflecting any existing real-world context. You must return a valid JSON format, and all string values must not contain unescaped newlines." return f"{base_prompt}\n\n{get_language_instruction()}" def _build_individual_persona_prompt( @@ -681,45 +681,45 @@ class OasisProfileGenerator: entity_attributes: Dict[str, Any], context: str ) -> str: - """构建个人实体的详细人设提示词""" + """Build the detailed persona prompt for an individual entity.""" - attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "无" - context_str = context[:3000] if context else "无额外上下文" + attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "None" + context_str = context[:3000] if context else "No additional context" - return f"""为实体生成详细的社交媒体用户人设,最大程度还原已有现实情况。 + return f"""Generate a detailed social media user persona for the entity, faithfully reflecting any existing real-world context. -实体名称: {entity_name} -实体类型: {entity_type} -实体摘要: {entity_summary} -实体属性: {attrs_str} +Entity Name: {entity_name} +Entity Type: {entity_type} +Entity Summary: {entity_summary} +Entity Attributes: {attrs_str} -上下文信息: +Context Information: {context_str} -请生成JSON,包含以下字段: +Please generate JSON containing the following fields: -1. bio: 社交媒体简介,200字 -2. persona: 详细人设描述(2000字的纯文本),需包含: - - 基本信息(年龄、职业、教育背景、所在地) - - 人物背景(重要经历、与事件的关联、社会关系) - - 性格特征(MBTI类型、核心性格、情绪表达方式) - - 社交媒体行为(发帖频率、内容偏好、互动风格、语言特点) - - 立场观点(对话题的态度、可能被激怒/感动的内容) - - 独特特征(口头禅、特殊经历、个人爱好) - - 个人记忆(人设的重要部分,要介绍这个个体与事件的关联,以及这个个体在事件中的已有动作与反应) -3. age: 年龄数字(必须是整数) -4. gender: 性别,必须是英文: "male" 或 "female" -5. mbti: MBTI类型(如INTJ、ENFP等) -6. country: 国家(使用中文,如"中国") -7. profession: 职业 -8. interested_topics: 感兴趣话题数组 +1. bio: Social media bio, 200 words +2. persona: Detailed persona description (2000 words of plain text), must include: + - Basic information (age, profession, education background, location) + - Background (important experiences, connection to events, social relationships) + - Personality traits (MBTI type, core personality, emotional expression style) + - Social media behavior (posting frequency, content preferences, interaction style, language characteristics) + - Stance and views (attitude toward topics, content that may anger/move them) + - Unique features (catchphrases, special experiences, personal hobbies) + - Personal memory (an important part of the persona; describe the individual's connection to the event and any prior actions and reactions in the event) +3. age: Age number (must be an integer) +4. gender: Gender, must be in English: "male" or "female" +5. mbti: MBTI type (e.g., INTJ, ENFP, etc.) +6. country: Country (use the local-language country name, e.g., "China") +7. profession: Profession +8. interested_topics: Array of topics of interest -重要: -- 所有字段值必须是字符串或数字,不要使用换行符 -- persona必须是一段连贯的文字描述 -- {get_language_instruction()} (gender字段必须用英文male/female) -- 内容要与实体信息保持一致 -- age必须是有效的整数,gender必须是"male"或"female" +Important: +- All field values must be strings or numbers; do not use newline characters +- persona must be a coherent paragraph of text +- {get_language_instruction()} (the gender field must use English male/female) +- Content must be consistent with entity information +- age must be a valid integer, gender must be "male" or "female" """ def _build_group_persona_prompt( @@ -730,45 +730,45 @@ class OasisProfileGenerator: entity_attributes: Dict[str, Any], context: str ) -> str: - """构建群体/机构实体的详细人设提示词""" + """Build the detailed persona prompt for a group/institutional entity.""" - attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "无" - context_str = context[:3000] if context else "无额外上下文" + attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "None" + context_str = context[:3000] if context else "No additional context" - return f"""为机构/群体实体生成详细的社交媒体账号设定,最大程度还原已有现实情况。 + return f"""Generate a detailed social media account profile for the institution/group entity, faithfully reflecting any existing real-world context. -实体名称: {entity_name} -实体类型: {entity_type} -实体摘要: {entity_summary} -实体属性: {attrs_str} +Entity Name: {entity_name} +Entity Type: {entity_type} +Entity Summary: {entity_summary} +Entity Attributes: {attrs_str} -上下文信息: +Context Information: {context_str} -请生成JSON,包含以下字段: +Please generate JSON containing the following fields: -1. bio: 官方账号简介,200字,专业得体 -2. persona: 详细账号设定描述(2000字的纯文本),需包含: - - 机构基本信息(正式名称、机构性质、成立背景、主要职能) - - 账号定位(账号类型、目标受众、核心功能) - - 发言风格(语言特点、常用表达、禁忌话题) - - 发布内容特点(内容类型、发布频率、活跃时间段) - - 立场态度(对核心话题的官方立场、面对争议的处理方式) - - 特殊说明(代表的群体画像、运营习惯) - - 机构记忆(机构人设的重要部分,要介绍这个机构与事件的关联,以及这个机构在事件中的已有动作与反应) -3. age: 固定填30(机构账号的虚拟年龄) -4. gender: 固定填"other"(机构账号使用other表示非个人) -5. mbti: MBTI类型,用于描述账号风格,如ISTJ代表严谨保守 -6. country: 国家(使用中文,如"中国") -7. profession: 机构职能描述 -8. interested_topics: 关注领域数组 +1. bio: Official account bio, 200 words, professional and appropriate +2. persona: Detailed account profile description (2000 words of plain text), must include: + - Institution basic information (official name, institution nature, founding background, main functions) + - Account positioning (account type, target audience, core functions) + - Speaking style (language characteristics, common expressions, taboo topics) + - Content publishing characteristics (content type, publishing frequency, active time periods) + - Stance and attitude (official stance on core topics, approach to handling controversy) + - Special notes (representative group profile, operating habits) + - Institutional memory (an important part of the institutional persona; describe the institution's connection to the event and any prior actions and reactions in the event) +3. age: Always 30 (virtual age of the institutional account) +4. gender: Always "other" (institutional accounts use "other" to indicate non-individual) +5. mbti: MBTI type, used to describe the account style, e.g., ISTJ for rigorous and conservative +6. country: Country (use the local-language country name, e.g., "China") +7. profession: Description of institutional function +8. interested_topics: Array of focus areas -重要: -- 所有字段值必须是字符串或数字,不允许null值 -- persona必须是一段连贯的文字描述,不要使用换行符 -- {get_language_instruction()} (gender字段必须用英文"other") -- age必须是整数30,gender必须是字符串"other" -- 机构账号发言要符合其身份定位""" +Important: +- All field values must be strings or numbers; null values are not allowed +- persona must be a coherent paragraph of text; do not use newline characters +- {get_language_instruction()} (the gender field must use English "other") +- age must be the integer 30, gender must be the string "other" +- Institutional account statements must be consistent with its identity positioning""" def _generate_profile_rule_based( self, @@ -777,9 +777,9 @@ class OasisProfileGenerator: entity_summary: str, entity_attributes: Dict[str, Any] ) -> Dict[str, Any]: - """使用规则生成基础人设""" + """Generate a basic persona using rules.""" - # 根据实体类型生成不同的人设 + # Produce different personas depending on the entity type entity_type_lower = entity_type.lower() if entity_type_lower in ["student", "alumni"]: @@ -810,10 +810,10 @@ class OasisProfileGenerator: return { "bio": f"Official account for {entity_name}. News and updates.", "persona": f"{entity_name} is a media entity that reports news and facilitates public discourse. The account shares timely updates and engages with the audience on current events.", - "age": 30, # 机构虚拟年龄 - "gender": "other", # 机构使用other - "mbti": "ISTJ", # 机构风格:严谨保守 - "country": "中国", + "age": 30, # Virtual age for institutional accounts + "gender": "other", # Institutions use 'other' + "mbti": "ISTJ", # Institutional style: rigorous and conservative + "country": "China", "profession": "Media", "interested_topics": ["General News", "Current Events", "Public Affairs"], } @@ -822,16 +822,16 @@ class OasisProfileGenerator: return { "bio": f"Official account of {entity_name}.", "persona": f"{entity_name} is an institutional entity that communicates official positions, announcements, and engages with stakeholders on relevant matters.", - "age": 30, # 机构虚拟年龄 - "gender": "other", # 机构使用other - "mbti": "ISTJ", # 机构风格:严谨保守 - "country": "中国", + "age": 30, # Virtual age for institutional accounts + "gender": "other", # Institutions use 'other' + "mbti": "ISTJ", # Institutional style: rigorous and conservative + "country": "China", "profession": entity_type, "interested_topics": ["Public Policy", "Community", "Official Announcements"], } else: - # 默认人设 + # Default persona return { "bio": entity_summary[:150] if entity_summary else f"{entity_type}: {entity_name}", "persona": entity_summary or f"{entity_name} is a {entity_type.lower()} participating in social discussions.", @@ -844,7 +844,7 @@ class OasisProfileGenerator: } def set_graph_id(self, graph_id: str): - """设置图谱ID用于Zep检索""" + """Set the graph ID used for Zep retrieval.""" self.graph_id = graph_id def generate_profiles_from_entities( @@ -858,52 +858,53 @@ class OasisProfileGenerator: output_platform: str = "reddit" ) -> List[OasisAgentProfile]: """ - 批量从实体生成Agent Profile(支持并行生成) + Batch-generate Agent Profiles from entities (supports parallel generation). Args: - entities: 实体列表 - use_llm: 是否使用LLM生成详细人设 - progress_callback: 进度回调函数 (current, total, message) - graph_id: 图谱ID,用于Zep检索获取更丰富上下文 - parallel_count: 并行生成数量,默认5 - realtime_output_path: 实时写入的文件路径(如果提供,每生成一个就写入一次) - output_platform: 输出平台格式 ("reddit" 或 "twitter") + entities: List of entities. + use_llm: Whether to use LLM to generate detailed personas. + progress_callback: Progress callback function (current, total, message). + graph_id: Graph ID, used by Zep retrieval to fetch richer context. + parallel_count: Number of parallel generations (default 5). + realtime_output_path: Real-time write file path. If provided, every newly generated + profile is written immediately. + output_platform: Output platform format ("reddit" or "twitter"). Returns: - Agent Profile列表 + List of Agent Profiles """ import concurrent.futures from threading import Lock - # 设置graph_id用于Zep检索 + # Set graph_id for Zep retrieval if graph_id: self.graph_id = graph_id total = len(entities) - profiles = [None] * total # 预分配列表保持顺序 - completed_count = [0] # 使用列表以便在闭包中修改 + profiles = [None] * total # pre-allocate list to preserve order + completed_count = [0] # use a list so the closure can mutate it lock = Lock() - # 实时写入文件的辅助函数 + # Helper: write generated profiles to disk in real time def save_profiles_realtime(): - """实时保存已生成的 profiles 到文件""" + """Save the profiles generated so far to disk in real time.""" if not realtime_output_path: return with lock: - # 过滤出已生成的 profiles + # Filter out profiles that are already generated existing_profiles = [p for p in profiles if p is not None] if not existing_profiles: return try: if output_platform == "reddit": - # Reddit JSON 格式 + # Reddit JSON format profiles_data = [p.to_reddit_format() for p in existing_profiles] with open(realtime_output_path, 'w', encoding='utf-8') as f: json.dump(profiles_data, f, ensure_ascii=False, indent=2) else: - # Twitter CSV 格式 + # Twitter CSV format import csv profiles_data = [p.to_twitter_format() for p in existing_profiles] if profiles_data: @@ -913,13 +914,13 @@ class OasisProfileGenerator: writer.writeheader() writer.writerows(profiles_data) except Exception as e: - logger.warning(f"实时保存 profiles 失败: {e}") + logger.warning(f"Real-time save of profiles failed: {e}") # Capture locale before spawning thread pool workers current_locale = get_locale() def generate_single_profile(idx: int, entity: EntityNode) -> tuple: - """生成单个profile的工作函数""" + """Worker function that generates a single profile.""" set_locale(current_locale) entity_type = entity.get_entity_type() or "Entity" @@ -930,14 +931,14 @@ class OasisProfileGenerator: use_llm=use_llm ) - # 实时输出生成的人设到控制台和日志 + # Stream the generated persona to console and log in real time self._print_generated_profile(entity.name, entity_type, profile) return idx, profile, None except Exception as e: - logger.error(f"生成实体 {entity.name} 的人设失败: {str(e)}") - # 创建一个基础profile + logger.error(f"Failed to generate persona for entity {entity.name}: {str(e)}") + # Create a basic profile as a fallback fallback_profile = OasisAgentProfile( user_id=idx, user_name=self._generate_username(entity.name), @@ -949,20 +950,20 @@ class OasisProfileGenerator: ) return idx, fallback_profile, str(e) - logger.info(f"开始并行生成 {total} 个Agent人设(并行数: {parallel_count})...") + logger.info(f"Starting parallel generation of {total} Agent personas (parallel count: {parallel_count})...") print(f"\n{'='*60}") - print(f"开始生成Agent人设 - 共 {total} 个实体,并行数: {parallel_count}") + print(f"Starting Agent persona generation - {total} entities total, parallel count: {parallel_count}") print(f"{'='*60}\n") - # 使用线程池并行执行 + # Run with a thread pool with concurrent.futures.ThreadPoolExecutor(max_workers=parallel_count) as executor: - # 提交所有任务 + # Submit all tasks future_to_entity = { executor.submit(generate_single_profile, idx, entity): (idx, entity) for idx, entity in enumerate(entities) } - # 收集结果 + # Collect results for future in concurrent.futures.as_completed(future_to_entity): idx, entity = future_to_entity[future] entity_type = entity.get_entity_type() or "Entity" @@ -975,23 +976,23 @@ class OasisProfileGenerator: completed_count[0] += 1 current = completed_count[0] - # 实时写入文件 + # Write to file in real time save_profiles_realtime() if progress_callback: progress_callback( current, total, - f"已完成 {current}/{total}: {entity.name}({entity_type})" + f"Done {current}/{total}: {entity.name} ({entity_type})" ) if error: - logger.warning(f"[{current}/{total}] {entity.name} 使用备用人设: {error}") + logger.warning(f"[{current}/{total}] {entity.name} used fallback persona: {error}") else: - logger.info(f"[{current}/{total}] 成功生成人设: {entity.name} ({entity_type})") + logger.info(f"[{current}/{total}] Successfully generated persona: {entity.name} ({entity_type})") except Exception as e: - logger.error(f"处理实体 {entity.name} 时发生异常: {str(e)}") + logger.error(f"Exception while processing entity {entity.name}: {str(e)}") with lock: completed_count[0] += 1 profiles[idx] = OasisAgentProfile( @@ -1003,44 +1004,44 @@ class OasisProfileGenerator: source_entity_uuid=entity.uuid, source_entity_type=entity_type, ) - # 实时写入文件(即使是备用人设) + # Write to file in real time (even for the fallback persona) save_profiles_realtime() print(f"\n{'='*60}") - print(f"人设生成完成!共生成 {len([p for p in profiles if p])} 个Agent") + print(f"Persona generation complete! Generated {len([p for p in profiles if p])} Agents in total") print(f"{'='*60}\n") return profiles def _print_generated_profile(self, entity_name: str, entity_type: str, profile: OasisAgentProfile): - """实时输出生成的人设到控制台(完整内容,不截断)""" + """Stream the generated persona to the console in real time (full content, no truncation).""" separator = "-" * 70 - # 构建完整输出内容(不截断) - topics_str = ', '.join(profile.interested_topics) if profile.interested_topics else '无' + # Build full output content (no truncation) + topics_str = ', '.join(profile.interested_topics) if profile.interested_topics else 'None' output_lines = [ f"\n{separator}", t('progress.profileGenerated', name=entity_name, type=entity_type), f"{separator}", - f"用户名: {profile.user_name}", + f"Username: {profile.user_name}", f"", - f"【简介】", + f"[Bio]", f"{profile.bio}", f"", - f"【详细人设】", + f"[Detailed Persona]", f"{profile.persona}", f"", - f"【基本属性】", - f"年龄: {profile.age} | 性别: {profile.gender} | MBTI: {profile.mbti}", - f"职业: {profile.profession} | 国家: {profile.country}", - f"兴趣话题: {topics_str}", + f"[Basic Attributes]", + f"Age: {profile.age} | Gender: {profile.gender} | MBTI: {profile.mbti}", + f"Profession: {profile.profession} | Country: {profile.country}", + f"Interested topics: {topics_str}", separator ] output = "\n".join(output_lines) - # 只输出到控制台(避免重复,logger不再输出完整内容) + # Only output to console (avoid duplication; the logger no longer prints the full content) print(output) def save_profiles( @@ -1050,16 +1051,16 @@ class OasisProfileGenerator: platform: str = "reddit" ): """ - 保存Profile到文件(根据平台选择正确格式) + Save profiles to a file (uses the correct format for the platform). - OASIS平台格式要求: - - Twitter: CSV格式 - - Reddit: JSON格式 + OASIS platform format requirements: + - Twitter: CSV format + - Reddit: JSON format Args: - profiles: Profile列表 - file_path: 文件路径 - platform: 平台类型 ("reddit" 或 "twitter") + profiles: List of profiles. + file_path: File path. + platform: Platform type ("reddit" or "twitter"). """ if platform == "twitter": self._save_twitter_csv(profiles, file_path) @@ -1068,117 +1069,118 @@ class OasisProfileGenerator: def _save_twitter_csv(self, profiles: List[OasisAgentProfile], file_path: str): """ - 保存Twitter Profile为CSV格式(符合OASIS官方要求) + Save Twitter profiles as CSV (per OASIS official requirements). - OASIS Twitter要求的CSV字段: - - user_id: 用户ID(根据CSV顺序从0开始) - - name: 用户真实姓名 - - username: 系统中的用户名 - - user_char: 详细人设描述(注入到LLM系统提示中,指导Agent行为) - - description: 简短的公开简介(显示在用户资料页面) + CSV fields required by OASIS Twitter: + - user_id: User ID (sequential from 0 based on CSV order). + - name: User's real name. + - username: Username in the system. + - user_char: Detailed persona description (injected into the LLM system prompt to + guide the agent's behaviour). + - description: Short public bio (shown on the user profile page). - user_char vs description 区别: - - user_char: 内部使用,LLM系统提示,决定Agent如何思考和行动 - - description: 外部显示,其他用户可见的简介 + user_char vs description: + - user_char: Internal — used in the LLM system prompt, governs how the agent thinks + and acts. + - description: External — the public bio visible to other users. """ import csv - # 确保文件扩展名是.csv + # Ensure the file extension is .csv if not file_path.endswith('.csv'): file_path = file_path.replace('.json', '.csv') with open(file_path, 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f) - # 写入OASIS要求的表头 + # Write the OASIS-required header headers = ['user_id', 'name', 'username', 'user_char', 'description'] writer.writerow(headers) - # 写入数据行 + # Write data rows for idx, profile in enumerate(profiles): - # user_char: 完整人设(bio + persona),用于LLM系统提示 + # user_char: full persona (bio + persona), used in the LLM system prompt user_char = profile.bio if profile.persona and profile.persona != profile.bio: user_char = f"{profile.bio} {profile.persona}" - # 处理换行符(CSV中用空格替代) + # Process newlines (replace with spaces in CSV) user_char = user_char.replace('\n', ' ').replace('\r', ' ') - # description: 简短简介,用于外部显示 + # description: short bio for external display description = profile.bio.replace('\n', ' ').replace('\r', ' ') row = [ - idx, # user_id: 从0开始的顺序ID - profile.name, # name: 真实姓名 - profile.user_name, # username: 用户名 - user_char, # user_char: 完整人设(内部LLM使用) - description # description: 简短简介(外部显示) + idx, # user_id: sequential ID starting from 0 + profile.name, # name: real name + profile.user_name, # username: username + user_char, # user_char: full persona (internal LLM use) + description # description: short bio (external display) ] writer.writerow(row) - logger.info(f"已保存 {len(profiles)} 个Twitter Profile到 {file_path} (OASIS CSV格式)") + logger.info(f"Saved {len(profiles)} Twitter profiles to {file_path} (OASIS CSV format)") def _normalize_gender(self, gender: Optional[str]) -> str: """ - 标准化gender字段为OASIS要求的英文格式 + Normalize the gender field to the English format OASIS requires. - OASIS要求: male, female, other + OASIS requires: male, female, other. """ if not gender: return "other" gender_lower = gender.lower().strip() - # 中文映射 + # Legacy Chinese-value mapping translated to English keys for backward + # compatibility with previously generated data; current generation is English-only. + # Original mapping: "male"→male, "female"→female, "institution"→other, "other"→other. gender_map = { - "男": "male", - "女": "female", - "机构": "other", - "其他": "other", - # 英文已有 "male": "male", "female": "female", "other": "other", + "institution": "other", } return gender_map.get(gender_lower, "other") def _save_reddit_json(self, profiles: List[OasisAgentProfile], file_path: str): """ - 保存Reddit Profile为JSON格式 + Save Reddit profiles as JSON. - 使用与 to_reddit_format() 一致的格式,确保 OASIS 能正确读取。 - 必须包含 user_id 字段,这是 OASIS agent_graph.get_agent() 匹配的关键! + Uses the same format as to_reddit_format() so OASIS can read it correctly. + The user_id field is required — it's the key that OASIS agent_graph.get_agent() + uses to match. - 必需字段: - - user_id: 用户ID(整数,用于匹配 initial_posts 中的 poster_agent_id) - - username: 用户名 - - name: 显示名称 - - bio: 简介 - - persona: 详细人设 - - age: 年龄(整数) - - gender: "male", "female", 或 "other" - - mbti: MBTI类型 - - country: 国家 + Required fields: + - user_id: User ID (integer, used to match poster_agent_id in initial_posts). + - username: Username. + - name: Display name. + - bio: Bio. + - persona: Detailed persona. + - age: Age (integer). + - gender: "male", "female", or "other". + - mbti: MBTI type. + - country: Country. """ data = [] for idx, profile in enumerate(profiles): - # 使用与 to_reddit_format() 一致的格式 + # Use the same format as to_reddit_format() item = { - "user_id": profile.user_id if profile.user_id is not None else idx, # 关键:必须包含 user_id + "user_id": profile.user_id if profile.user_id is not None else idx, # Key: must include user_id "username": profile.user_name, "name": profile.name, "bio": profile.bio[:150] if profile.bio else f"{profile.name}", "persona": profile.persona or f"{profile.name} is a participant in social discussions.", "karma": profile.karma if profile.karma else 1000, "created_at": profile.created_at, - # OASIS必需字段 - 确保都有默认值 + # OASIS-required fields — ensure each has a default "age": profile.age if profile.age else 30, "gender": self._normalize_gender(profile.gender), "mbti": profile.mbti if profile.mbti else "ISTJ", - "country": profile.country if profile.country else "中国", + "country": profile.country if profile.country else "China", } - # 可选字段 + # Optional fields if profile.profession: item["profession"] = profile.profession if profile.interested_topics: @@ -1189,16 +1191,16 @@ class OasisProfileGenerator: with open(file_path, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) - logger.info(f"已保存 {len(profiles)} 个Reddit Profile到 {file_path} (JSON格式,包含user_id字段)") + logger.info(f"Saved {len(profiles)} Reddit profiles to {file_path} (JSON format, with user_id)") - # 保留旧方法名作为别名,保持向后兼容 + # Keep the old method name as an alias for backward compatibility def save_profiles_to_json( self, profiles: List[OasisAgentProfile], file_path: str, platform: str = "reddit" ): - """[已废弃] 请使用 save_profiles() 方法""" - logger.warning("save_profiles_to_json已废弃,请使用save_profiles方法") + """[Deprecated] Please use the save_profiles() method instead.""" + logger.warning("save_profiles_to_json is deprecated; please use save_profiles()") self.save_profiles(profiles, file_path, platform) diff --git a/backend/app/services/ontology_generator.py b/backend/app/services/ontology_generator.py index aedf403f..f7afca73 100644 --- a/backend/app/services/ontology_generator.py +++ b/backend/app/services/ontology_generator.py @@ -1,6 +1,6 @@ """ -本体生成服务 -接口1:分析文本内容,生成适合社会模拟的实体和关系类型定义 +Ontology generation service +Interface 1: analyze text content to generate entity and relationship type definitions suitable for social simulation """ import json @@ -14,169 +14,169 @@ logger = logging.getLogger(__name__) def _to_pascal_case(name: str) -> str: - """将任意格式的名称转换为 PascalCase(如 'works_for' -> 'WorksFor', 'person' -> 'Person')""" - # 按非字母数字字符分割 + """Convert any-format name to PascalCase (e.g. 'works_for' -> 'WorksFor', 'person' -> 'Person')""" + # Split by non-alphanumeric characters parts = re.split(r'[^a-zA-Z0-9]+', name) - # 再按 camelCase 边界分割(如 'camelCase' -> ['camel', 'Case']) + # Then split by camelCase boundaries (e.g. 'camelCase' -> ['camel', 'Case']) words = [] for part in parts: words.extend(re.sub(r'([a-z])([A-Z])', r'\1_\2', part).split('_')) - # 每个词首字母大写,过滤空串 + # Capitalize each word, filter out empty strings result = ''.join(word.capitalize() for word in words if word) return result if result else 'Unknown' -# 本体生成的系统提示词 -ONTOLOGY_SYSTEM_PROMPT = """你是一个专业的知识图谱本体设计专家。你的任务是分析给定的文本内容和模拟需求,设计适合**社交媒体舆论模拟**的实体类型和关系类型。 +# System prompt for ontology generation +ONTOLOGY_SYSTEM_PROMPT = """You are a professional knowledge-graph ontology design expert. Your task is to analyze the given text content and simulation requirement, design entity types and relationship types suitable for **social media public-opinion simulation**. -**重要:你必须输出有效的JSON格式数据,不要输出任何其他内容。** +**IMPORTANT: You MUST output valid JSON format data, and nothing else.** -## 核心任务背景 +## Core Task Background -我们正在构建一个**社交媒体舆论模拟系统**。在这个系统中: -- 每个实体都是一个可以在社交媒体上发声、互动、传播信息的"账号"或"主体" -- 实体之间会相互影响、转发、评论、回应 -- 我们需要模拟舆论事件中各方的反应和信息传播路径 +We are building a **social media public-opinion simulation system**. In this system: +- Each entity is an "account" or "subject" that can speak out, interact, and spread information on social media +- Entities influence each other, repost, comment, and respond +- We need to simulate the reactions of all parties and the information propagation paths in public-opinion events -因此,**实体必须是现实中真实存在的、可以在社媒上发声和互动的主体**: +Therefore, **Entities must be real-world subjects that actually exist and can speak out and interact on social media**: -**可以是**: -- 具体的个人(公众人物、当事人、意见领袖、专家学者、普通人) -- 公司、企业(包括其官方账号) -- 组织机构(大学、协会、NGO、工会等) -- 政府部门、监管机构 -- 媒体机构(报纸、电视台、自媒体、网站) -- 社交媒体平台本身 -- 特定群体代表(如校友会、粉丝团、维权群体等) +**Can be**: +- Specific individuals (public figures, parties involved, opinion leaders, experts and scholars, ordinary people) +- Companies, enterprises (including their official accounts) +- Organizations (universities, associations, NGOs, unions, etc.) +- Government departments, regulatory agencies +- Media organizations (newspapers, TV stations, self-media, websites) +- Social media platforms themselves +- Representatives of specific groups (e.g., alumni associations, fan groups, rights-advocacy groups, etc.) -**不可以是**: -- 抽象概念(如"舆论"、"情绪"、"趋势") -- 主题/话题(如"学术诚信"、"教育改革") -- 观点/态度(如"支持方"、"反对方") +**Cannot be**: +- Abstract concepts (e.g., "public opinion", "emotion", "trend") +- Themes/topics (e.g., "academic integrity", "education reform") +- Views/attitudes (e.g., "supporters", "opponents") -## 输出格式 +## Output Format -请输出JSON格式,包含以下结构: +Please output JSON format containing the following structure: ```json { "entity_types": [ { - "name": "实体类型名称(英文,PascalCase)", - "description": "简短描述(英文,不超过100字符)", + "name": "Entity type name (English, PascalCase)", + "description": "Short description (English, no more than 100 characters)", "attributes": [ { - "name": "属性名(英文,snake_case)", + "name": "Attribute name (English, snake_case)", "type": "text", - "description": "属性描述" + "description": "Attribute description" } ], - "examples": ["示例实体1", "示例实体2"] + "examples": ["Example entity 1", "Example entity 2"] } ], "edge_types": [ { - "name": "关系类型名称(英文,UPPER_SNAKE_CASE)", - "description": "简短描述(英文,不超过100字符)", + "name": "Relationship type name (English, UPPER_SNAKE_CASE)", + "description": "Short description (English, no more than 100 characters)", "source_targets": [ - {"source": "源实体类型", "target": "目标实体类型"} + {"source": "Source entity type", "target": "Target entity type"} ], "attributes": [] } ], - "analysis_summary": "对文本内容的简要分析说明" + "analysis_summary": "Brief analysis and description of the text content" } ``` -## 设计指南(极其重要!) +## Design Guidelines (EXTREMELY IMPORTANT!) -### 1. 实体类型设计 - 必须严格遵守 +### 1. Entity Type Design - MUST be strictly followed -**数量要求:必须正好10个实体类型** +**Quantity requirement: must be exactly 10 entity types** -**层次结构要求(必须同时包含具体类型和兜底类型)**: +**Hierarchy requirement (must include both specific types and fallback types)**: -你的10个实体类型必须包含以下层次: +Your 10 entity types must include the following levels: -A. **兜底类型(必须包含,放在列表最后2个)**: - - `Person`: 任何自然人个体的兜底类型。当一个人不属于其他更具体的人物类型时,归入此类。 - - `Organization`: 任何组织机构的兜底类型。当一个组织不属于其他更具体的组织类型时,归入此类。 +A. **Fallback types (must be included, placed last 2 in the list)**: + - `Person`: Fallback type for any individual person. When a person does not fit any other more specific person type, they are classified here. + - `Organization`: Fallback type for any organization. When an organization does not fit any other more specific organization type, it is classified here. -B. **具体类型(8个,根据文本内容设计)**: - - 针对文本中出现的主要角色,设计更具体的类型 - - 例如:如果文本涉及学术事件,可以有 `Student`, `Professor`, `University` - - 例如:如果文本涉及商业事件,可以有 `Company`, `CEO`, `Employee` +B. **Specific types (8, designed from the text content)**: + - Design more specific types for the main characters appearing in the text + - For example: if the text involves an academic event, you may have `Student`, `Professor`, `University` + - For example: if the text involves a business event, you may have `Company`, `CEO`, `Employee` -**为什么需要兜底类型**: -- 文本中会出现各种人物,如"中小学教师"、"路人甲"、"某位网友" -- 如果没有专门的类型匹配,他们应该被归入 `Person` -- 同理,小型组织、临时团体等应该归入 `Organization` +**Why fallback types are needed**: +- The text will contain various characters, such as "primary/secondary school teachers", "passerby A", "a certain netizen" +- If no specific type matches them, they should be classified as `Person` +- Similarly, small organizations, temporary groups, etc. should be classified as `Organization` -**具体类型的设计原则**: -- 从文本中识别出高频出现或关键的角色类型 -- 每个具体类型应该有明确的边界,避免重叠 -- description 必须清晰说明这个类型和兜底类型的区别 +**Design principles for specific types**: +- Identify frequently occurring or key character types from the text +- Each specific type should have clear boundaries to avoid overlap +- The description must clearly explain the difference between this type and the fallback types -### 2. 关系类型设计 +### 2. Relationship Type Design -- 数量:6-10个 -- 关系应该反映社媒互动中的真实联系 -- 确保关系的 source_targets 涵盖你定义的实体类型 +- Quantity: 6-10 +- Relationships should reflect real connections in social-media interactions +- Ensure the source_targets of relationships cover the entity types you defined -### 3. 属性设计 +### 3. Attribute Design -- 每个实体类型1-3个关键属性 -- **注意**:属性名不能使用 `name`、`uuid`、`group_id`、`created_at`、`summary`(这些是系统保留字) -- 推荐使用:`full_name`, `title`, `role`, `position`, `location`, `description` 等 +- 1-3 key attributes per entity type +- **Note**: Attribute names must NOT use `name`, `uuid`, `group_id`, `created_at`, `summary` (these are system reserved words) +- Recommended: `full_name`, `title`, `role`, `position`, `location`, `description`, etc. -## 实体类型参考 +## Entity Type Reference -**个人类(具体)**: -- Student: 学生 -- Professor: 教授/学者 -- Journalist: 记者 -- Celebrity: 明星/网红 -- Executive: 高管 -- Official: 政府官员 -- Lawyer: 律师 -- Doctor: 医生 +**Person types (specific)**: +- Student: Student +- Professor: Professor/Scholar +- Journalist: Journalist +- Celebrity: Celebrity/Internet celebrity +- Executive: Executive +- Official: Government official +- Lawyer: Lawyer +- Doctor: Doctor -**个人类(兜底)**: -- Person: 任何自然人(不属于上述具体类型时使用) +**Person types (fallback)**: +- Person: Any individual (used when not fitting the specific types above) -**组织类(具体)**: -- University: 高校 -- Company: 公司企业 -- GovernmentAgency: 政府机构 -- MediaOutlet: 媒体机构 -- Hospital: 医院 -- School: 中小学 -- NGO: 非政府组织 +**Organization types (specific)**: +- University: University/College +- Company: Company/Enterprise +- GovernmentAgency: Government agency +- MediaOutlet: Media organization +- Hospital: Hospital +- School: Primary/Secondary school +- NGO: Non-governmental organization -**组织类(兜底)**: -- Organization: 任何组织机构(不属于上述具体类型时使用) +**Organization types (fallback)**: +- Organization: Any organization (used when not fitting the specific types above) -## 关系类型参考 +## Relationship Type Reference -- WORKS_FOR: 工作于 -- STUDIES_AT: 就读于 -- AFFILIATED_WITH: 隶属于 -- REPRESENTS: 代表 -- REGULATES: 监管 -- REPORTS_ON: 报道 -- COMMENTS_ON: 评论 -- RESPONDS_TO: 回应 -- SUPPORTS: 支持 -- OPPOSES: 反对 -- COLLABORATES_WITH: 合作 -- COMPETES_WITH: 竞争 +- WORKS_FOR: Works for +- STUDIES_AT: Studies at +- AFFILIATED_WITH: Affiliated with +- REPRESENTS: Represents +- REGULATES: Regulates +- REPORTS_ON: Reports on +- COMMENTS_ON: Comments on +- RESPONDS_TO: Responds to +- SUPPORTS: Supports +- OPPOSES: Opposes +- COLLABORATES_WITH: Collaborates with +- COMPETES_WITH: Competes with """ class OntologyGenerator: """ - 本体生成器 - 分析文本内容,生成实体和关系类型定义 + Ontology generator + Analyze text content and generate entity and relationship type definitions """ def __init__(self, llm_client: Optional[LLMClient] = None): @@ -189,17 +189,17 @@ class OntologyGenerator: additional_context: Optional[str] = None ) -> Dict[str, Any]: """ - 生成本体定义 + Generate the ontology definition Args: - document_texts: 文档文本列表 - simulation_requirement: 模拟需求描述 - additional_context: 额外上下文 + document_texts: List of document texts + simulation_requirement: Simulation requirement description + additional_context: Additional context Returns: - 本体定义(entity_types, edge_types等) + Ontology definition (entity_types, edge_types, etc.) """ - # 构建用户消息 + # Build user message user_message = self._build_user_message( document_texts, simulation_requirement, @@ -213,7 +213,7 @@ class OntologyGenerator: {"role": "user", "content": user_message} ] - # 调用LLM + # Call the LLM try: result = self.llm_client.chat_json( messages=messages, @@ -224,7 +224,7 @@ class OntologyGenerator: logger.warning( f"[ontology] first attempt failed (likely M3 JSON truncation): {json_err}; retrying with compact prompt" ) - # 紧凑重试:限制 entity/edge types 数量,砍掉 attributes + # Compact retry: limit the number of entity/edge types, drop attributes compact_doc = "\n".join(document_texts)[:30000] compact_messages = [ { @@ -249,12 +249,12 @@ class OntologyGenerator: max_tokens=8192, ) - # 验证和后处理 + # Validate and post-process result = self._validate_and_process(result) return result - # 传给 LLM 的文本最大长度(5万字) + # Max text length to send to the LLM (50,000 chars) MAX_TEXT_LENGTH_FOR_LLM = 50000 def _build_user_message( @@ -263,50 +263,50 @@ class OntologyGenerator: simulation_requirement: str, additional_context: Optional[str] ) -> str: - """构建用户消息""" + """Build user message""" - # 合并文本 + # Merge text combined_text = "\n\n---\n\n".join(document_texts) original_length = len(combined_text) - # 如果文本超过5万字,截断(仅影响传给LLM的内容,不影响图谱构建) + # If text exceeds 50,000 chars, truncate (only affects what is sent to the LLM, not the graph construction) if len(combined_text) > self.MAX_TEXT_LENGTH_FOR_LLM: combined_text = combined_text[:self.MAX_TEXT_LENGTH_FOR_LLM] - combined_text += f"\n\n...(原文共{original_length}字,已截取前{self.MAX_TEXT_LENGTH_FOR_LLM}字用于本体分析)..." + combined_text += f"\n\n...(original text is {original_length} chars, truncated to the first {self.MAX_TEXT_LENGTH_FOR_LLM} chars for ontology analysis)..." - message = f"""## 模拟需求 + message = f"""## Simulation requirements {simulation_requirement} -## 文档内容 +## Document content {combined_text} """ if additional_context: message += f""" -## 额外说明 +## Additional notes {additional_context} """ message += """ -请根据以上内容,设计适合社会舆论模拟的实体类型和关系类型。 +Please design entity types and edge types suitable for social opinion simulation based on the content above. -**必须遵守的规则**: -1. 必须正好输出10个实体类型 -2. 最后2个必须是兜底类型:Person(个人兜底)和 Organization(组织兜底) -3. 前8个是根据文本内容设计的具体类型 -4. 所有实体类型必须是现实中可以发声的主体,不能是抽象概念 -5. 属性名不能使用 name、uuid、group_id 等保留字,用 full_name、org_name 等替代 +**Rules that must be followed**: +1. Output exactly 10 entity types +2. The last 2 must be fallback types: Person (individual fallback) and Organization (organization fallback) +3. The first 8 should be specific types designed from the text content +4. All entity types must be real-world entities capable of speaking out, not abstract concepts +5. Attribute names must not use reserved words like name, uuid, group_id; use full_name, org_name, etc. instead """ return message def _validate_and_process(self, result: Dict[str, Any]) -> Dict[str, Any]: - """验证和后处理结果""" + """Validate and post-process the result""" - # 确保必要字段存在 + # Ensure required fields exist if "entity_types" not in result: result["entity_types"] = [] if "edge_types" not in result: @@ -314,11 +314,11 @@ class OntologyGenerator: if "analysis_summary" not in result: result["analysis_summary"] = "" - # 验证实体类型 - # 记录原始名称到 PascalCase 的映射,用于后续修正 edge 的 source_targets 引用 + # Validate entity types + # Record the raw-name -> PascalCase mapping, used later to fix edge source_targets references entity_name_map = {} for entity in result["entity_types"]: - # 强制将 entity name 转为 PascalCase(Zep API 要求) + # Force-convert entity name to PascalCase (Zep API requirement) if "name" in entity: original_name = entity["name"] entity["name"] = _to_pascal_case(original_name) @@ -329,19 +329,19 @@ class OntologyGenerator: entity["attributes"] = [] if "examples" not in entity: entity["examples"] = [] - # 确保description不超过100字符 + # Ensure description does not exceed 100 characters if len(entity.get("description", "")) > 100: entity["description"] = entity["description"][:97] + "..." - # 验证关系类型 + # Validate edge types for edge in result["edge_types"]: - # 强制将 edge name 转为 SCREAMING_SNAKE_CASE(Zep API 要求) + # Force-convert edge name to SCREAMING_SNAKE_CASE (Zep API requirement) if "name" in edge: original_name = edge["name"] edge["name"] = original_name.upper() if edge["name"] != original_name: logger.warning(f"Edge type name '{original_name}' auto-converted to '{edge['name']}'") - # 修正 source_targets 中的实体名称引用,与转换后的 PascalCase 保持一致 + # Fix entity name references in source_targets to match the converted PascalCase for st in edge.get("source_targets", []): if st.get("source") in entity_name_map: st["source"] = entity_name_map[st["source"]] @@ -354,11 +354,11 @@ class OntologyGenerator: if len(edge.get("description", "")) > 100: edge["description"] = edge["description"][:97] + "..." - # Zep API 限制:最多 10 个自定义实体类型,最多 10 个自定义边类型 + # Zep API limit: at most 10 custom entity types, at most 10 custom edge types MAX_ENTITY_TYPES = 10 MAX_EDGE_TYPES = 10 - # 去重:按 name 去重,保留首次出现的 + # Deduplicate by name, keep the first occurrence seen_names = set() deduped = [] for entity in result["entity_types"]: @@ -370,7 +370,7 @@ class OntologyGenerator: logger.warning(f"Duplicate entity type '{name}' removed during validation") result["entity_types"] = deduped - # 兜底类型定义 + # Fallback type definitions person_fallback = { "name": "Person", "description": "Any individual person not fitting other specific person types.", @@ -391,12 +391,12 @@ class OntologyGenerator: "examples": ["small business", "community group"] } - # 检查是否已有兜底类型 + # Check whether fallback types already exist entity_names = {e["name"] for e in result["entity_types"]} has_person = "Person" in entity_names has_organization = "Organization" in entity_names - # 需要添加的兜底类型 + # Fallback types that need to be added fallbacks_to_add = [] if not has_person: fallbacks_to_add.append(person_fallback) @@ -407,17 +407,17 @@ class OntologyGenerator: current_count = len(result["entity_types"]) needed_slots = len(fallbacks_to_add) - # 如果添加后会超过 10 个,需要移除一些现有类型 + # If adding would exceed 10, we need to drop some existing types if current_count + needed_slots > MAX_ENTITY_TYPES: - # 计算需要移除多少个 + # Compute how many to remove to_remove = current_count + needed_slots - MAX_ENTITY_TYPES - # 从末尾移除(保留前面更重要的具体类型) + # Remove from the end (keep the more important specific types at the front) result["entity_types"] = result["entity_types"][:-to_remove] - # 添加兜底类型 + # Add fallback types result["entity_types"].extend(fallbacks_to_add) - # 最终确保不超过限制(防御性编程) + # Final guard to ensure the limit is not exceeded (defensive coding) if len(result["entity_types"]) > MAX_ENTITY_TYPES: result["entity_types"] = result["entity_types"][:MAX_ENTITY_TYPES] @@ -428,29 +428,29 @@ class OntologyGenerator: def generate_python_code(self, ontology: Dict[str, Any]) -> str: """ - 将本体定义转换为Python代码(类似ontology.py) + Convert the ontology definition to Python code (similar to ontology.py) Args: - ontology: 本体定义 + ontology: Ontology definition Returns: - Python代码字符串 + Python code string """ code_lines = [ '"""', - '自定义实体类型定义', - '由MiroFish自动生成,用于社会舆论模拟', + 'Custom entity type definitions', + 'Auto-generated by MiroFish, for social opinion simulation', '"""', '', 'from pydantic import Field', 'from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel', '', '', - '# ============== 实体类型定义 ==============', + '# ============== Entity type definitions ==============', '', ] - # 生成实体类型 + # Generate entity types for entity in ontology.get("entity_types", []): name = entity["name"] desc = entity.get("description", f"A {name} entity.") @@ -473,13 +473,13 @@ class OntologyGenerator: code_lines.append('') code_lines.append('') - code_lines.append('# ============== 关系类型定义 ==============') + code_lines.append('# ============== Edge type definitions ==============') code_lines.append('') - # 生成关系类型 + # Generate edge types for edge in ontology.get("edge_types", []): name = edge["name"] - # 转换为PascalCase类名 + # Convert to PascalCase class name class_name = ''.join(word.capitalize() for word in name.split('_')) desc = edge.get("description", f"A {name} relationship.") @@ -501,8 +501,8 @@ class OntologyGenerator: code_lines.append('') code_lines.append('') - # 生成类型字典 - code_lines.append('# ============== 类型配置 ==============') + # Generate type dictionary + code_lines.append('# ============== Type configuration ==============') code_lines.append('') code_lines.append('ENTITY_TYPES = {') for entity in ontology.get("entity_types", []): @@ -518,7 +518,7 @@ class OntologyGenerator: code_lines.append('}') code_lines.append('') - # 生成边的source_targets映射 + # Generate edge source_targets mapping code_lines.append('EDGE_SOURCE_TARGETS = {') for edge in ontology.get("edge_types", []): name = edge["name"] diff --git a/backend/app/services/report_agent.py b/backend/app/services/report_agent.py index cecd70b4..08a314f9 100644 --- a/backend/app/services/report_agent.py +++ b/backend/app/services/report_agent.py @@ -1,12 +1,12 @@ """ -Report Agent服务 -使用LangChain + Zep实现ReACT模式的模拟报告生成 +Report Agent service +Uses LangChain + Zep to implement the ReACT pattern for simulation report generation -功能: -1. 根据模拟需求和Zep图谱信息生成报告 -2. 先规划目录结构,然后分段生成 -3. 每段采用ReACT多轮思考与反思模式 -4. 支持与用户对话,在对话中自主调用检索工具 +Features: +1. Generate reports based on simulation requirements and Zep graph information +2. First plan the table of contents, then generate section by section +3. Each section uses the ReACT multi-round thinking and reflection pattern +4. Support conversations with the user, autonomously calling retrieval tools during the conversation """ import os @@ -35,18 +35,18 @@ logger = get_logger('mirofish.report_agent') class ReportLogger: """ - Report Agent 详细日志记录器 - - 在报告文件夹中生成 agent_log.jsonl 文件,记录每一步详细动作。 - 每行是一个完整的 JSON 对象,包含时间戳、动作类型、详细内容等。 + Detailed logger for Report Agent + + Generates an agent_log.jsonl file in the report folder, recording every detailed action. + Each line is a complete JSON object containing timestamps, action types, detailed content, etc. """ - + def __init__(self, report_id: str): """ - 初始化日志记录器 - + Initialize the logger + Args: - report_id: 报告ID,用于确定日志文件路径 + report_id: Report ID, used to determine the log file path """ self.report_id = report_id self.log_file_path = os.path.join( @@ -56,31 +56,31 @@ class ReportLogger: self._ensure_log_file() def _ensure_log_file(self): - """确保日志文件所在目录存在""" + """Ensure the directory containing the log file exists""" log_dir = os.path.dirname(self.log_file_path) os.makedirs(log_dir, exist_ok=True) - + def _get_elapsed_time(self) -> float: - """获取从开始到现在的耗时(秒)""" + """Get the elapsed time in seconds since the start""" return (datetime.now() - self.start_time).total_seconds() - + def log( - self, - action: str, + self, + action: str, stage: str, details: Dict[str, Any], section_title: str = None, section_index: int = None ): """ - 记录一条日志 - + Record a log entry + Args: - action: 动作类型,如 'start', 'tool_call', 'llm_response', 'section_complete' 等 - stage: 当前阶段,如 'planning', 'generating', 'completed' - details: 详细内容字典,不截断 - section_title: 当前章节标题(可选) - section_index: 当前章节索引(可选) + action: Action type, e.g. 'start', 'tool_call', 'llm_response', 'section_complete', etc. + stage: Current stage, e.g. 'planning', 'generating', 'completed' + details: Detailed content dictionary, not truncated + section_title: Current section title (optional) + section_index: Current section index (optional) """ log_entry = { "timestamp": datetime.now().isoformat(), @@ -93,12 +93,12 @@ class ReportLogger: "details": details } - # 追加写入 JSONL 文件 + # Append to JSONL file with open(self.log_file_path, 'a', encoding='utf-8') as f: f.write(json.dumps(log_entry, ensure_ascii=False) + '\n') - + def log_start(self, simulation_id: str, graph_id: str, simulation_requirement: str): - """记录报告生成开始""" + """Record the start of report generation""" self.log( action="report_start", stage="pending", @@ -109,17 +109,17 @@ class ReportLogger: "message": t('report.taskStarted') } ) - + def log_planning_start(self): - """记录大纲规划开始""" + """Record the start of outline planning""" self.log( action="planning_start", stage="planning", details={"message": t('report.planningStart')} ) - + def log_planning_context(self, context: Dict[str, Any]): - """记录规划时获取的上下文信息""" + """Record the context information obtained during planning""" self.log( action="planning_context", stage="planning", @@ -128,9 +128,9 @@ class ReportLogger: "context": context } ) - + def log_planning_complete(self, outline_dict: Dict[str, Any]): - """记录大纲规划完成""" + """Record the completion of outline planning""" self.log( action="planning_complete", stage="planning", @@ -139,9 +139,9 @@ class ReportLogger: "outline": outline_dict } ) - + def log_section_start(self, section_title: str, section_index: int): - """记录章节生成开始""" + """Record the start of section generation""" self.log( action="section_start", stage="generating", @@ -149,9 +149,9 @@ class ReportLogger: section_index=section_index, details={"message": t('report.sectionStart', title=section_title)} ) - + def log_react_thought(self, section_title: str, section_index: int, iteration: int, thought: str): - """记录 ReACT 思考过程""" + """Record the ReACT thinking process""" self.log( action="react_thought", stage="generating", @@ -163,16 +163,16 @@ class ReportLogger: "message": t('report.reactThought', iteration=iteration) } ) - + def log_tool_call( - self, - section_title: str, + self, + section_title: str, section_index: int, - tool_name: str, + tool_name: str, parameters: Dict[str, Any], iteration: int ): - """记录工具调用""" + """Record a tool call""" self.log( action="tool_call", stage="generating", @@ -185,7 +185,7 @@ class ReportLogger: "message": t('report.toolCall', toolName=tool_name) } ) - + def log_tool_result( self, section_title: str, @@ -194,7 +194,7 @@ class ReportLogger: result: str, iteration: int ): - """记录工具调用结果(完整内容,不截断)""" + """Record the tool call result (full content, not truncated)""" self.log( action="tool_result", stage="generating", @@ -203,12 +203,12 @@ class ReportLogger: details={ "iteration": iteration, "tool_name": tool_name, - "result": result, # 完整结果,不截断 + "result": result, # Full result, not truncated "result_length": len(result), "message": t('report.toolResult', toolName=tool_name) } ) - + def log_llm_response( self, section_title: str, @@ -218,7 +218,7 @@ class ReportLogger: has_tool_calls: bool, has_final_answer: bool ): - """记录 LLM 响应(完整内容,不截断)""" + """Record the LLM response (full content, not truncated)""" self.log( action="llm_response", stage="generating", @@ -226,14 +226,14 @@ class ReportLogger: section_index=section_index, details={ "iteration": iteration, - "response": response, # 完整响应,不截断 + "response": response, # Full response, not truncated "response_length": len(response), "has_tool_calls": has_tool_calls, "has_final_answer": has_final_answer, "message": t('report.llmResponse', hasToolCalls=has_tool_calls, hasFinalAnswer=has_final_answer) } ) - + def log_section_content( self, section_title: str, @@ -241,20 +241,20 @@ class ReportLogger: content: str, tool_calls_count: int ): - """记录章节内容生成完成(仅记录内容,不代表整个章节完成)""" + """Record the completion of section content generation (only records content, not the whole section)""" self.log( action="section_content", stage="generating", section_title=section_title, section_index=section_index, details={ - "content": content, # 完整内容,不截断 + "content": content, # Full content, not truncated "content_length": len(content), "tool_calls_count": tool_calls_count, "message": t('report.sectionContentDone', title=section_title) } ) - + def log_section_full_complete( self, section_title: str, @@ -262,9 +262,10 @@ class ReportLogger: full_content: str ): """ - 记录章节生成完成 + Record the completion of section generation - 前端应监听此日志来判断一个章节是否真正完成,并获取完整内容 + The frontend should listen to this log to determine whether a section is truly complete + and to obtain the full content. """ self.log( action="section_complete", @@ -277,9 +278,9 @@ class ReportLogger: "message": t('report.sectionComplete', title=section_title) } ) - + def log_report_complete(self, total_sections: int, total_time_seconds: float): - """记录报告生成完成""" + """Record the completion of report generation""" self.log( action="report_complete", stage="completed", @@ -289,9 +290,9 @@ class ReportLogger: "message": t('report.reportComplete') } ) - + def log_error(self, error_message: str, stage: str, section_title: str = None): - """记录错误""" + """Record an error""" self.log( action="error", stage=stage, @@ -306,18 +307,18 @@ class ReportLogger: class ReportConsoleLogger: """ - Report Agent 控制台日志记录器 - - 将控制台风格的日志(INFO、WARNING等)写入报告文件夹中的 console_log.txt 文件。 - 这些日志与 agent_log.jsonl 不同,是纯文本格式的控制台输出。 + Console-style logger for Report Agent + + Writes console-style logs (INFO, WARNING, etc.) to the console_log.txt file in the report folder. + These logs are different from agent_log.jsonl and are plain-text console output. """ - + def __init__(self, report_id: str): """ - 初始化控制台日志记录器 - + Initialize the console logger + Args: - report_id: 报告ID,用于确定日志文件路径 + report_id: Report ID, used to determine the log file path """ self.report_id = report_id self.log_file_path = os.path.join( @@ -328,66 +329,66 @@ class ReportConsoleLogger: self._setup_file_handler() def _ensure_log_file(self): - """确保日志文件所在目录存在""" + """Ensure the log file directory exists""" log_dir = os.path.dirname(self.log_file_path) os.makedirs(log_dir, exist_ok=True) def _setup_file_handler(self): - """设置文件处理器,将日志同时写入文件""" + """Set up the file handler to also write logs to a file""" import logging - - # 创建文件处理器 + + # Create the file handler self._file_handler = logging.FileHandler( self.log_file_path, mode='a', encoding='utf-8' ) self._file_handler.setLevel(logging.INFO) - - # 使用与控制台相同的简洁格式 + + # Use the same concise format as the console formatter = logging.Formatter( '[%(asctime)s] %(levelname)s: %(message)s', datefmt='%H:%M:%S' ) self._file_handler.setFormatter(formatter) - - # 添加到 report_agent 相关的 logger + + # Attach to report_agent-related loggers loggers_to_attach = [ 'mirofish.report_agent', 'mirofish.zep_tools', ] - + for logger_name in loggers_to_attach: target_logger = logging.getLogger(logger_name) - # 避免重复添加 + # Avoid adding duplicates if self._file_handler not in target_logger.handlers: target_logger.addHandler(self._file_handler) - + def close(self): - """关闭文件处理器并从 logger 中移除""" + """Close the file handler and remove it from the logger""" import logging - + if self._file_handler: loggers_to_detach = [ 'mirofish.report_agent', 'mirofish.zep_tools', ] - + for logger_name in loggers_to_detach: target_logger = logging.getLogger(logger_name) if self._file_handler in target_logger.handlers: target_logger.removeHandler(self._file_handler) - + self._file_handler.close() self._file_handler = None - + def __del__(self): - """析构时确保关闭文件处理器""" + """Ensure the file handler is closed on destruction""" self.close() class ReportStatus(str, Enum): - """报告状态""" + """Report status""" PENDING = "pending" PLANNING = "planning" GENERATING = "generating" @@ -397,7 +398,7 @@ class ReportStatus(str, Enum): @dataclass class ReportSection: - """报告章节""" + """Report section""" title: str content: str = "" @@ -408,7 +409,7 @@ class ReportSection: } def to_markdown(self, level: int = 2) -> str: - """转换为Markdown格式""" + """Convert to Markdown format""" md = f"{'#' * level} {self.title}\n\n" if self.content: md += f"{self.content}\n\n" @@ -417,20 +418,20 @@ class ReportSection: @dataclass class ReportOutline: - """报告大纲""" + """Report outline""" title: str summary: str sections: List[ReportSection] - + def to_dict(self) -> Dict[str, Any]: return { "title": self.title, "summary": self.summary, "sections": [s.to_dict() for s in self.sections] } - + def to_markdown(self) -> str: - """转换为Markdown格式""" + """Convert to Markdown format""" md = f"# {self.title}\n\n" md += f"> {self.summary}\n\n" for section in self.sections: @@ -440,7 +441,7 @@ class ReportOutline: @dataclass class Report: - """完整报告""" + """Complete report""" report_id: str simulation_id: str graph_id: str @@ -468,421 +469,420 @@ class Report: # ═══════════════════════════════════════════════════════════════ -# Prompt 模板常量 +# Prompt template constants # ═══════════════════════════════════════════════════════════════ -# ── 工具描述 ── +# ── Tool descriptions ── TOOL_DESC_INSIGHT_FORGE = """\ -【深度洞察检索 - 强大的检索工具】 -这是我们强大的检索函数,专为深度分析设计。它会: -1. 自动将你的问题分解为多个子问题 -2. 从多个维度检索模拟图谱中的信息 -3. 整合语义搜索、实体分析、关系链追踪的结果 -4. 返回最全面、最深度的检索内容 +[Deep Insight Retrieval - Powerful Retrieval Tool] +This is our powerful retrieval function, designed for in-depth analysis. It will: +1. Automatically decompose your question into multiple sub-questions +2. Retrieve information from the simulation graph across multiple dimensions +3. Integrate the results of semantic search, entity analysis, and relationship chain tracing +4. Return the most comprehensive and deepest retrieval content -【使用场景】 -- 需要深入分析某个话题 -- 需要了解事件的多个方面 -- 需要获取支撑报告章节的丰富素材 +[Usage Scenarios] +- When you need in-depth analysis of a specific topic +- When you need to understand multiple aspects of an event +- When you need rich material to support a report section -【返回内容】 -- 相关事实原文(可直接引用) -- 核心实体洞察 -- 关系链分析""" +[Returned Content] +- Original text of relevant facts (can be quoted directly) +- Core entity insights +- Relationship chain analysis""" TOOL_DESC_PANORAMA_SEARCH = """\ -【广度搜索 - 获取全貌视图】 -这个工具用于获取模拟结果的完整全貌,特别适合了解事件演变过程。它会: -1. 获取所有相关节点和关系 -2. 区分当前有效的事实和历史/过期的事实 -3. 帮助你了解舆情是如何演变的 +[Broad Search - Get the Big Picture] +This tool is used to obtain the complete picture of simulation results and is especially suited for understanding how an event evolved. It will: +1. Fetch all relevant nodes and relationships +2. Distinguish currently valid facts from historical/expired ones +3. Help you understand how public opinion evolved -【使用场景】 -- 需要了解事件的完整发展脉络 -- 需要对比不同阶段的舆情变化 -- 需要获取全面的实体和关系信息 +[Usage Scenarios] +- When you need the full development arc of an event +- When you need to compare public opinion changes across phases +- When you need comprehensive entity and relationship information -【返回内容】 -- 当前有效事实(模拟最新结果) -- 历史/过期事实(演变记录) -- 所有涉及的实体""" +[Returned Content] +- Currently valid facts (latest simulation results) +- Historical/expired facts (records of evolution) +- All entities involved""" TOOL_DESC_QUICK_SEARCH = """\ -【简单搜索 - 快速检索】 -轻量级的快速检索工具,适合简单、直接的信息查询。 +[Simple Search - Quick Retrieval] +A lightweight, fast retrieval tool suitable for simple, direct information lookups. -【使用场景】 -- 需要快速查找某个具体信息 -- 需要验证某个事实 -- 简单的信息检索 +[Usage Scenarios] +- When you need to quickly find a specific piece of information +- When you need to verify a fact +- Simple information retrieval -【返回内容】 -- 与查询最相关的事实列表""" +[Returned Content] +- List of facts most relevant to the query""" TOOL_DESC_INTERVIEW_AGENTS = """\ -【深度采访 - 真实Agent采访(双平台)】 -调用OASIS模拟环境的采访API,对正在运行的模拟Agent进行真实采访! -这不是LLM模拟,而是调用真实的采访接口获取模拟Agent的原始回答。 -默认在Twitter和Reddit两个平台同时采访,获取更全面的观点。 +[Deep Interview - Real Agent Interviews (Dual Platform)] +Calls the OASIS simulation environment's interview API to conduct real interviews with the running simulation Agents! +This is not LLM simulation — it calls real interview endpoints to get raw answers from the simulation Agents. +By default it interviews on both Twitter and Reddit simultaneously, yielding a more comprehensive set of viewpoints. -功能流程: -1. 自动读取人设文件,了解所有模拟Agent -2. 智能选择与采访主题最相关的Agent(如学生、媒体、官方等) -3. 自动生成采访问题 -4. 调用 /api/simulation/interview/batch 接口在双平台进行真实采访 -5. 整合所有采访结果,提供多视角分析 +Workflow: +1. Automatically read the persona files to learn about all simulation Agents +2. Intelligently select the Agents most relevant to the interview topic (e.g. students, media, officials) +3. Automatically generate interview questions +4. Call the /api/simulation/interview/batch endpoint to perform real interviews on both platforms +5. Integrate all interview results and provide multi-perspective analysis -【使用场景】 -- 需要从不同角色视角了解事件看法(学生怎么看?媒体怎么看?官方怎么说?) -- 需要收集多方意见和立场 -- 需要获取模拟Agent的真实回答(来自OASIS模拟环境) -- 想让报告更生动,包含"采访实录" +[Usage Scenarios] +- When you need to understand different roles' views of the event (How do students see it? How does the media see it? What do officials say?) +- When you need to collect opinions and positions from multiple sides +- When you need real answers from simulation Agents (from the OASIS simulation environment) +- When you want the report to be more vivid, including "interview transcripts" -【返回内容】 -- 被采访Agent的身份信息 -- 各Agent在Twitter和Reddit两个平台的采访回答 -- 关键引言(可直接引用) -- 采访摘要和观点对比 +[Returned Content] +- Identity information of interviewed Agents +- Interview answers from each Agent on both Twitter and Reddit +- Key quotes (can be cited directly) +- Interview summary and comparison of viewpoints -【重要】需要OASIS模拟环境正在运行才能使用此功能!""" +[Important] This feature requires the OASIS simulation environment to be running!""" -# ── 大纲规划 prompt ── +# ── Outline planning prompt ── PLAN_SYSTEM_PROMPT = """\ -你是一个「未来预测报告」的撰写专家,拥有对模拟世界的「上帝视角」——你可以洞察模拟中每一位Agent的行为、言论和互动。 +You are a "Future Prediction Report" writing expert with a "God's-eye view" of the simulation world — you can observe the behavior, speech, and interactions of every Agent in the simulation. -【核心理念】 -我们构建了一个模拟世界,并向其中注入了特定的「模拟需求」作为变量。模拟世界的演化结果,就是对未来可能发生情况的预测。你正在观察的不是"实验数据",而是"未来的预演"。 +[Core Philosophy] +We build a simulation world and inject a specific "simulation requirement" as a variable. The evolution of the simulation world is a prediction of what could happen in the future. What you are observing is not "experimental data", but a "rehearsal of the future". -【你的任务】 -撰写一份「未来预测报告」,回答: -1. 在我们设定的条件下,未来发生了什么? -2. 各类Agent(人群)是如何反应和行动? -3. 这个模拟揭示了哪些值得关注的未来趋势和风险? +[Your Task] +Write a "Future Prediction Report" that answers: +1. Under the conditions we set, what happened in the future? +2. How do various Agents (crowds) react and act? +3. What future trends and risks does this simulation reveal that deserve attention? -【报告定位】 -- ✅ 这是一份基于模拟的未来预测报告,揭示"如果这样,未来会怎样" -- ✅ 聚焦于预测结果:事件走向、群体反应、涌现现象、潜在风险 -- ✅ 模拟世界中的Agent言行就是对未来人群行为的预测 -- ❌ 不是对现实世界现状的分析 -- ❌ 不是泛泛而谈的舆情综述 +[Report Positioning] +- ✅ This is a future prediction report based on a simulation, revealing "if this happens, the future will look like this" +- ✅ Focus on the predicted outcomes: event trajectories, group reactions, emergent phenomena, potential risks +- ✅ Agents' words and actions in the simulation world are predictions of future crowd behavior +- ❌ It is NOT an analysis of the current state of the real world +- ❌ It is NOT a generic overview of public opinion -【章节数量限制】 -- 最少2个章节,最多5个章节 -- 不需要子章节,每个章节直接撰写完整内容 -- 内容要精炼,聚焦于核心预测发现 -- 章节结构由你根据预测结果自主设计 +[Section Count Limit] +- Minimum 2 sections, maximum 5 sections +- No sub-sections needed; each section should be written as complete content +- Keep content focused and refined, focusing on the core prediction findings +- The section structure is up to you, based on the prediction results -请输出JSON格式的报告大纲,格式如下: +Please output the report outline in JSON format as follows: { - "title": "报告标题", - "summary": "报告摘要(一句话概括核心预测发现)", + "title": "Report title", + "summary": "Report summary (one-sentence summary of the core prediction finding)", "sections": [ { - "title": "章节标题", - "description": "章节内容描述" + "title": "Section title", + "description": "Description of the section content" } ] } -注意:sections数组最少2个,最多5个元素!""" +Note: the sections array must contain at least 2 and at most 5 elements!""" PLAN_USER_PROMPT_TEMPLATE = """\ -【预测场景设定】 -我们向模拟世界注入的变量(模拟需求):{simulation_requirement} +[Prediction Scenario Settings] +The variable (simulation requirement) we injected into the simulation world: {simulation_requirement} -【模拟世界规模】 -- 参与模拟的实体数量: {total_nodes} -- 实体间产生的关系数量: {total_edges} -- 实体类型分布: {entity_types} -- 活跃Agent数量: {total_entities} +[Simulation World Scale] +- Number of entities participating in the simulation: {total_nodes} +- Number of relationships generated between entities: {total_edges} +- Distribution of entity types: {entity_types} +- Number of active Agents: {total_entities} -【模拟预测到的部分未来事实样本】 +[Sample of Future Facts Predicted by the Simulation] {related_facts_json} -请以「上帝视角」审视这个未来预演: -1. 在我们设定的条件下,未来呈现出了什么样的状态? -2. 各类人群(Agent)是如何反应和行动的? -3. 这个模拟揭示了哪些值得关注的未来趋势? +Please review this future rehearsal with a "God's-eye view": +1. Under the conditions we set, what state did the future take on? +2. How did various groups of people (Agents) react and act? +3. What future trends does this simulation reveal that deserve attention? -根据预测结果,设计最合适的报告章节结构。 +Based on the prediction results, design the most appropriate report section structure. -【再次提醒】报告章节数量:最少2个,最多5个,内容要精炼聚焦于核心预测发现。""" +[Reminder] Report section count: minimum 2, maximum 5, with content focused and refined on the core prediction findings.""" -# ── 章节生成 prompt ── +# ── Section generation prompt ── SECTION_SYSTEM_PROMPT_TEMPLATE = """\ -你是一个「未来预测报告」的撰写专家,正在撰写报告的一个章节。 +You are a "Future Prediction Report" writing expert, currently writing a section of the report. -报告标题: {report_title} -报告摘要: {report_summary} -预测场景(模拟需求): {simulation_requirement} +Report title: {report_title} +Report summary: {report_summary} +Prediction scenario (simulation requirement): {simulation_requirement} -当前要撰写的章节: {section_title} +The section you are writing right now: {section_title} ═══════════════════════════════════════════════════════════════ -【核心理念】 +[Core Philosophy] ═══════════════════════════════════════════════════════════════ -模拟世界是对未来的预演。我们向模拟世界注入了特定条件(模拟需求), -模拟中Agent的行为和互动,就是对未来人群行为的预测。 +The simulation world is a rehearsal of the future. We inject specific conditions (the simulation requirement) into the simulation world, and the behavior and interactions of Agents in the simulation are predictions of future crowd behavior. -你的任务是: -- 揭示在设定条件下,未来发生了什么 -- 预测各类人群(Agent)是如何反应和行动的 -- 发现值得关注的未来趋势、风险和机会 +Your task is to: +- Reveal what happened in the future under the set conditions +- Predict how various groups of people (Agents) react and act +- Identify future trends, risks, and opportunities that deserve attention -❌ 不要写成对现实世界现状的分析 -✅ 要聚焦于"未来会怎样"——模拟结果就是预测的未来 +❌ Do not write this as an analysis of the current state of the real world +✅ Focus on "what the future will look like" — the simulation results are the predicted future ═══════════════════════════════════════════════════════════════ -【最重要的规则 - 必须遵守】 +[Most Important Rules - Must Be Followed] ═══════════════════════════════════════════════════════════════ -1. 【必须调用工具观察模拟世界】 - - 你正在以「上帝视角」观察未来的预演 - - 所有内容必须来自模拟世界中发生的事件和Agent言行 - - 禁止使用你自己的知识来编写报告内容 - - 每个章节至少调用3次工具(最多5次)来观察模拟的世界,它代表了未来 +1. [You MUST call tools to observe the simulation world] + - You are observing the future rehearsal with a "God's-eye view" + - All content must come from events and Agent speech/actions that occurred in the simulation world + - It is forbidden to use your own knowledge to write the report content + - Each section must call tools at least 3 times (and at most 5 times) to observe the simulation world, which represents the future -2. 【必须引用Agent的原始言行】 - - Agent的发言和行为是对未来人群行为的预测 - - 在报告中使用引用格式展示这些预测,例如: - > "某类人群会表示:原文内容..." - - 这些引用是模拟预测的核心证据 +2. [You MUST quote the Agents' original words and actions] + - Agents' speech and behavior are predictions of future crowd behavior + - Use the quote format in the report to display these predictions, for example: + > "A certain group of people would say: original text..." + - These quotes are the core evidence of the simulation's predictions -3. 【语言一致性 - 引用内容必须翻译为报告语言】 - - 工具返回的内容可能包含与报告语言不同的表述 - - 报告必须全部使用与用户指定语言一致的语言撰写 - - 当你引用工具返回的其他语言内容时,必须将其翻译为报告语言后再写入 - - 翻译时保持原意不变,确保表述自然通顺 - - 这一规则同时适用于正文和引用块(> 格式)中的内容 +3. [Language consistency — quoted content must be translated to the report language] + - Content returned by tools may include expressions in a language different from the report language + - The report must be written entirely in the language specified by the user + - When quoting content returned by tools in other languages, you must translate it into the report language before writing it in + - Preserve the original meaning during translation, and ensure the expression is natural and fluent + - This rule applies to both the body text and the content in quote blocks (> format) -4. 【忠实呈现预测结果】 - - 报告内容必须反映模拟世界中的代表未来的模拟结果 - - 不要添加模拟中不存在的信息 - - 如果某方面信息不足,如实说明 +4. [Faithfully present the prediction results] + - The report content must reflect the simulation results in the simulation world that represent the future + - Do not add information that does not exist in the simulation + - If information is insufficient in some aspect, state this honestly ═══════════════════════════════════════════════════════════════ -【⚠️ 格式规范 - 极其重要!】 +[⚠️ Format Specification - Extremely Important!] ═══════════════════════════════════════════════════════════════ -【一个章节 = 最小内容单位】 -- 每个章节是报告的最小分块单位 -- ❌ 禁止在章节内使用任何 Markdown 标题(#、##、###、#### 等) -- ❌ 禁止在内容开头添加章节主标题 -- ✅ 章节标题由系统自动添加,你只需撰写纯正文内容 -- ✅ 使用**粗体**、段落分隔、引用、列表来组织内容,但不要用标题 +[One section = the smallest content unit] +- Each section is the smallest chunked unit of the report +- ❌ Do NOT use any Markdown headings (#, ##, ###, ####, etc.) inside a section +- ❌ Do NOT add the section's main title at the beginning of the content +- ✅ The section title is added automatically by the system; you only need to write plain body content +- ✅ Use **bold**, paragraph breaks, quotes, and lists to organize content, but do not use headings -【正确示例】 +[Correct Example] ``` -本章节分析了事件的舆论传播态势。通过对模拟数据的深入分析,我们发现... +This section analyzes the spread of public opinion around the event. Through in-depth analysis of the simulation data, we found... -**首发引爆阶段** +**Outbreak Phase** -微博作为舆情的第一现场,承担了信息首发的核心功能: +Weibo served as the on-site first mover for public opinion, playing the core role of breaking the news first: -> "微博贡献了68%的首发声量..." +> "Weibo contributed 68% of the first-wave volume..." -**情绪放大阶段** +**Amplification Phase** -抖音平台进一步放大了事件影响力: +Douyin further amplified the event's impact: -- 视觉冲击力强 -- 情绪共鸣度高 +- Strong visual impact +- High emotional resonance ``` -【错误示例】 +[Incorrect Example] ``` -## 执行摘要 ← 错误!不要添加任何标题 -### 一、首发阶段 ← 错误!不要用###分小节 -#### 1.1 详细分析 ← 错误!不要用####细分 +## Executive Summary ← Wrong! Do not add any headings +### 1. Outbreak Phase ← Wrong! Do not use ### to split subsections +#### 1.1 Detailed Analysis ← Wrong! Do not use #### for further subdivision -本章节分析了... +This section analyzes... ``` ═══════════════════════════════════════════════════════════════ -【可用检索工具】(每章节调用3-5次) +[Available Retrieval Tools] (call 3-5 times per section) ═══════════════════════════════════════════════════════════════ {tools_description} -【工具使用建议 - 请混合使用不同工具,不要只用一种】 -- insight_forge: 深度洞察分析,自动分解问题并多维度检索事实和关系 -- panorama_search: 广角全景搜索,了解事件全貌、时间线和演变过程 -- quick_search: 快速验证某个具体信息点 -- interview_agents: 采访模拟Agent,获取不同角色的第一人称观点和真实反应 +[Tool usage suggestions — please mix different tools, do not use only one] +- insight_forge: in-depth insight analysis that automatically decomposes questions and retrieves facts and relationships across multiple dimensions +- panorama_search: broad panoramic search to understand the full picture, timeline, and evolution of an event +- quick_search: quickly verify a specific information point +- interview_agents: interview simulation Agents to obtain first-person perspectives and real reactions from different roles ═══════════════════════════════════════════════════════════════ -【工作流程】 +[Workflow] ═══════════════════════════════════════════════════════════════ -每次回复你只能做以下两件事之一(不可同时做): +Each turn you may only do one of the following two things (never both): -选项A - 调用工具: -输出你的思考,然后用以下格式调用一个工具: +Option A — Call a tool: +Output your thinking, then call one tool in the following format: -{{"name": "工具名称", "parameters": {{"参数名": "参数值"}}}} +{{"name": "tool name", "parameters": {{"param name": "param value"}}}} -系统会执行工具并把结果返回给你。你不需要也不能自己编写工具返回结果。 +The system will execute the tool and return the result to you. You do not need to and must not write the tool's return result yourself. -选项B - 输出最终内容: -当你已通过工具获取了足够信息,以 "Final Answer:" 开头输出章节内容。 +Option B — Output the final content: +When you have gathered enough information from the tools, output the section content starting with "Final Answer:". -⚠️ 严格禁止: -- 禁止在一次回复中同时包含工具调用和 Final Answer -- 禁止自己编造工具返回结果(Observation),所有工具结果由系统注入 -- 每次回复最多调用一个工具 +⚠️ Strictly forbidden: +- It is forbidden to include both a tool call and Final Answer in the same turn +- It is forbidden to fabricate tool return results (Observation) yourself; all tool results are injected by the system +- A maximum of one tool call per turn ═══════════════════════════════════════════════════════════════ -【章节内容要求】 +[Section Content Requirements] ═══════════════════════════════════════════════════════════════ -1. 内容必须基于工具检索到的模拟数据 -2. 大量引用原文来展示模拟效果 -3. 使用Markdown格式(但禁止使用标题): - - 使用 **粗体文字** 标记重点(代替子标题) - - 使用列表(-或1.2.3.)组织要点 - - 使用空行分隔不同段落 - - ❌ 禁止使用 #、##、###、#### 等任何标题语法 -4. 【引用格式规范 - 必须单独成段】 - 引用必须独立成段,前后各有一个空行,不能混在段落中: +1. Content must be based on the simulation data retrieved by tools +2. Heavily quote original text to demonstrate the simulation's effects +3. Use Markdown format (but do not use headings): + - Use **bold text** to mark emphasis (replacing sub-headings) + - Use lists (- or 1.2.3.) to organize key points + - Use blank lines to separate different paragraphs + - ❌ Do not use any heading syntax such as #, ##, ###, #### +4. [Quote format specification — must stand as its own paragraph] + Quotes must stand alone as a paragraph, with a blank line before and after, and must not be mixed into a paragraph: - ✅ 正确格式: + ✅ Correct format: ``` - 校方的回应被认为缺乏实质内容。 + The school's response was considered to lack substantive content. - > "校方的应对模式在瞬息万变的社交媒体环境中显得僵化和迟缓。" + > "The school's response pattern appears rigid and slow in the rapidly changing social media environment." - 这一评价反映了公众的普遍不满。 + This assessment reflects the public's general dissatisfaction. ``` - ❌ 错误格式: + ❌ Incorrect format: ``` - 校方的回应被认为缺乏实质内容。> "校方的应对模式..." 这一评价反映了... + The school's response was considered to lack substantive content.> "The school's response pattern..." This assessment reflects... ``` -5. 保持与其他章节的逻辑连贯性 -6. 【避免重复】仔细阅读下方已完成的章节内容,不要重复描述相同的信息 -7. 【再次强调】不要添加任何标题!用**粗体**代替小节标题""" +5. Maintain logical consistency with other sections +6. [Avoid repetition] Carefully read the completed section content below and do not repeat the same information +7. [Re-emphasizing] Do not add any headings! Use **bold** in place of sub-headings""" SECTION_USER_PROMPT_TEMPLATE = """\ -已完成的章节内容(请仔细阅读,避免重复): +Completed section content (please read carefully and avoid repetition): {previous_content} ═══════════════════════════════════════════════════════════════ -【当前任务】撰写章节: {section_title} +[Current Task] Write the section: {section_title} ═══════════════════════════════════════════════════════════════ -【重要提醒】 -1. 仔细阅读上方已完成的章节,避免重复相同的内容! -2. 开始前必须先调用工具获取模拟数据 -3. 请混合使用不同工具,不要只用一种 -4. 报告内容必须来自检索结果,不要使用自己的知识 +[Important Reminders] +1. Carefully read the completed sections above and avoid repeating the same content! +2. You must call tools to obtain simulation data before starting +3. Please mix different tools, do not use only one +4. Report content must come from the retrieval results, not from your own knowledge -【⚠️ 格式警告 - 必须遵守】 -- ❌ 不要写任何标题(#、##、###、####都不行) -- ❌ 不要写"{section_title}"作为开头 -- ✅ 章节标题由系统自动添加 -- ✅ 直接写正文,用**粗体**代替小节标题 +[⚠️ Format Warning - Must Be Followed] +- ❌ Do not write any headings (no #, ##, ###, ####) +- ❌ Do not write "{section_title}" as the opening +- ✅ Section titles are added automatically by the system +- ✅ Write the body directly, use **bold** in place of sub-headings -请开始: -1. 首先思考(Thought)这个章节需要什么信息 -2. 然后调用工具(Action)获取模拟数据 -3. 收集足够信息后输出 Final Answer(纯正文,无任何标题)""" +Please start: +1. First think (Thought) about what information this section needs +2. Then call a tool (Action) to obtain simulation data +3. Once enough information is collected, output the Final Answer (plain body text, no headings of any kind)""" -# ── ReACT 循环内消息模板 ── +# ── Message templates inside the ReACT loop ── REACT_OBSERVATION_TEMPLATE = """\ -Observation(检索结果): +Observation (retrieval result): -═══ 工具 {tool_name} 返回 ═══ +═══ Tool {tool_name} returned ═══ {result} ═══════════════════════════════════════════════════════════════ -已调用工具 {tool_calls_count}/{max_tool_calls} 次(已用: {used_tools_str}){unused_hint} -- 如果信息充分:以 "Final Answer:" 开头输出章节内容(必须引用上述原文) -- 如果需要更多信息:调用一个工具继续检索 +Tool has been called {tool_calls_count}/{max_tool_calls} times (used: {used_tools_str}){unused_hint} +- If information is sufficient: start output with "Final Answer:" (must quote the original text above) +- If more information is needed: call one tool to continue retrieval ═══════════════════════════════════════════════════════════════""" REACT_INSUFFICIENT_TOOLS_MSG = ( - "【注意】你只调用了{tool_calls_count}次工具,至少需要{min_tool_calls}次。" - "请再调用工具获取更多模拟数据,然后再输出 Final Answer。{unused_hint}" + "[Notice] You have only called the tool {tool_calls_count} times; at least {min_tool_calls} are required." + " Please call tools to obtain more simulation data before outputting Final Answer. {unused_hint}" ) REACT_INSUFFICIENT_TOOLS_MSG_ALT = ( - "当前只调用了 {tool_calls_count} 次工具,至少需要 {min_tool_calls} 次。" - "请调用工具获取模拟数据。{unused_hint}" + "You have only called tools {tool_calls_count} times; at least {min_tool_calls} are required." + " Please call tools to obtain simulation data. {unused_hint}" ) REACT_TOOL_LIMIT_MSG = ( - "工具调用次数已达上限({tool_calls_count}/{max_tool_calls}),不能再调用工具。" - '请立即基于已获取的信息,以 "Final Answer:" 开头输出章节内容。' + "The tool call limit has been reached ({tool_calls_count}/{max_tool_calls}); no more tool calls are allowed." + ' Please immediately output the section content starting with "Final Answer:" based on the information you have already obtained.' ) -REACT_UNUSED_TOOLS_HINT = "\n💡 你还没有使用过: {unused_list},建议尝试不同工具获取多角度信息" +REACT_UNUSED_TOOLS_HINT = "\n💡 You haven't used yet: {unused_list}; it is recommended to try different tools to get information from multiple angles" -REACT_FORCE_FINAL_MSG = "已达到工具调用限制,请直接输出 Final Answer: 并生成章节内容。" +REACT_FORCE_FINAL_MSG = "The tool call limit has been reached; please directly output Final Answer: and generate the section content." # ── Chat prompt ── CHAT_SYSTEM_PROMPT_TEMPLATE = """\ -你是一个简洁高效的模拟预测助手。 +You are a concise and efficient simulation prediction assistant. -【背景】 -预测条件: {simulation_requirement} +[Background] +Prediction conditions: {simulation_requirement} -【已生成的分析报告】 +[Generated Analysis Report] {report_content} -【规则】 -1. 优先基于上述报告内容回答问题 -2. 直接回答问题,避免冗长的思考论述 -3. 仅在报告内容不足以回答时,才调用工具检索更多数据 -4. 回答要简洁、清晰、有条理 +[Rules] +1. Prioritize answering questions based on the report content above +2. Answer questions directly, avoiding lengthy deliberation +3. Only call tools to retrieve more data when the report content is insufficient +4. Answers should be concise, clear, and well-organized -【可用工具】(仅在需要时使用,最多调用1-2次) +[Available Tools] (use only when needed, call at most 1-2 times) {tools_description} -【工具调用格式】 +[Tool Call Format] -{{"name": "工具名称", "parameters": {{"参数名": "参数值"}}}} +{{"name": "tool name", "parameters": {{"param name": "param value"}}}} -【回答风格】 -- 简洁直接,不要长篇大论 -- 使用 > 格式引用关键内容 -- 优先给出结论,再解释原因""" +[Answer Style] +- Be concise and direct; no long-winded essays +- Use the > format to quote key content +- Give the conclusion first, then explain the reasons""" -CHAT_OBSERVATION_SUFFIX = "\n\n请简洁回答问题。" +CHAT_OBSERVATION_SUFFIX = "\n\nPlease answer the question concisely." # ═══════════════════════════════════════════════════════════════ -# ReportAgent 主类 +# ReportAgent main class # ═══════════════════════════════════════════════════════════════ class ReportAgent: """ - Report Agent - 模拟报告生成Agent + Report Agent - Simulation Report Generation Agent - 采用ReACT(Reasoning + Acting)模式: - 1. 规划阶段:分析模拟需求,规划报告目录结构 - 2. 生成阶段:逐章节生成内容,每章节可多次调用工具获取信息 - 3. 反思阶段:检查内容完整性和准确性 + Adopts the ReACT (Reasoning + Acting) pattern: + 1. Planning phase: analyze simulation requirements and plan the report structure + 2. Generation phase: generate content section by section, with multiple tool calls per section + 3. Reflection phase: check content completeness and accuracy """ - - # 最大工具调用次数(每个章节) + + # Maximum number of tool calls (per section) MAX_TOOL_CALLS_PER_SECTION = 5 - - # 最大反思轮数 + + # Maximum reflection rounds MAX_REFLECTION_ROUNDS = 3 - - # 对话中的最大工具调用次数 + + # Maximum number of tool calls in a conversation MAX_TOOL_CALLS_PER_CHAT = 2 - + def __init__( - self, + self, graph_id: str, simulation_id: str, simulation_requirement: str, @@ -890,14 +890,14 @@ class ReportAgent: zep_tools: Optional[ZepToolsService] = None ): """ - 初始化Report Agent - + Initialize the Report Agent + Args: - graph_id: 图谱ID - simulation_id: 模拟ID - simulation_requirement: 模拟需求描述 - llm_client: LLM客户端(可选) - zep_tools: Zep工具服务(可选) + graph_id: Graph ID + simulation_id: Simulation ID + simulation_requirement: Description of the simulation requirement + llm_client: LLM client (optional) + zep_tools: Zep tools service (optional) """ self.graph_id = graph_id self.simulation_id = simulation_id @@ -906,64 +906,64 @@ class ReportAgent: self.llm = llm_client or LLMClient() self.zep_tools = zep_tools or ZepToolsService() - # 工具定义 + # Tool definitions self.tools = self._define_tools() - - # 日志记录器(在 generate_report 中初始化) + + # Logger (initialized in generate_report) self.report_logger: Optional[ReportLogger] = None - # 控制台日志记录器(在 generate_report 中初始化) + # Console logger (initialized in generate_report) self.console_logger: Optional[ReportConsoleLogger] = None - + logger.info(t('report.agentInitDone', graphId=graph_id, simulationId=simulation_id)) - + def _define_tools(self) -> Dict[str, Dict[str, Any]]: - """定义可用工具""" + """Define the available tools""" return { "insight_forge": { "name": "insight_forge", "description": TOOL_DESC_INSIGHT_FORGE, "parameters": { - "query": "你想深入分析的问题或话题", - "report_context": "当前报告章节的上下文(可选,有助于生成更精准的子问题)" + "query": "The question or topic you want to analyze in depth", + "report_context": "Context of the current report section (optional, helps generate more precise sub-questions)" } }, "panorama_search": { "name": "panorama_search", "description": TOOL_DESC_PANORAMA_SEARCH, "parameters": { - "query": "搜索查询,用于相关性排序", - "include_expired": "是否包含过期/历史内容(默认True)" + "query": "Search query, used for relevance ranking", + "include_expired": "Whether to include expired/historical content (default True)" } }, "quick_search": { "name": "quick_search", "description": TOOL_DESC_QUICK_SEARCH, "parameters": { - "query": "搜索查询字符串", - "limit": "返回结果数量(可选,默认10)" + "query": "Search query string", + "limit": "Number of results to return (optional, default 10)" } }, "interview_agents": { "name": "interview_agents", "description": TOOL_DESC_INTERVIEW_AGENTS, "parameters": { - "interview_topic": "采访主题或需求描述(如:'了解学生对宿舍甲醛事件的看法')", - "max_agents": "最多采访的Agent数量(可选,默认5,最大10)" + "interview_topic": "Interview topic or requirement description (e.g. 'Learn students' views on the dormitory formaldehyde incident')", + "max_agents": "Maximum number of Agents to interview (optional, default 5, max 10)" } } } - + def _execute_tool(self, tool_name: str, parameters: Dict[str, Any], report_context: str = "") -> str: """ - 执行工具调用 - + Execute a tool call + Args: - tool_name: 工具名称 - parameters: 工具参数 - report_context: 报告上下文(用于InsightForge) - + tool_name: Tool name + parameters: Tool parameters + report_context: Report context (used by InsightForge) + Returns: - 工具执行结果(文本格式) + Tool execution result (in text format) """ logger.info(t('report.executingTool', toolName=tool_name, params=parameters)) @@ -980,7 +980,7 @@ class ReportAgent: return result.to_text() elif tool_name == "panorama_search": - # 广度搜索 - 获取全貌 + # Broad search - get the big picture query = parameters.get("query", "") include_expired = parameters.get("include_expired", True) if isinstance(include_expired, str): @@ -993,7 +993,7 @@ class ReportAgent: return result.to_text() elif tool_name == "quick_search": - # 简单搜索 - 快速检索 + # Simple search - quick retrieval query = parameters.get("query", "") limit = parameters.get("limit", 10) if isinstance(limit, str): @@ -1006,7 +1006,7 @@ class ReportAgent: return result.to_text() elif tool_name == "interview_agents": - # 深度采访 - 调用真实的OASIS采访API获取模拟Agent的回答(双平台) + # Deep interview - call the real OASIS interview API to obtain simulation Agent responses (dual platform) interview_topic = parameters.get("interview_topic", parameters.get("query", "")) max_agents = parameters.get("max_agents", 5) if isinstance(max_agents, str): @@ -1020,10 +1020,10 @@ class ReportAgent: ) return result.to_text() - # ========== 向后兼容的旧工具(内部重定向到新工具) ========== - + # ========== Backward-compatible legacy tools (internally redirected to new tools) ========== + elif tool_name == "search_graph": - # 重定向到 quick_search + # Redirect to quick_search logger.info(t('report.redirectToQuickSearch')) return self._execute_tool("quick_search", parameters, report_context) @@ -1040,7 +1040,7 @@ class ReportAgent: return json.dumps(result, ensure_ascii=False, indent=2) elif tool_name == "get_simulation_context": - # 重定向到 insight_forge,因为它更强大 + # Redirect to insight_forge because it is more powerful logger.info(t('report.redirectToInsightForge')) query = parameters.get("query", self.simulation_requirement) return self._execute_tool("insight_forge", {"query": query}, report_context) @@ -1055,26 +1055,26 @@ class ReportAgent: return json.dumps(result, ensure_ascii=False, indent=2) else: - return f"未知工具: {tool_name}。请使用以下工具之一: insight_forge, panorama_search, quick_search" - + return f"Unknown tool: {tool_name}. Please use one of the following tools: insight_forge, panorama_search, quick_search" + except Exception as e: logger.error(t('report.toolExecFailed', toolName=tool_name, error=str(e))) - return f"工具执行失败: {str(e)}" - - # 合法的工具名称集合,用于裸 JSON 兜底解析时校验 + return f"Tool execution failed: {str(e)}" + + # Set of valid tool names, used for validation during bare-JSON fallback parsing VALID_TOOL_NAMES = {"insight_forge", "panorama_search", "quick_search", "interview_agents"} def _parse_tool_calls(self, response: str) -> List[Dict[str, Any]]: """ - 从LLM响应中解析工具调用 + Parse tool calls from the LLM response - 支持的格式(按优先级): + Supported formats (in priority order): 1. {"name": "tool_name", "parameters": {...}} - 2. 裸 JSON(响应整体或单行就是一个工具调用 JSON) + 2. Bare JSON (the entire response or a single line is a tool call JSON) """ tool_calls = [] - # 格式1: XML风格(标准格式) + # Format 1: XML-style (standard format) xml_pattern = r'\s*(\{.*?\})\s*' for match in re.finditer(xml_pattern, response, re.DOTALL): try: @@ -1086,8 +1086,8 @@ class ReportAgent: if tool_calls: return tool_calls - # 格式2: 兜底 - LLM 直接输出裸 JSON(没包 标签) - # 只在格式1未匹配时尝试,避免误匹配正文中的 JSON + # Format 2: Fallback - LLM directly outputs bare JSON (without tags) + # Only tried when Format 1 did not match, to avoid false matches of JSON in body text stripped = response.strip() if stripped.startswith('{') and stripped.endswith('}'): try: @@ -1098,7 +1098,7 @@ class ReportAgent: except json.JSONDecodeError: pass - # 响应可能包含思考文字 + 裸 JSON,尝试提取最后一个 JSON 对象 + # The response may contain thinking text + bare JSON; try extracting the last JSON object json_pattern = r'(\{"(?:name|tool)"\s*:.*?\})\s*$' match = re.search(json_pattern, stripped, re.DOTALL) if match: @@ -1112,57 +1112,57 @@ class ReportAgent: return tool_calls def _is_valid_tool_call(self, data: dict) -> bool: - """校验解析出的 JSON 是否是合法的工具调用""" - # 支持 {"name": ..., "parameters": ...} 和 {"tool": ..., "params": ...} 两种键名 + """Validate whether the parsed JSON is a legitimate tool call""" + # Support both {"name": ..., "parameters": ...} and {"tool": ..., "params": ...} key names tool_name = data.get("name") or data.get("tool") if tool_name and tool_name in self.VALID_TOOL_NAMES: - # 统一键名为 name / parameters + # Unify the key names to name / parameters if "tool" in data: data["name"] = data.pop("tool") if "params" in data and "parameters" not in data: data["parameters"] = data.pop("params") return True return False - + def _get_tools_description(self) -> str: - """生成工具描述文本""" - desc_parts = ["可用工具:"] + """Generate the tool description text""" + desc_parts = ["Available tools:"] for name, tool in self.tools.items(): params_desc = ", ".join([f"{k}: {v}" for k, v in tool["parameters"].items()]) desc_parts.append(f"- {name}: {tool['description']}") if params_desc: - desc_parts.append(f" 参数: {params_desc}") + desc_parts.append(f" Parameters: {params_desc}") return "\n".join(desc_parts) def plan_outline( - self, + self, progress_callback: Optional[Callable] = None ) -> ReportOutline: """ - 规划报告大纲 - - 使用LLM分析模拟需求,规划报告的目录结构 - + Plan the report outline + + Use the LLM to analyze simulation requirements and plan the report's table of contents. + Args: - progress_callback: 进度回调函数 - + progress_callback: Progress callback function + Returns: - ReportOutline: 报告大纲 + ReportOutline: The report outline """ logger.info(t('report.startPlanningOutline')) - + if progress_callback: progress_callback("planning", 0, t('progress.analyzingRequirements')) - - # 首先获取模拟上下文 + + # First, obtain the simulation context context = self.zep_tools.get_simulation_context( graph_id=self.graph_id, simulation_requirement=self.simulation_requirement ) - + if progress_callback: progress_callback("planning", 30, t('progress.generatingOutline')) - + system_prompt = f"{PLAN_SYSTEM_PROMPT}\n\n{get_language_instruction()}" user_prompt = PLAN_USER_PROMPT_TEMPLATE.format( simulation_requirement=self.simulation_requirement, @@ -1181,45 +1181,45 @@ class ReportAgent: ], temperature=0.3 ) - + if progress_callback: progress_callback("planning", 80, t('progress.parsingOutline')) - - # 解析大纲 + + # Parse the outline sections = [] for section_data in response.get("sections", []): sections.append(ReportSection( title=section_data.get("title", ""), content="" )) - + outline = ReportOutline( - title=response.get("title", "模拟分析报告"), + title=response.get("title", "Simulation Analysis Report"), summary=response.get("summary", ""), sections=sections ) - + if progress_callback: progress_callback("planning", 100, t('progress.outlinePlanComplete')) - + logger.info(t('report.outlinePlanDone', count=len(sections))) return outline - + except Exception as e: logger.error(t('report.outlinePlanFailed', error=str(e))) - # 返回默认大纲(3个章节,作为fallback) + # Return the default outline (3 sections, as a fallback) return ReportOutline( - title="未来预测报告", - summary="基于模拟预测的未来趋势与风险分析", + title="Future Prediction Report", + summary="Future trend and risk analysis based on simulation predictions", sections=[ - ReportSection(title="预测场景与核心发现"), - ReportSection(title="人群行为预测分析"), - ReportSection(title="趋势展望与风险提示") + ReportSection(title="Prediction Scenario and Core Findings"), + ReportSection(title="Crowd Behavior Prediction Analysis"), + ReportSection(title="Trend Outlook and Risk Warning") ] ) def _generate_section_react( - self, + self, section: ReportSection, outline: ReportOutline, previous_sections: List[str], @@ -1227,31 +1227,31 @@ class ReportAgent: section_index: int = 0 ) -> str: """ - 使用ReACT模式生成单个章节内容 - - ReACT循环: - 1. Thought(思考)- 分析需要什么信息 - 2. Action(行动)- 调用工具获取信息 - 3. Observation(观察)- 分析工具返回结果 - 4. 重复直到信息足够或达到最大次数 - 5. Final Answer(最终回答)- 生成章节内容 - + Generate a single section's content using the ReACT pattern + + ReACT loop: + 1. Thought - analyze what information is needed + 2. Action - call tools to obtain information + 3. Observation - analyze the tool's returned results + 4. Repeat until enough information is gathered or the maximum number of calls is reached + 5. Final Answer - generate the section content + Args: - section: 要生成的章节 - outline: 完整大纲 - previous_sections: 之前章节的内容(用于保持连贯性) - progress_callback: 进度回调 - section_index: 章节索引(用于日志记录) - + section: The section to generate + outline: The full outline + previous_sections: Content of previous sections (used to maintain consistency) + progress_callback: Progress callback + section_index: Section index (used for logging) + Returns: - 章节内容(Markdown格式) + The section content (in Markdown format) """ logger.info(t('report.reactGenerateSection', title=section.title)) - - # 记录章节开始日志 + + # Record the section start log if self.report_logger: self.report_logger.log_section_start(section.title, section_index) - + system_prompt = SECTION_SYSTEM_PROMPT_TEMPLATE.format( report_title=outline.title, report_summary=outline.summary, @@ -1261,17 +1261,17 @@ class ReportAgent: ) system_prompt = f"{system_prompt}\n\n{get_language_instruction()}" - # 构建用户prompt - 每个已完成章节各传入最大4000字 + # Build the user prompt - up to 4000 chars per completed section are passed in if previous_sections: previous_parts = [] for sec in previous_sections: - # 每个章节最多4000字 + # Up to 4000 chars per section truncated = sec[:4000] + "..." if len(sec) > 4000 else sec previous_parts.append(truncated) previous_content = "\n\n---\n\n".join(previous_parts) else: - previous_content = "(这是第一个章节)" - + previous_content = "(This is the first section)" + user_prompt = SECTION_USER_PROMPT_TEMPLATE.format( previous_content=previous_content, section_title=section.title, @@ -1281,17 +1281,17 @@ class ReportAgent: {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ] - - # ReACT循环 + + # ReACT loop tool_calls_count = 0 - max_iterations = 5 # 最大迭代轮数 - min_tool_calls = 3 # 最少工具调用次数 - conflict_retries = 0 # 工具调用与Final Answer同时出现的连续冲突次数 - used_tools = set() # 记录已调用过的工具名 + max_iterations = 5 # Maximum number of iterations + min_tool_calls = 3 # Minimum number of tool calls + conflict_retries = 0 # Number of consecutive conflicts where tool calls and Final Answer appear together + used_tools = set() # Records the names of tools that have been called all_tools = {"insight_forge", "panorama_search", "quick_search", "interview_agents"} - # 报告上下文,用于InsightForge的子问题生成 - report_context = f"章节标题: {section.title}\n模拟需求: {self.simulation_requirement}" + # Report context, used for InsightForge's sub-question generation + report_context = f"Section title: {section.title}\nSimulation requirement: {self.simulation_requirement}" for iteration in range(max_iterations): if progress_callback: @@ -1301,32 +1301,32 @@ class ReportAgent: t('progress.deepSearchAndWrite', current=tool_calls_count, max=self.MAX_TOOL_CALLS_PER_SECTION) ) - # 调用LLM + # Call the LLM response = self.llm.chat( messages=messages, temperature=0.5, max_tokens=4096 ) - # 检查 LLM 返回是否为 None(API 异常或内容为空) + # Check whether the LLM response is None (API exception or empty content) if response is None: logger.warning(t('report.sectionIterNone', title=section.title, iteration=iteration + 1)) - # 如果还有迭代次数,添加消息并重试 + # If iterations remain, add a message and retry if iteration < max_iterations - 1: - messages.append({"role": "assistant", "content": "(响应为空)"}) - messages.append({"role": "user", "content": "请继续生成内容。"}) + messages.append({"role": "assistant", "content": "(response is empty)"}) + messages.append({"role": "user", "content": "Please continue generating content."}) continue - # 最后一次迭代也返回 None,跳出循环进入强制收尾 + # The last iteration also returned None; break out of the loop into forced finalization break - logger.debug(f"LLM响应: {response[:200]}...") + logger.debug(f"LLM response: {response[:200]}...") - # 解析一次,复用结果 + # Parse once and reuse the result tool_calls = self._parse_tool_calls(response) has_tool_calls = bool(tool_calls) has_final_answer = "Final Answer:" in response - # ── 冲突处理:LLM 同时输出了工具调用和 Final Answer ── + # ── Conflict handling: LLM output both a tool call and Final Answer ── if has_tool_calls and has_final_answer: conflict_retries += 1 logger.warning( @@ -1334,21 +1334,21 @@ class ReportAgent: ) if conflict_retries <= 2: - # 前两次:丢弃本次响应,要求 LLM 重新回复 + # First two attempts: discard this response and ask the LLM to reply again messages.append({"role": "assistant", "content": response}) messages.append({ "role": "user", "content": ( - "【格式错误】你在一次回复中同时包含了工具调用和 Final Answer,这是不允许的。\n" - "每次回复只能做以下两件事之一:\n" - "- 调用一个工具(输出一个 块,不要写 Final Answer)\n" - "- 输出最终内容(以 'Final Answer:' 开头,不要包含 )\n" - "请重新回复,只做其中一件事。" + "[Format Error] You included both a tool call and Final Answer in a single reply, which is not allowed.\n" + "Each reply can only do one of the following two things:\n" + "- Call a tool (output one block, do not write Final Answer)\n" + "- Output the final content (start with 'Final Answer:', do not include )\n" + "Please reply again and do only one of them." ), }) continue else: - # 第三次:降级处理,截断到第一个工具调用,强制执行 + # Third attempt: graceful degradation, truncate to the first tool call and force execution logger.warning( t('report.sectionConflictDowngrade', title=section.title, conflictCount=conflict_retries) ) @@ -1360,7 +1360,7 @@ class ReportAgent: has_final_answer = False conflict_retries = 0 - # 记录 LLM 响应日志 + # Log the LLM response if self.report_logger: self.report_logger.log_llm_response( section_title=section.title, @@ -1371,13 +1371,13 @@ class ReportAgent: has_final_answer=has_final_answer ) - # ── 情况1:LLM 输出了 Final Answer ── + # ── Case 1: LLM output Final Answer ── if has_final_answer: - # 工具调用次数不足,拒绝并要求继续调工具 + # Tool call count is insufficient; reject and require more tool calls if tool_calls_count < min_tool_calls: messages.append({"role": "assistant", "content": response}) unused_tools = all_tools - used_tools - unused_hint = f"(这些工具还未使用,推荐用一下他们: {', '.join(unused_tools)})" if unused_tools else "" + unused_hint = f"(These tools have not been used yet; we recommend giving them a try: {', '.join(unused_tools)})" if unused_tools else "" messages.append({ "role": "user", "content": REACT_INSUFFICIENT_TOOLS_MSG.format( @@ -1388,7 +1388,7 @@ class ReportAgent: }) continue - # 正常结束 + # Normal end final_answer = response.split("Final Answer:")[-1].strip() logger.info(t('report.sectionGenDone', title=section.title, count=tool_calls_count)) @@ -1401,9 +1401,9 @@ class ReportAgent: ) return final_answer - # ── 情况2:LLM 尝试调用工具 ── + # ── Case 2: LLM attempted to call a tool ── if has_tool_calls: - # 工具额度已耗尽 → 明确告知,要求输出 Final Answer + # Tool quota exhausted → state explicitly and require Final Answer if tool_calls_count >= self.MAX_TOOL_CALLS_PER_SECTION: messages.append({"role": "assistant", "content": response}) messages.append({ @@ -1415,7 +1415,7 @@ class ReportAgent: }) continue - # 只执行第一个工具调用 + # Only execute the first tool call call = tool_calls[0] if len(tool_calls) > 1: logger.info(t('report.multiToolOnlyFirst', total=len(tool_calls), toolName=call['name'])) @@ -1447,7 +1447,7 @@ class ReportAgent: tool_calls_count += 1 used_tools.add(call['name']) - # 构建未使用工具提示 + # Build the unused-tools hint unused_tools = all_tools - used_tools unused_hint = "" if unused_tools and tool_calls_count < self.MAX_TOOL_CALLS_PER_SECTION: @@ -1467,13 +1467,13 @@ class ReportAgent: }) continue - # ── 情况3:既没有工具调用,也没有 Final Answer ── + # ── Case 3: Neither a tool call nor a Final Answer ── messages.append({"role": "assistant", "content": response}) if tool_calls_count < min_tool_calls: - # 工具调用次数不足,推荐未用过的工具 + # Tool call count insufficient; recommend unused tools unused_tools = all_tools - used_tools - unused_hint = f"(这些工具还未使用,推荐用一下他们: {', '.join(unused_tools)})" if unused_tools else "" + unused_hint = f"(These tools have not been used yet; we recommend giving them a try: {', '.join(unused_tools)})" if unused_tools else "" messages.append({ "role": "user", @@ -1485,8 +1485,8 @@ class ReportAgent: }) continue - # 工具调用已足够,LLM 输出了内容但没带 "Final Answer:" 前缀 - # 直接将这段内容作为最终答案,不再空转 + # Tool call count is sufficient; the LLM output content but did not include "Final Answer:" prefix + # Use this content as the final answer directly, no more spinning logger.info(t('report.sectionNoPrefix', title=section.title, count=tool_calls_count)) final_answer = response.strip() @@ -1499,17 +1499,17 @@ class ReportAgent: ) return final_answer - # 达到最大迭代次数,强制生成内容 + # Maximum iterations reached; force content generation logger.warning(t('report.sectionMaxIter', title=section.title)) messages.append({"role": "user", "content": REACT_FORCE_FINAL_MSG}) - + response = self.llm.chat( messages=messages, temperature=0.5, max_tokens=4096 ) - # 检查强制收尾时 LLM 返回是否为 None + # Check whether the LLM returned None during forced finalization if response is None: logger.error(t('report.sectionForceFailed', title=section.title)) final_answer = t('report.sectionGenFailedContent') @@ -1517,8 +1517,8 @@ class ReportAgent: final_answer = response.split("Final Answer:")[-1].strip() else: final_answer = response - - # 记录章节内容生成完成日志 + + # Record the section content generation complete log if self.report_logger: self.report_logger.log_section_content( section_title=section.title, @@ -1526,42 +1526,44 @@ class ReportAgent: content=final_answer, tool_calls_count=tool_calls_count ) - + return final_answer def generate_report( - self, + self, progress_callback: Optional[Callable[[str, int, str], None]] = None, report_id: Optional[str] = None ) -> Report: """ - 生成完整报告(分章节实时输出) - - 每个章节生成完成后立即保存到文件夹,不需要等待整个报告完成。 - 文件结构: + Generate the complete report (streaming output section by section) + + Each section is saved to the folder as soon as it is generated, without waiting + for the entire report to finish. + + File structure: reports/{report_id}/ - meta.json - 报告元信息 - outline.json - 报告大纲 - progress.json - 生成进度 - section_01.md - 第1章节 - section_02.md - 第2章节 + meta.json - report metadata + outline.json - report outline + progress.json - generation progress + section_01.md - section 1 + section_02.md - section 2 ... - full_report.md - 完整报告 - + full_report.md - full report + Args: - progress_callback: 进度回调函数 (stage, progress, message) - report_id: 报告ID(可选,如果不传则自动生成) - + progress_callback: Progress callback function (stage, progress, message) + report_id: Report ID (optional; auto-generated if not provided) + Returns: - Report: 完整报告 + Report: The complete report """ import uuid - - # 如果没有传入 report_id,则自动生成 + + # If no report_id is provided, auto-generate one if not report_id: report_id = f"report_{uuid.uuid4().hex[:12]}" start_time = datetime.now() - + report = Report( report_id=report_id, simulation_id=self.simulation_id, @@ -1570,74 +1572,74 @@ class ReportAgent: status=ReportStatus.PENDING, created_at=datetime.now().isoformat() ) - - # 已完成的章节标题列表(用于进度追踪) + + # List of completed section titles (used for progress tracking) completed_section_titles = [] - + try: - # 初始化:创建报告文件夹并保存初始状态 + # Initialization: create the report folder and save the initial state ReportManager._ensure_report_folder(report_id) - - # 初始化日志记录器(结构化日志 agent_log.jsonl) + + # Initialize the structured logger (agent_log.jsonl) self.report_logger = ReportLogger(report_id) self.report_logger.log_start( simulation_id=self.simulation_id, graph_id=self.graph_id, simulation_requirement=self.simulation_requirement ) - - # 初始化控制台日志记录器(console_log.txt) + + # Initialize the console logger (console_log.txt) self.console_logger = ReportConsoleLogger(report_id) - + ReportManager.update_progress( report_id, "pending", 0, t('progress.initReport'), completed_sections=[] ) ReportManager.save_report(report) - - # 阶段1: 规划大纲 + + # Phase 1: plan the outline report.status = ReportStatus.PLANNING ReportManager.update_progress( report_id, "planning", 5, t('progress.startPlanningOutline'), completed_sections=[] ) - - # 记录规划开始日志 + + # Log the start of planning self.report_logger.log_planning_start() - + if progress_callback: progress_callback("planning", 0, t('progress.startPlanningOutline')) - + outline = self.plan_outline( - progress_callback=lambda stage, prog, msg: + progress_callback=lambda stage, prog, msg: progress_callback(stage, prog // 5, msg) if progress_callback else None ) report.outline = outline - - # 记录规划完成日志 + + # Log the completion of planning self.report_logger.log_planning_complete(outline.to_dict()) - - # 保存大纲到文件 + + # Save the outline to a file ReportManager.save_outline(report_id, outline) ReportManager.update_progress( report_id, "planning", 15, t('progress.outlineDone', count=len(outline.sections)), completed_sections=[] ) ReportManager.save_report(report) - + logger.info(t('report.outlineSavedToFile', reportId=report_id)) - - # 阶段2: 逐章节生成(分章节保存) + + # Phase 2: generate section by section (saving per section) report.status = ReportStatus.GENERATING - + total_sections = len(outline.sections) - generated_sections = [] # 保存内容用于上下文 - + generated_sections = [] # Saved content for context + for i, section in enumerate(outline.sections): section_num = i + 1 base_progress = 20 + int((i / total_sections) * 70) - - # 更新进度 + + # Update progress ReportManager.update_progress( report_id, "generating", base_progress, t('progress.generatingSection', title=section.title, current=section_num, total=total_sections), @@ -1651,29 +1653,29 @@ class ReportAgent: base_progress, t('progress.generatingSection', title=section.title, current=section_num, total=total_sections) ) - - # 生成主章节内容 + + # Generate the main section content section_content = self._generate_section_react( section=section, outline=outline, previous_sections=generated_sections, progress_callback=lambda stage, prog, msg: progress_callback( - stage, + stage, base_progress + int(prog * 0.7 / total_sections), msg ) if progress_callback else None, section_index=section_num ) - + section.content = section_content generated_sections.append(f"## {section.title}\n\n{section_content}") - # 保存章节 + # Save the section ReportManager.save_section(report_id, section_num, section) completed_section_titles.append(section.title) - # 记录章节完成日志 + # Log section completion full_section_content = f"## {section.title}\n\n{section_content}" if self.report_logger: @@ -1684,69 +1686,69 @@ class ReportAgent: ) logger.info(t('report.sectionSaved', reportId=report_id, sectionNum=f"{section_num:02d}")) - - # 更新进度 + + # Update progress ReportManager.update_progress( - report_id, "generating", + report_id, "generating", base_progress + int(70 / total_sections), t('progress.sectionDone', title=section.title), current_section=None, completed_sections=completed_section_titles ) - - # 阶段3: 组装完整报告 + + # Phase 3: assemble the full report if progress_callback: progress_callback("generating", 95, t('progress.assemblingReport')) - + ReportManager.update_progress( report_id, "generating", 95, t('progress.assemblingReport'), completed_sections=completed_section_titles ) - - # 使用ReportManager组装完整报告 + + # Use ReportManager to assemble the full report report.markdown_content = ReportManager.assemble_full_report(report_id, outline) report.status = ReportStatus.COMPLETED report.completed_at = datetime.now().isoformat() - - # 计算总耗时 + + # Calculate total elapsed time total_time_seconds = (datetime.now() - start_time).total_seconds() - - # 记录报告完成日志 + + # Log report completion if self.report_logger: self.report_logger.log_report_complete( total_sections=total_sections, total_time_seconds=total_time_seconds ) - - # 保存最终报告 + + # Save the final report ReportManager.save_report(report) ReportManager.update_progress( report_id, "completed", 100, t('progress.reportComplete'), completed_sections=completed_section_titles ) - + if progress_callback: progress_callback("completed", 100, t('progress.reportComplete')) - + logger.info(t('report.reportGenDone', reportId=report_id)) - - # 关闭控制台日志记录器 + + # Close the console logger if self.console_logger: self.console_logger.close() self.console_logger = None - + return report - + except Exception as e: logger.error(t('report.reportGenFailed', error=str(e))) report.status = ReportStatus.FAILED report.error = str(e) - - # 记录错误日志 + + # Log the error if self.report_logger: self.report_logger.log_error(str(e), "failed") - - # 保存失败状态 + + # Save the failure state try: ReportManager.save_report(report) ReportManager.update_progress( @@ -1754,126 +1756,126 @@ class ReportAgent: completed_sections=completed_section_titles ) except Exception: - pass # 忽略保存失败的错误 - - # 关闭控制台日志记录器 + pass # Ignore errors when saving the failure state + + # Close the console logger if self.console_logger: self.console_logger.close() self.console_logger = None - + return report def chat( - self, + self, message: str, chat_history: List[Dict[str, str]] = None ) -> Dict[str, Any]: """ - 与Report Agent对话 - - 在对话中Agent可以自主调用检索工具来回答问题 - + Converse with the Report Agent + + During the conversation, the Agent can autonomously call retrieval tools to answer questions. + Args: - message: 用户消息 - chat_history: 对话历史 - + message: User message + chat_history: Conversation history + Returns: { - "response": "Agent回复", - "tool_calls": [调用的工具列表], - "sources": [信息来源] + "response": "Agent's reply", + "tool_calls": [list of tools called], + "sources": [information sources] } """ logger.info(t('report.agentChat', message=message[:50])) - + chat_history = chat_history or [] - - # 获取已生成的报告内容 + + # Get the content of the generated report report_content = "" try: report = ReportManager.get_report_by_simulation(self.simulation_id) if report and report.markdown_content: - # 限制报告长度,避免上下文过长 + # Limit the report length to avoid overly long context report_content = report.markdown_content[:15000] if len(report.markdown_content) > 15000: - report_content += "\n\n... [报告内容已截断] ..." + report_content += "\n\n... [Report content truncated] ..." except Exception as e: logger.warning(t('report.fetchReportFailed', error=e)) - + system_prompt = CHAT_SYSTEM_PROMPT_TEMPLATE.format( simulation_requirement=self.simulation_requirement, - report_content=report_content if report_content else "(暂无报告)", + report_content=report_content if report_content else "(No report yet)", tools_description=self._get_tools_description(), ) system_prompt = f"{system_prompt}\n\n{get_language_instruction()}" - # 构建消息 + # Build the messages messages = [{"role": "system", "content": system_prompt}] - - # 添加历史对话 - for h in chat_history[-10:]: # 限制历史长度 + + # Add the conversation history + for h in chat_history[-10:]: # Limit the history length messages.append(h) - - # 添加用户消息 + + # Add the user message messages.append({ - "role": "user", + "role": "user", "content": message }) - - # ReACT循环(简化版) + + # ReACT loop (simplified) tool_calls_made = [] - max_iterations = 2 # 减少迭代轮数 - + max_iterations = 2 # Reduced number of iterations + for iteration in range(max_iterations): response = self.llm.chat( messages=messages, temperature=0.5 ) - - # 解析工具调用 + + # Parse tool calls tool_calls = self._parse_tool_calls(response) - + if not tool_calls: - # 没有工具调用,直接返回响应 + # No tool calls; return the response directly clean_response = re.sub(r'.*?', '', response, flags=re.DOTALL) clean_response = re.sub(r'\[TOOL_CALL\].*?\)', '', clean_response) - + return { "response": clean_response.strip(), "tool_calls": tool_calls_made, "sources": [tc.get("parameters", {}).get("query", "") for tc in tool_calls_made] } - - # 执行工具调用(限制数量) + + # Execute tool calls (limited) tool_results = [] - for call in tool_calls[:1]: # 每轮最多执行1次工具调用 + for call in tool_calls[:1]: # At most 1 tool call per turn if len(tool_calls_made) >= self.MAX_TOOL_CALLS_PER_CHAT: break result = self._execute_tool(call["name"], call.get("parameters", {})) tool_results.append({ "tool": call["name"], - "result": result[:1500] # 限制结果长度 + "result": result[:1500] # Limit the result length }) tool_calls_made.append(call) - - # 将结果添加到消息 + + # Append the result to messages messages.append({"role": "assistant", "content": response}) - observation = "\n".join([f"[{r['tool']}结果]\n{r['result']}" for r in tool_results]) + observation = "\n".join([f"[{r['tool']} result]\n{r['result']}" for r in tool_results]) messages.append({ "role": "user", "content": observation + CHAT_OBSERVATION_SUFFIX }) - - # 达到最大迭代,获取最终响应 + + # Maximum iterations reached; obtain the final response final_response = self.llm.chat( messages=messages, temperature=0.5 ) - - # 清理响应 + + # Clean the response clean_response = re.sub(r'.*?', '', final_response, flags=re.DOTALL) clean_response = re.sub(r'\[TOOL_CALL\].*?\)', '', clean_response) - + return { "response": clean_response.strip(), "tool_calls": tool_calls_made, @@ -1883,95 +1885,95 @@ class ReportAgent: class ReportManager: """ - 报告管理器 + Report Manager - 负责报告的持久化存储和检索 + Responsible for the persistent storage and retrieval of reports - 文件结构(分章节输出): + File structure (per-section output): reports/ {report_id}/ - meta.json - 报告元信息和状态 - outline.json - 报告大纲 - progress.json - 生成进度 - section_01.md - 第1章节 - section_02.md - 第2章节 + meta.json - Report metadata and status + outline.json - Report outline + progress.json - Generation progress + section_01.md - Section 1 + section_02.md - Section 2 ... - full_report.md - 完整报告 + full_report.md - Full report """ - # 报告存储目录 + # Report storage directory REPORTS_DIR = os.path.join(Config.UPLOAD_FOLDER, 'reports') @classmethod def _ensure_reports_dir(cls): - """确保报告根目录存在""" + """Ensure the report root directory exists""" os.makedirs(cls.REPORTS_DIR, exist_ok=True) @classmethod def _get_report_folder(cls, report_id: str) -> str: - """获取报告文件夹路径""" + """Get the report folder path""" return os.path.join(cls.REPORTS_DIR, report_id) @classmethod def _ensure_report_folder(cls, report_id: str) -> str: - """确保报告文件夹存在并返回路径""" + """Ensure the report folder exists and return the path""" folder = cls._get_report_folder(report_id) os.makedirs(folder, exist_ok=True) return folder @classmethod def _get_report_path(cls, report_id: str) -> str: - """获取报告元信息文件路径""" + """Get the report metadata file path""" return os.path.join(cls._get_report_folder(report_id), "meta.json") @classmethod def _get_report_markdown_path(cls, report_id: str) -> str: - """获取完整报告Markdown文件路径""" + """Get the full report Markdown file path""" return os.path.join(cls._get_report_folder(report_id), "full_report.md") @classmethod def _get_outline_path(cls, report_id: str) -> str: - """获取大纲文件路径""" + """Get the outline file path""" return os.path.join(cls._get_report_folder(report_id), "outline.json") @classmethod def _get_progress_path(cls, report_id: str) -> str: - """获取进度文件路径""" + """Get the progress file path""" return os.path.join(cls._get_report_folder(report_id), "progress.json") @classmethod def _get_section_path(cls, report_id: str, section_index: int) -> str: - """获取章节Markdown文件路径""" + """Get the section Markdown file path""" return os.path.join(cls._get_report_folder(report_id), f"section_{section_index:02d}.md") @classmethod def _get_agent_log_path(cls, report_id: str) -> str: - """获取 Agent 日志文件路径""" + """Get the Agent log file path""" return os.path.join(cls._get_report_folder(report_id), "agent_log.jsonl") @classmethod def _get_console_log_path(cls, report_id: str) -> str: - """获取控制台日志文件路径""" + """Get the console log file path""" return os.path.join(cls._get_report_folder(report_id), "console_log.txt") @classmethod def get_console_log(cls, report_id: str, from_line: int = 0) -> Dict[str, Any]: """ - 获取控制台日志内容 + Get the console log content - 这是报告生成过程中的控制台输出日志(INFO、WARNING等), - 与 agent_log.jsonl 的结构化日志不同。 + This is the console output log (INFO, WARNING, etc.) produced during report + generation, distinct from the structured log stored in agent_log.jsonl. Args: - report_id: 报告ID - from_line: 从第几行开始读取(用于增量获取,0 表示从头开始) + report_id: The report ID + from_line: The line number to start reading from (for incremental fetch, 0 means from the beginning) Returns: { - "logs": [日志行列表], - "total_lines": 总行数, - "from_line": 起始行号, - "has_more": 是否还有更多日志 + "logs": [List of log lines], + "total_lines": Total number of lines, + "from_line": Starting line number, + "has_more": Whether more logs are available } """ log_path = cls._get_console_log_path(report_id) @@ -1991,26 +1993,26 @@ class ReportManager: for i, line in enumerate(f): total_lines = i + 1 if i >= from_line: - # 保留原始日志行,去掉末尾换行符 + # Keep the original log line, stripping the trailing newline logs.append(line.rstrip('\n\r')) return { "logs": logs, "total_lines": total_lines, "from_line": from_line, - "has_more": False # 已读取到末尾 + "has_more": False # Already read to the end } @classmethod def get_console_log_stream(cls, report_id: str) -> List[str]: """ - 获取完整的控制台日志(一次性获取全部) + Get the full console log (fetch all at once) Args: - report_id: 报告ID + report_id: The report ID Returns: - 日志行列表 + List of log lines """ result = cls.get_console_log(report_id, from_line=0) return result["logs"] @@ -2018,18 +2020,18 @@ class ReportManager: @classmethod def get_agent_log(cls, report_id: str, from_line: int = 0) -> Dict[str, Any]: """ - 获取 Agent 日志内容 + Get the Agent log content Args: - report_id: 报告ID - from_line: 从第几行开始读取(用于增量获取,0 表示从头开始) + report_id: The report ID + from_line: The line number to start reading from (for incremental fetch, 0 means from the beginning) Returns: { - "logs": [日志条目列表], - "total_lines": 总行数, - "from_line": 起始行号, - "has_more": 是否还有更多日志 + "logs": [List of log entries], + "total_lines": Total number of lines, + "from_line": Starting line number, + "has_more": Whether more logs are available } """ log_path = cls._get_agent_log_path(report_id) @@ -2053,26 +2055,26 @@ class ReportManager: log_entry = json.loads(line.strip()) logs.append(log_entry) except json.JSONDecodeError: - # 跳过解析失败的行 + # Skip lines that failed to parse continue return { "logs": logs, "total_lines": total_lines, "from_line": from_line, - "has_more": False # 已读取到末尾 + "has_more": False # Already read to the end } @classmethod def get_agent_log_stream(cls, report_id: str) -> List[Dict[str, Any]]: """ - 获取完整的 Agent 日志(用于一次性获取全部) + Get the full Agent log (for fetching all entries at once) Args: - report_id: 报告ID + report_id: The report ID Returns: - 日志条目列表 + List of log entries """ result = cls.get_agent_log(report_id, from_line=0) return result["logs"] @@ -2080,9 +2082,9 @@ class ReportManager: @classmethod def save_outline(cls, report_id: str, outline: ReportOutline) -> None: """ - 保存报告大纲 + Save the report outline - 在规划阶段完成后立即调用 + Called immediately after the planning phase completes """ cls._ensure_report_folder(report_id) @@ -2099,27 +2101,27 @@ class ReportManager: section: ReportSection ) -> str: """ - 保存单个章节 + Save a single section - 在每个章节生成完成后立即调用,实现分章节输出 + Called immediately after each section is generated, enabling per-section output Args: - report_id: 报告ID - section_index: 章节索引(从1开始) - section: 章节对象 + report_id: The report ID + section_index: The section index (1-based) + section: The section object Returns: - 保存的文件路径 + The saved file path """ cls._ensure_report_folder(report_id) - # 构建章节Markdown内容 - 清理可能存在的重复标题 + # Build the section Markdown content - clean up any potentially duplicate headings cleaned_content = cls._clean_section_content(section.content, section.title) md_content = f"## {section.title}\n\n" if cleaned_content: md_content += f"{cleaned_content}\n\n" - # 保存文件 + # Save the file file_suffix = f"section_{section_index:02d}.md" file_path = os.path.join(cls._get_report_folder(report_id), file_suffix) with open(file_path, 'w', encoding='utf-8') as f: @@ -2131,17 +2133,17 @@ class ReportManager: @classmethod def _clean_section_content(cls, content: str, section_title: str) -> str: """ - 清理章节内容 + Clean up the section content - 1. 移除内容开头与章节标题重复的Markdown标题行 - 2. 将所有 ### 及以下级别的标题转换为粗体文本 + 1. Remove Markdown heading lines at the start of the content that duplicate the section title + 2. Convert all ### and lower-level headings to bold text Args: - content: 原始内容 - section_title: 章节标题 + content: The original content + section_title: The section title Returns: - 清理后的内容 + The cleaned content """ import re @@ -2156,26 +2158,26 @@ class ReportManager: for i, line in enumerate(lines): stripped = line.strip() - # 检查是否是Markdown标题行 + # Check whether this is a Markdown heading line heading_match = re.match(r'^(#{1,6})\s+(.+)$', stripped) if heading_match: level = len(heading_match.group(1)) title_text = heading_match.group(2).strip() - # 检查是否是与章节标题重复的标题(跳过前5行内的重复) + # Check whether this heading duplicates the section title (skip duplicates within the first 5 lines) if i < 5: if title_text == section_title or title_text.replace(' ', '') == section_title.replace(' ', ''): skip_next_empty = True continue - # 将所有级别的标题(#, ##, ###, ####等)转换为粗体 - # 因为章节标题由系统添加,内容中不应有任何标题 + # Convert all heading levels (#, ##, ###, ####, etc.) to bold + # The section title is added by the system, so the content should not contain any headings cleaned_lines.append(f"**{title_text}**") - cleaned_lines.append("") # 添加空行 + cleaned_lines.append("") # Add a blank line continue - # 如果上一行是被跳过的标题,且当前行为空,也跳过 + # If the previous line was a skipped heading and the current line is empty, skip it as well if skip_next_empty and stripped == '': skip_next_empty = False continue @@ -2183,14 +2185,14 @@ class ReportManager: skip_next_empty = False cleaned_lines.append(line) - # 移除开头的空行 + # Remove leading blank lines while cleaned_lines and cleaned_lines[0].strip() == '': cleaned_lines.pop(0) - # 移除开头的分隔线 + # Remove leading separator lines while cleaned_lines and cleaned_lines[0].strip() in ['---', '***', '___']: cleaned_lines.pop(0) - # 同时移除分隔线后的空行 + # Also remove blank lines that follow a separator while cleaned_lines and cleaned_lines[0].strip() == '': cleaned_lines.pop(0) @@ -2207,9 +2209,9 @@ class ReportManager: completed_sections: List[str] = None ) -> None: """ - 更新报告生成进度 + Update the report generation progress - 前端可以通过读取progress.json获取实时进度 + The frontend can read progress.json to obtain real-time progress """ cls._ensure_report_folder(report_id) @@ -2227,7 +2229,7 @@ class ReportManager: @classmethod def get_progress(cls, report_id: str) -> Optional[Dict[str, Any]]: - """获取报告生成进度""" + """Get the report generation progress""" path = cls._get_progress_path(report_id) if not os.path.exists(path): @@ -2239,9 +2241,9 @@ class ReportManager: @classmethod def get_generated_sections(cls, report_id: str) -> List[Dict[str, Any]]: """ - 获取已生成的章节列表 + Get the list of generated sections - 返回所有已保存的章节文件信息 + Return information about all saved section files """ folder = cls._get_report_folder(report_id) @@ -2255,7 +2257,7 @@ class ReportManager: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() - # 从文件名解析章节索引 + # Parse the section index from the filename parts = filename.replace('.md', '').split('_') section_index = int(parts[1]) @@ -2270,26 +2272,26 @@ class ReportManager: @classmethod def assemble_full_report(cls, report_id: str, outline: ReportOutline) -> str: """ - 组装完整报告 + Assemble the full report - 从已保存的章节文件组装完整报告,并进行标题清理 + Assemble the full report from the saved section files, with heading cleanup """ folder = cls._get_report_folder(report_id) - # 构建报告头部 + # Build the report header md_content = f"# {outline.title}\n\n" md_content += f"> {outline.summary}\n\n" md_content += f"---\n\n" - # 按顺序读取所有章节文件 + # Read all section files in order sections = cls.get_generated_sections(report_id) for section_info in sections: md_content += section_info["content"] - # 后处理:清理整个报告的标题问题 + # Post-process: clean up heading issues across the entire report md_content = cls._post_process_report(md_content, outline) - # 保存完整报告 + # Save the full report full_path = cls._get_report_markdown_path(report_id) with open(full_path, 'w', encoding='utf-8') as f: f.write(md_content) @@ -2300,18 +2302,18 @@ class ReportManager: @classmethod def _post_process_report(cls, content: str, outline: ReportOutline) -> str: """ - 后处理报告内容 + Post-process the report content - 1. 移除重复的标题 - 2. 保留报告主标题(#)和章节标题(##),移除其他级别的标题(###, ####等) - 3. 清理多余的空行和分隔线 + 1. Remove duplicate headings + 2. Keep the report main heading (#) and section headings (##); remove other heading levels (###, ####, etc.) + 3. Clean up redundant blank lines and separator lines Args: - content: 原始报告内容 - outline: 报告大纲 + content: The original report content + outline: The report outline Returns: - 处理后的内容 + The processed content """ import re @@ -2319,7 +2321,7 @@ class ReportManager: processed_lines = [] prev_was_heading = False - # 收集大纲中的所有章节标题 + # Collect all section titles from the outline section_titles = set() for section in outline.sections: section_titles.add(section.title) @@ -2329,14 +2331,14 @@ class ReportManager: line = lines[i] stripped = line.strip() - # 检查是否是标题行 + # Check whether this is a heading line heading_match = re.match(r'^(#{1,6})\s+(.+)$', stripped) if heading_match: level = len(heading_match.group(1)) title = heading_match.group(2).strip() - # 检查是否是重复标题(在连续5行内出现相同内容的标题) + # Check whether this is a duplicate heading (same content appearing within 5 consecutive lines) is_duplicate = False for j in range(max(0, len(processed_lines) - 5), len(processed_lines)): prev_line = processed_lines[j].strip() @@ -2348,43 +2350,43 @@ class ReportManager: break if is_duplicate: - # 跳过重复标题及其后的空行 + # Skip the duplicate heading and any blank lines that follow i += 1 while i < len(lines) and lines[i].strip() == '': i += 1 continue - # 标题层级处理: - # - # (level=1) 只保留报告主标题 - # - ## (level=2) 保留章节标题 - # - ### 及以下 (level>=3) 转换为粗体文本 + # Heading level handling: + # - # (level=1) Keep only the report main heading + # - ## (level=2) Keep the section headings + # - ### and below (level>=3) Convert to bold text if level == 1: if title == outline.title: - # 保留报告主标题 + # Keep the report main heading processed_lines.append(line) prev_was_heading = True elif title in section_titles: - # 章节标题错误使用了#,修正为## + # Section heading mistakenly used #, correct it to ## processed_lines.append(f"## {title}") prev_was_heading = True else: - # 其他一级标题转为粗体 + # Other level-1 headings converted to bold processed_lines.append(f"**{title}**") processed_lines.append("") prev_was_heading = False elif level == 2: if title in section_titles or title == outline.title: - # 保留章节标题 + # Keep the section heading processed_lines.append(line) prev_was_heading = True else: - # 非章节的二级标题转为粗体 + # Non-section level-2 headings converted to bold processed_lines.append(f"**{title}**") processed_lines.append("") prev_was_heading = False else: - # ### 及以下级别的标题转换为粗体文本 + # ### and lower-level headings converted to bold text processed_lines.append(f"**{title}**") processed_lines.append("") prev_was_heading = False @@ -2393,12 +2395,12 @@ class ReportManager: continue elif stripped == '---' and prev_was_heading: - # 跳过标题后紧跟的分隔线 + # Skip a separator line that immediately follows a heading i += 1 continue elif stripped == '' and prev_was_heading: - # 标题后只保留一个空行 + # Keep only one blank line after a heading if processed_lines and processed_lines[-1].strip() != '': processed_lines.append(line) prev_was_heading = False @@ -2409,7 +2411,7 @@ class ReportManager: i += 1 - # 清理连续的多个空行(保留最多2个) + # Clean up consecutive blank lines (keep at most 2) result_lines = [] empty_count = 0 for line in processed_lines: @@ -2425,18 +2427,18 @@ class ReportManager: @classmethod def save_report(cls, report: Report) -> None: - """保存报告元信息和完整报告""" + """Save the report metadata and the full report""" cls._ensure_report_folder(report.report_id) - # 保存元信息JSON + # Save the metadata JSON with open(cls._get_report_path(report.report_id), 'w', encoding='utf-8') as f: json.dump(report.to_dict(), f, ensure_ascii=False, indent=2) - # 保存大纲 + # Save the outline if report.outline: cls.save_outline(report.report_id, report.outline) - # 保存完整Markdown报告 + # Save the full Markdown report if report.markdown_content: with open(cls._get_report_markdown_path(report.report_id), 'w', encoding='utf-8') as f: f.write(report.markdown_content) @@ -2445,11 +2447,11 @@ class ReportManager: @classmethod def get_report(cls, report_id: str) -> Optional[Report]: - """获取报告""" + """Get a report""" path = cls._get_report_path(report_id) if not os.path.exists(path): - # 兼容旧格式:检查直接存储在reports目录下的文件 + # Backward compatibility: check for files stored directly in the reports directory old_path = os.path.join(cls.REPORTS_DIR, f"{report_id}.json") if os.path.exists(old_path): path = old_path @@ -2459,7 +2461,7 @@ class ReportManager: with open(path, 'r', encoding='utf-8') as f: data = json.load(f) - # 重建Report对象 + # Rebuild the Report object outline = None if data.get('outline'): outline_data = data['outline'] @@ -2475,7 +2477,7 @@ class ReportManager: sections=sections ) - # 如果markdown_content为空,尝试从full_report.md读取 + # If markdown_content is empty, try to read from full_report.md markdown_content = data.get('markdown_content', '') if not markdown_content: full_report_path = cls._get_report_markdown_path(report_id) @@ -2498,17 +2500,17 @@ class ReportManager: @classmethod def get_report_by_simulation(cls, simulation_id: str) -> Optional[Report]: - """根据模拟ID获取报告""" + """Get a report by simulation ID""" cls._ensure_reports_dir() for item in os.listdir(cls.REPORTS_DIR): item_path = os.path.join(cls.REPORTS_DIR, item) - # 新格式:文件夹 + # New format: folder if os.path.isdir(item_path): report = cls.get_report(item) if report and report.simulation_id == simulation_id: return report - # 兼容旧格式:JSON文件 + # Backward compatibility: JSON file elif item.endswith('.json'): report_id = item[:-5] report = cls.get_report(report_id) @@ -2519,19 +2521,19 @@ class ReportManager: @classmethod def list_reports(cls, simulation_id: Optional[str] = None, limit: int = 50) -> List[Report]: - """列出报告""" + """List reports""" cls._ensure_reports_dir() reports = [] for item in os.listdir(cls.REPORTS_DIR): item_path = os.path.join(cls.REPORTS_DIR, item) - # 新格式:文件夹 + # New format: folder if os.path.isdir(item_path): report = cls.get_report(item) if report: if simulation_id is None or report.simulation_id == simulation_id: reports.append(report) - # 兼容旧格式:JSON文件 + # Backward compatibility: JSON file elif item.endswith('.json'): report_id = item[:-5] report = cls.get_report(report_id) @@ -2539,25 +2541,25 @@ class ReportManager: if simulation_id is None or report.simulation_id == simulation_id: reports.append(report) - # 按创建时间倒序 + # Sort by creation time in descending order reports.sort(key=lambda r: r.created_at, reverse=True) return reports[:limit] @classmethod def delete_report(cls, report_id: str) -> bool: - """删除报告(整个文件夹)""" + """Delete a report (the entire folder)""" import shutil folder_path = cls._get_report_folder(report_id) - # 新格式:删除整个文件夹 + # New format: delete the entire folder if os.path.exists(folder_path) and os.path.isdir(folder_path): shutil.rmtree(folder_path) logger.info(t('report.reportFolderDeleted', reportId=report_id)) return True - # 兼容旧格式:删除单独的文件 + # Backward compatibility: delete the individual file deleted = False old_json_path = os.path.join(cls.REPORTS_DIR, f"{report_id}.json") old_md_path = os.path.join(cls.REPORTS_DIR, f"{report_id}.md") diff --git a/backend/app/services/simulation_config_generator.py b/backend/app/services/simulation_config_generator.py index cb77f6b6..b4f1b791 100644 --- a/backend/app/services/simulation_config_generator.py +++ b/backend/app/services/simulation_config_generator.py @@ -1,13 +1,15 @@ """ -模拟配置智能生成器 -使用LLM根据模拟需求、文档内容、图谱信息自动生成细致的模拟参数 -实现全程自动化,无需人工设置参数 +Intelligent simulation configuration generator +Uses an LLM to automatically generate fine-grained simulation parameters from +the simulation requirement, document content, and graph information. +The whole flow is fully automated and requires no manual parameter tuning. -采用分步生成策略,避免一次性生成过长内容导致失败: -1. 生成时间配置 -2. 生成事件配置 -3. 分批生成Agent配置 -4. 生成平台配置 +A step-by-step generation strategy is used to avoid one-shot failures caused +by overly long outputs: +1. Generate the time configuration +2. Generate the event configuration +3. Generate Agent configurations in batches +4. Generate the platform configuration """ import json @@ -25,156 +27,156 @@ from .zep_entity_reader import EntityNode, ZepEntityReader logger = get_logger('mirofish.simulation_config') -# 中国作息时间配置(北京时间) +# Chinese daily-rhythm time configuration (Beijing time) CHINA_TIMEZONE_CONFIG = { - # 深夜时段(几乎无人活动) + # Late-night slot (almost no activity) "dead_hours": [0, 1, 2, 3, 4, 5], - # 早间时段(逐渐醒来) + # Early-morning slot (people gradually wake up) "morning_hours": [6, 7, 8], - # 工作时段 + # Work hours "work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18], - # 晚间高峰(最活跃) + # Evening peak (most active) "peak_hours": [19, 20, 21, 22], - # 夜间时段(活跃度下降) + # Night slot (activity drops off) "night_hours": [23], - # 活跃度系数 + # Activity multipliers "activity_multipliers": { - "dead": 0.05, # 凌晨几乎无人 - "morning": 0.4, # 早间逐渐活跃 - "work": 0.7, # 工作时段中等 - "peak": 1.5, # 晚间高峰 - "night": 0.5 # 深夜下降 + "dead": 0.05, # almost nobody active in the small hours + "morning": 0.4, # activity ramps up in the morning + "work": 0.7, # moderate activity during work hours + "peak": 1.5, # evening peak + "night": 0.5 # drops off late at night } } @dataclass class AgentActivityConfig: - """单个Agent的活动配置""" + """Activity configuration for a single Agent""" agent_id: int entity_uuid: str entity_name: str entity_type: str - # 活跃度配置 (0.0-1.0) - activity_level: float = 0.5 # 整体活跃度 + # Activity level (0.0-1.0) + activity_level: float = 0.5 # overall activity level - # 发言频率(每小时预期发言次数) + # Posting frequency (expected posts per hour) posts_per_hour: float = 1.0 comments_per_hour: float = 2.0 - # 活跃时间段(24小时制,0-23) + # Active hours (24-hour clock, 0-23) active_hours: List[int] = field(default_factory=lambda: list(range(8, 23))) - # 响应速度(对热点事件的反应延迟,单位:模拟分钟) + # Response speed (reaction delay to hot events, in simulated minutes) response_delay_min: int = 5 response_delay_max: int = 60 - # 情感倾向 (-1.0到1.0,负面到正面) + # Sentiment bias (-1.0 to 1.0, negative to positive) sentiment_bias: float = 0.0 - # 立场(对特定话题的态度) + # Stance (attitude toward a specific topic) stance: str = "neutral" # supportive, opposing, neutral, observer - # 影响力权重(决定其发言被其他Agent看到的概率) + # Influence weight (probability that this agent's posts are seen by other agents) influence_weight: float = 1.0 @dataclass class TimeSimulationConfig: - """时间模拟配置(基于中国人作息习惯)""" - # 模拟总时长(模拟小时数) - total_simulation_hours: int = 72 # 默认模拟72小时(3天) + """Time simulation configuration (based on a Chinese daily-rhythm pattern)""" + # Total simulation duration (simulated hours) + total_simulation_hours: int = 72 # default: 72 hours (3 days) - # 每轮代表的时间(模拟分钟)- 默认60分钟(1小时),加快时间流速 + # Simulated minutes per round (default 60 = 1 hour) to accelerate the time flow minutes_per_round: int = 60 - # 每小时激活的Agent数量范围 + # Range of agents activated per hour agents_per_hour_min: int = 5 agents_per_hour_max: int = 20 - # 高峰时段(晚间19-22点,中国人最活跃的时间) + # Peak hours (19-22 in the evening, the most active time for Chinese users) peak_hours: List[int] = field(default_factory=lambda: [19, 20, 21, 22]) peak_activity_multiplier: float = 1.5 - # 低谷时段(凌晨0-5点,几乎无人活动) + # Off-peak hours (0-5 in the small hours, almost nobody active) off_peak_hours: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 5]) - off_peak_activity_multiplier: float = 0.05 # 凌晨活跃度极低 + off_peak_activity_multiplier: float = 0.05 # very low activity in the small hours - # 早间时段 + # Morning hours morning_hours: List[int] = field(default_factory=lambda: [6, 7, 8]) morning_activity_multiplier: float = 0.4 - # 工作时段 + # Work hours work_hours: List[int] = field(default_factory=lambda: [9, 10, 11, 12, 13, 14, 15, 16, 17, 18]) work_activity_multiplier: float = 0.7 @dataclass class EventConfig: - """事件配置""" - # 初始事件(模拟开始时的触发事件) + """Event configuration""" + # Initial events (triggered when the simulation starts) initial_posts: List[Dict[str, Any]] = field(default_factory=list) - # 定时事件(在特定时间触发的事件) + # Scheduled events (triggered at specific times) scheduled_events: List[Dict[str, Any]] = field(default_factory=list) - # 热点话题关键词 + # Hot topic keywords hot_topics: List[str] = field(default_factory=list) - # 舆论引导方向 + # Narrative direction of public opinion narrative_direction: str = "" @dataclass class PlatformConfig: - """平台特定配置""" + """Platform-specific configuration""" platform: str # twitter or reddit - # 推荐算法权重 - recency_weight: float = 0.4 # 时间新鲜度 - popularity_weight: float = 0.3 # 热度 - relevance_weight: float = 0.3 # 相关性 + # Recommendation algorithm weights + recency_weight: float = 0.4 # freshness + popularity_weight: float = 0.3 # popularity + relevance_weight: float = 0.3 # relevance - # 病毒传播阈值(达到多少互动后触发扩散) + # Viral spread threshold (interaction count required to trigger virality) viral_threshold: int = 10 - # 回声室效应强度(相似观点聚集程度) + # Echo-chamber strength (how much similar opinions cluster together) echo_chamber_strength: float = 0.5 @dataclass class SimulationParameters: - """完整的模拟参数配置""" - # 基础信息 + """Full simulation parameter configuration""" + # Basic info simulation_id: str project_id: str graph_id: str simulation_requirement: str - # 时间配置 + # Time configuration time_config: TimeSimulationConfig = field(default_factory=TimeSimulationConfig) - # Agent配置列表 + # Agent configuration list agent_configs: List[AgentActivityConfig] = field(default_factory=list) - # 事件配置 + # Event configuration event_config: EventConfig = field(default_factory=EventConfig) - # 平台配置 + # Platform configuration twitter_config: Optional[PlatformConfig] = None reddit_config: Optional[PlatformConfig] = None - # LLM配置 + # LLM configuration llm_model: str = "" llm_base_url: str = "" - # 生成元数据 + # Generation metadata generated_at: str = field(default_factory=lambda: datetime.now().isoformat()) - generation_reasoning: str = "" # LLM的推理说明 + generation_reasoning: str = "" # LLM's reasoning notes def to_dict(self) -> Dict[str, Any]: - """转换为字典""" + """Convert to dictionary""" time_dict = asdict(self.time_config) return { "simulation_id": self.simulation_id, @@ -193,34 +195,35 @@ class SimulationParameters: } def to_json(self, indent: int = 2) -> str: - """转换为JSON字符串""" + """Convert to JSON string""" return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent) class SimulationConfigGenerator: """ - 模拟配置智能生成器 + Intelligent simulation configuration generator - 使用LLM分析模拟需求、文档内容、图谱实体信息, - 自动生成最佳的模拟参数配置 + Uses an LLM to analyze the simulation requirement, document content, and + graph entity information and automatically produce the best-fit simulation + parameter configuration. - 采用分步生成策略: - 1. 生成时间配置和事件配置(轻量级) - 2. 分批生成Agent配置(每批10-20个) - 3. 生成平台配置 + Step-by-step generation strategy: + 1. Generate time and event configuration (lightweight) + 2. Generate Agent configuration in batches (10-20 agents per batch) + 3. Generate platform configuration """ - # 上下文最大字符数 + # Maximum context length in characters MAX_CONTEXT_LENGTH = 50000 - # 每批生成的Agent数量 + # Number of Agents generated per batch AGENTS_PER_BATCH = 15 - # 各步骤的上下文截断长度(字符数) - TIME_CONFIG_CONTEXT_LENGTH = 10000 # 时间配置 - EVENT_CONFIG_CONTEXT_LENGTH = 8000 # 事件配置 - ENTITY_SUMMARY_LENGTH = 300 # 实体摘要 - AGENT_SUMMARY_LENGTH = 300 # Agent配置中的实体摘要 - ENTITIES_PER_TYPE_DISPLAY = 20 # 每类实体显示数量 + # Per-step context truncation length (in characters) + TIME_CONFIG_CONTEXT_LENGTH = 10000 # time configuration + EVENT_CONFIG_CONTEXT_LENGTH = 8000 # event configuration + ENTITY_SUMMARY_LENGTH = 300 # entity summary + AGENT_SUMMARY_LENGTH = 300 # entity summary inside the Agent config + ENTITIES_PER_TYPE_DISPLAY = 20 # how many entities to show per type def __init__( self, @@ -233,7 +236,7 @@ class SimulationConfigGenerator: self.model_name = model_name or Config.LLM_MODEL_NAME if not self.api_key: - raise ValueError("LLM_API_KEY 未配置") + raise ValueError("LLM_API_KEY is not configured") self.client = OpenAI( api_key=self.api_key, @@ -253,27 +256,27 @@ class SimulationConfigGenerator: progress_callback: Optional[Callable[[int, int, str], None]] = None, ) -> SimulationParameters: """ - 智能生成完整的模拟配置(分步生成) + Intelligently generate the full simulation configuration (step-by-step) Args: - simulation_id: 模拟ID - project_id: 项目ID - graph_id: 图谱ID - simulation_requirement: 模拟需求描述 - document_text: 原始文档内容 - entities: 过滤后的实体列表 - enable_twitter: 是否启用Twitter - enable_reddit: 是否启用Reddit - progress_callback: 进度回调函数(current_step, total_steps, message) + simulation_id: Simulation ID + project_id: Project ID + graph_id: Graph ID + simulation_requirement: Simulation requirement description + document_text: Original document content + entities: Filtered entity list + enable_twitter: Whether to enable Twitter + enable_reddit: Whether to enable Reddit + progress_callback: Progress callback function (current_step, total_steps, message) Returns: - SimulationParameters: 完整的模拟参数 + SimulationParameters: the full simulation parameters """ - logger.info(f"开始智能生成模拟配置: simulation_id={simulation_id}, 实体数={len(entities)}") + logger.info(f"Starting intelligent simulation config generation: simulation_id={simulation_id}, entity_count={len(entities)}") - # 计算总步骤数 + # Compute the total number of steps num_batches = math.ceil(len(entities) / self.AGENTS_PER_BATCH) - total_steps = 3 + num_batches # 时间配置 + 事件配置 + N批Agent + 平台配置 + total_steps = 3 + num_batches # time config + event config + N agent batches + platform config current_step = 0 def report_progress(step: int, message: str): @@ -283,7 +286,7 @@ class SimulationConfigGenerator: progress_callback(step, total_steps, message) logger.info(f"[{step}/{total_steps}] {message}") - # 1. 构建基础上下文信息 + # 1. Build the base context context = self._build_context( simulation_requirement=simulation_requirement, document_text=document_text, @@ -292,20 +295,20 @@ class SimulationConfigGenerator: reasoning_parts = [] - # ========== 步骤1: 生成时间配置 ========== + # ========== Step 1: generate time configuration ========== report_progress(1, t('progress.generatingTimeConfig')) num_entities = len(entities) time_config_result = self._generate_time_config(context, num_entities) time_config = self._parse_time_config(time_config_result, num_entities) reasoning_parts.append(f"{t('progress.timeConfigLabel')}: {time_config_result.get('reasoning', t('common.success'))}") - # ========== 步骤2: 生成事件配置 ========== + # ========== Step 2: generate event configuration ========== report_progress(2, t('progress.generatingEventConfig')) event_config_result = self._generate_event_config(context, simulation_requirement, entities) event_config = self._parse_event_config(event_config_result) reasoning_parts.append(f"{t('progress.eventConfigLabel')}: {event_config_result.get('reasoning', t('common.success'))}") - # ========== 步骤3-N: 分批生成Agent配置 ========== + # ========== Steps 3-N: generate Agent configuration in batches ========== all_agent_configs = [] for batch_idx in range(num_batches): start_idx = batch_idx * self.AGENTS_PER_BATCH @@ -327,13 +330,13 @@ class SimulationConfigGenerator: reasoning_parts.append(t('progress.agentConfigResult', count=len(all_agent_configs))) - # ========== 为初始帖子分配发布者 Agent ========== - logger.info("为初始帖子分配合适的发布者 Agent...") + # ========== Assign a publisher Agent to each initial post ========== + logger.info("Assigning suitable publisher Agents to initial posts...") event_config = self._assign_initial_post_agents(event_config, all_agent_configs) assigned_count = len([p for p in event_config.initial_posts if p.get("poster_agent_id") is not None]) reasoning_parts.append(t('progress.postAssignResult', count=assigned_count)) - # ========== 最后一步: 生成平台配置 ========== + # ========== Final step: generate platform configuration ========== report_progress(total_steps, t('progress.generatingPlatformConfig')) twitter_config = None reddit_config = None @@ -358,7 +361,7 @@ class SimulationConfigGenerator: echo_chamber_strength=0.6 ) - # 构建最终参数 + # Build the final parameters params = SimulationParameters( simulation_id=simulation_id, project_id=project_id, @@ -374,7 +377,7 @@ class SimulationConfigGenerator: generation_reasoning=" | ".join(reasoning_parts) ) - logger.info(f"模拟配置生成完成: {len(params.agent_configs)} 个Agent配置") + logger.info(f"Simulation config generation complete: {len(params.agent_configs)} agent configs") return params @@ -384,33 +387,33 @@ class SimulationConfigGenerator: document_text: str, entities: List[EntityNode] ) -> str: - """构建LLM上下文,截断到最大长度""" + """Build the LLM context, truncating to the maximum length""" - # 实体摘要 + # Entity summary entity_summary = self._summarize_entities(entities) - # 构建上下文 + # Build the context context_parts = [ - f"## 模拟需求\n{simulation_requirement}", - f"\n## 实体信息 ({len(entities)}个)\n{entity_summary}", + f"## Simulation requirement\n{simulation_requirement}", + f"\n## Entity information ({len(entities)} in total)\n{entity_summary}", ] current_length = sum(len(p) for p in context_parts) - remaining_length = self.MAX_CONTEXT_LENGTH - current_length - 500 # 留500字符余量 + remaining_length = self.MAX_CONTEXT_LENGTH - current_length - 500 # leave 500 chars of headroom if remaining_length > 0 and document_text: doc_text = document_text[:remaining_length] if len(document_text) > remaining_length: - doc_text += "\n...(文档已截断)" - context_parts.append(f"\n## 原始文档内容\n{doc_text}") + doc_text += "\n...(document truncated)" + context_parts.append(f"\n## Original document content\n{doc_text}") return "\n".join(context_parts) def _summarize_entities(self, entities: List[EntityNode]) -> str: - """生成实体摘要""" + """Build the entity summary""" lines = [] - # 按类型分组 + # Group by type by_type: Dict[str, List[EntityNode]] = {} for e in entities: t = e.get_entity_type() or "Unknown" @@ -419,20 +422,20 @@ class SimulationConfigGenerator: by_type[t].append(e) for entity_type, type_entities in by_type.items(): - lines.append(f"\n### {entity_type} ({len(type_entities)}个)") - # 使用配置的显示数量和摘要长度 + lines.append(f"\n### {entity_type} ({len(type_entities)} in total)") + # Use the configured display count and summary length display_count = self.ENTITIES_PER_TYPE_DISPLAY summary_len = self.ENTITY_SUMMARY_LENGTH for e in type_entities[:display_count]: summary_preview = (e.summary[:summary_len] + "...") if len(e.summary) > summary_len else e.summary lines.append(f"- {e.name}: {summary_preview}") if len(type_entities) > display_count: - lines.append(f" ... 还有 {len(type_entities) - display_count} 个") + lines.append(f" ... and {len(type_entities) - display_count} more") return "\n".join(lines) def _call_llm_with_retry(self, prompt: str, system_prompt: str) -> Dict[str, Any]: - """带重试的LLM调用,包含JSON修复逻辑""" + """LLM call with retries, including JSON repair logic""" import re max_attempts = 3 @@ -447,25 +450,25 @@ class SimulationConfigGenerator: {"role": "user", "content": prompt} ], response_format={"type": "json_object"}, - temperature=0.7 - (attempt * 0.1) # 每次重试降低温度 - # 不设置max_tokens,让LLM自由发挥 + temperature=0.7 - (attempt * 0.1) # lower the temperature on every retry + # Do not set max_tokens; let the LLM speak freely ) content = response.choices[0].message.content finish_reason = response.choices[0].finish_reason - # 检查是否被截断 + # Check whether the output was truncated if finish_reason == 'length': - logger.warning(f"LLM输出被截断 (attempt {attempt+1})") + logger.warning(f"LLM output truncated (attempt {attempt+1})") content = self._fix_truncated_json(content) - # 尝试解析JSON + # Try to parse the JSON try: return json.loads(content) except json.JSONDecodeError as e: - logger.warning(f"JSON解析失败 (attempt {attempt+1}): {str(e)[:80]}") + logger.warning(f"JSON parse failed (attempt {attempt+1}): {str(e)[:80]}") - # 尝试修复JSON + # Try to repair the JSON fixed = self._try_fix_config_json(content) if fixed: return fixed @@ -473,44 +476,44 @@ class SimulationConfigGenerator: last_error = e except Exception as e: - logger.warning(f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}") + logger.warning(f"LLM call failed (attempt {attempt+1}): {str(e)[:80]}") last_error = e import time time.sleep(2 * (attempt + 1)) - raise last_error or Exception("LLM调用失败") + raise last_error or Exception("LLM call failed") def _fix_truncated_json(self, content: str) -> str: - """修复被截断的JSON""" + """Repair truncated JSON""" content = content.strip() - # 计算未闭合的括号 + # Count unclosed braces/brackets open_braces = content.count('{') - content.count('}') open_brackets = content.count('[') - content.count(']') - # 检查是否有未闭合的字符串 + # Check whether there is an unclosed string at the end if content and content[-1] not in '",}]': content += '"' - # 闭合括号 + # Close the brackets content += ']' * open_brackets content += '}' * open_braces return content def _try_fix_config_json(self, content: str) -> Optional[Dict[str, Any]]: - """尝试修复配置JSON""" + """Attempt to repair the configuration JSON""" import re - # 修复被截断的情况 + # Repair the truncated case content = self._fix_truncated_json(content) - # 提取JSON部分 + # Extract the JSON portion json_match = re.search(r'\{[\s\S]*\}', content) if json_match: json_str = json_match.group() - # 移除字符串中的换行符 + # Remove newlines from inside strings def fix_string(match): s = match.group(0) s = s.replace('\n', ' ').replace('\r', ' ') @@ -522,7 +525,7 @@ class SimulationConfigGenerator: try: return json.loads(json_str) except: - # 尝试移除所有控制字符 + # Try removing all control characters json_str = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', json_str) json_str = re.sub(r'\s+', ' ', json_str) try: @@ -533,35 +536,35 @@ class SimulationConfigGenerator: return None def _generate_time_config(self, context: str, num_entities: int) -> Dict[str, Any]: - """生成时间配置""" - # 使用配置的上下文截断长度 + """Generate the time configuration""" + # Use the configured context truncation length context_truncated = context[:self.TIME_CONFIG_CONTEXT_LENGTH] - # 计算最大允许值(80%的agent数) + # Compute the maximum allowed value (80% of agent count) max_agents_allowed = max(1, int(num_entities * 0.9)) - prompt = f"""基于以下模拟需求,生成时间模拟配置。 + prompt = f"""Based on the following simulation requirement, generate the time simulation configuration. {context_truncated} -## 任务 -请生成时间配置JSON。 +## Task +Please generate the time configuration JSON. -### 基本原则(仅供参考,需根据具体事件和参与群体灵活调整): -- 请根据模拟场景推断目标用户群体所在时区和作息习惯,以下为东八区(UTC+8)的参考示例 -- 凌晨0-5点几乎无人活动(活跃度系数0.05) -- 早上6-8点逐渐活跃(活跃度系数0.4) -- 工作时间9-18点中等活跃(活跃度系数0.7) -- 晚间19-22点是高峰期(活跃度系数1.5) -- 23点后活跃度下降(活跃度系数0.5) -- 一般规律:凌晨低活跃、早间渐增、工作时段中等、晚间高峰 -- **重要**:以下示例值仅供参考,你需要根据事件性质、参与群体特点来调整具体时段 - - 例如:学生群体高峰可能是21-23点;媒体全天活跃;官方机构只在工作时间 - - 例如:突发热点可能导致深夜也有讨论,off_peak_hours 可适当缩短 +### Basic principles (for reference only, adjust flexibly based on the specific event and participant groups): +- Infer the time zone and daily-rhythm of the target user group from the simulation scenario; the following are reference examples for UTC+8 (East 8 zone) +- 0-5 AM: almost no activity (activity multiplier 0.05) +- 6-8 AM: gradually active (activity multiplier 0.4) +- 9 AM-6 PM work hours: moderate activity (activity multiplier 0.7) +- 7-10 PM: peak hours (activity multiplier 1.5) +- After 11 PM: activity drops (activity multiplier 0.5) +- General pattern: low activity in the small hours, rising in the morning, moderate during work hours, evening peak +- **IMPORTANT**: The example values below are for reference only; you need to adjust the specific time slots based on the event nature and participant-group characteristics + - For example: students may peak at 9-11 PM; media is active all day; official agencies only during work hours + - For example: breaking hot topics may cause discussion even in the small hours, off_peak_hours can be appropriately shortened -### 返回JSON格式(不要markdown) +### Return JSON format (no markdown) -示例: +Example: {{ "total_simulation_hours": 72, "minutes_per_round": 60, @@ -571,71 +574,71 @@ class SimulationConfigGenerator: "off_peak_hours": [0, 1, 2, 3, 4, 5], "morning_hours": [6, 7, 8], "work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18], - "reasoning": "针对该事件的时间配置说明" + "reasoning": "Time configuration explanation for this event" }} -字段说明: -- total_simulation_hours (int): 模拟总时长,24-168小时,突发事件短、持续话题长 -- minutes_per_round (int): 每轮时长,30-120分钟,建议60分钟 -- agents_per_hour_min (int): 每小时最少激活Agent数(取值范围: 1-{max_agents_allowed}) -- agents_per_hour_max (int): 每小时最多激活Agent数(取值范围: 1-{max_agents_allowed}) -- peak_hours (int数组): 高峰时段,根据事件参与群体调整 -- off_peak_hours (int数组): 低谷时段,通常深夜凌晨 -- morning_hours (int数组): 早间时段 -- work_hours (int数组): 工作时段 -- reasoning (string): 简要说明为什么这样配置""" +Field description: +- total_simulation_hours (int): total simulation duration, 24-168 hours, shorter for breaking events, longer for sustained topics +- minutes_per_round (int): duration per round, 30-120 minutes, 60 minutes recommended +- agents_per_hour_min (int): minimum agents activated per hour (range: 1-{max_agents_allowed}) +- agents_per_hour_max (int): maximum agents activated per hour (range: 1-{max_agents_allowed}) +- peak_hours (int array): peak time slots, adjusted based on the event's participant groups +- off_peak_hours (int array): off-peak time slots, usually late night and small hours +- morning_hours (int array): morning time slots +- work_hours (int array): work time slots +- reasoning (string): brief explanation of why this configuration""" - system_prompt = "你是社交媒体模拟专家。返回纯JSON格式,时间配置需符合模拟场景中目标用户群体的作息习惯。" + system_prompt = "You are a social-media simulation expert. Return pure JSON format; the time configuration must match the daily-rhythm of the target user group in the simulation scenario." system_prompt = f"{system_prompt}\n\n{get_language_instruction()}" try: return self._call_llm_with_retry(prompt, system_prompt) except Exception as e: - logger.warning(f"时间配置LLM生成失败: {e}, 使用默认配置") + logger.warning(f"Time configuration LLM generation failed: {e}, using default configuration") return self._get_default_time_config(num_entities) def _get_default_time_config(self, num_entities: int) -> Dict[str, Any]: - """获取默认时间配置(中国人作息)""" + """Get default time configuration (Chinese daily-rhythm)""" return { "total_simulation_hours": 72, - "minutes_per_round": 60, # 每轮1小时,加快时间流速 + "minutes_per_round": 60, # 1 hour per round, to speed up time flow "agents_per_hour_min": max(1, num_entities // 15), "agents_per_hour_max": max(5, num_entities // 5), "peak_hours": [19, 20, 21, 22], "off_peak_hours": [0, 1, 2, 3, 4, 5], "morning_hours": [6, 7, 8], "work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18], - "reasoning": "使用默认中国人作息配置(每轮1小时)" + "reasoning": "Using default Chinese daily-rhythm configuration (1 hour per round)" } def _parse_time_config(self, result: Dict[str, Any], num_entities: int) -> TimeSimulationConfig: - """解析时间配置结果,并验证agents_per_hour值不超过总agent数""" - # 获取原始值 + """Parse time configuration result and verify that agents_per_hour values do not exceed total agent count""" + # Get original values agents_per_hour_min = result.get("agents_per_hour_min", max(1, num_entities // 15)) agents_per_hour_max = result.get("agents_per_hour_max", max(5, num_entities // 5)) - # 验证并修正:确保不超过总agent数 + # Validate and fix: ensure not exceeding total agent count if agents_per_hour_min > num_entities: - logger.warning(f"agents_per_hour_min ({agents_per_hour_min}) 超过总Agent数 ({num_entities}),已修正") + logger.warning(f"agents_per_hour_min ({agents_per_hour_min}) exceeds total agent count ({num_entities}), corrected") agents_per_hour_min = max(1, num_entities // 10) if agents_per_hour_max > num_entities: - logger.warning(f"agents_per_hour_max ({agents_per_hour_max}) 超过总Agent数 ({num_entities}),已修正") + logger.warning(f"agents_per_hour_max ({agents_per_hour_max}) exceeds total agent count ({num_entities}), corrected") agents_per_hour_max = max(agents_per_hour_min + 1, num_entities // 2) - # 确保 min < max + # Ensure min < max if agents_per_hour_min >= agents_per_hour_max: agents_per_hour_min = max(1, agents_per_hour_max // 2) - logger.warning(f"agents_per_hour_min >= max,已修正为 {agents_per_hour_min}") + logger.warning(f"agents_per_hour_min >= max, corrected to {agents_per_hour_min}") return TimeSimulationConfig( total_simulation_hours=result.get("total_simulation_hours", 72), - minutes_per_round=result.get("minutes_per_round", 60), # 默认每轮1小时 + minutes_per_round=result.get("minutes_per_round", 60), # default 1 hour per round agents_per_hour_min=agents_per_hour_min, agents_per_hour_max=agents_per_hour_max, peak_hours=result.get("peak_hours", [19, 20, 21, 22]), off_peak_hours=result.get("off_peak_hours", [0, 1, 2, 3, 4, 5]), - off_peak_activity_multiplier=0.05, # 凌晨几乎无人 + off_peak_activity_multiplier=0.05, # almost nobody active in the small hours morning_hours=result.get("morning_hours", [6, 7, 8]), morning_activity_multiplier=0.4, work_hours=result.get("work_hours", list(range(9, 19))), @@ -649,14 +652,14 @@ class SimulationConfigGenerator: simulation_requirement: str, entities: List[EntityNode] ) -> Dict[str, Any]: - """生成事件配置""" + """Generate event configuration""" - # 获取可用的实体类型列表,供 LLM 参考 + # Get the list of available entity types for LLM reference entity_types_available = list(set( e.get_entity_type() or "Unknown" for e in entities )) - # 为每种类型列出代表性实体名称 + # List representative entity names for each type type_examples = {} for e in entities: etype = e.get_entity_type() or "Unknown" @@ -670,54 +673,54 @@ class SimulationConfigGenerator: for t, examples in type_examples.items() ]) - # 使用配置的上下文截断长度 + # Use the configured context truncation length context_truncated = context[:self.EVENT_CONFIG_CONTEXT_LENGTH] - prompt = f"""基于以下模拟需求,生成事件配置。 + prompt = f"""Based on the following simulation requirement, generate the event configuration. -模拟需求: {simulation_requirement} +Simulation requirement: {simulation_requirement} {context_truncated} -## 可用实体类型及示例 +## Available entity types and examples {type_info} -## 任务 -请生成事件配置JSON: -- 提取热点话题关键词 -- 描述舆论发展方向 -- 设计初始帖子内容,**每个帖子必须指定 poster_type(发布者类型)** +## Task +Please generate the event configuration JSON: +- Extract hot-topic keywords +- Describe the direction of public-opinion development +- Design initial post content; **each post MUST specify poster_type (publisher type)** -**重要**: poster_type 必须从上面的"可用实体类型"中选择,这样初始帖子才能分配给合适的 Agent 发布。 -例如:官方声明应由 Official/University 类型发布,新闻由 MediaOutlet 发布,学生观点由 Student 发布。 +**IMPORTANT**: poster_type MUST be chosen from the "available entity types" above, so the initial posts can be assigned to appropriate Agents for publishing. +For example: official statements should be published by Official/University types, news by MediaOutlet, student views by Student. -返回JSON格式(不要markdown): +Return JSON format (no markdown): {{ - "hot_topics": ["关键词1", "关键词2", ...], - "narrative_direction": "<舆论发展方向描述>", + "hot_topics": ["keyword 1", "keyword 2", ...], + "narrative_direction": "", "initial_posts": [ - {{"content": "帖子内容", "poster_type": "实体类型(必须从可用类型中选择)"}}, + {{"content": "post content", "poster_type": "entity type (MUST be selected from available types)"}}, ... ], - "reasoning": "<简要说明>" + "reasoning": "" }}""" - system_prompt = "你是舆论分析专家。返回纯JSON格式。注意 poster_type 必须精确匹配可用实体类型。" + system_prompt = "You are a public-opinion analysis expert. Return pure JSON format. Note that poster_type must exactly match the available entity types." system_prompt = f"{system_prompt}\n\n{get_language_instruction()}\nIMPORTANT: The 'poster_type' field value MUST be in English PascalCase exactly matching the available entity types. Only 'content', 'narrative_direction', 'hot_topics' and 'reasoning' fields should use the specified language." try: return self._call_llm_with_retry(prompt, system_prompt) except Exception as e: - logger.warning(f"事件配置LLM生成失败: {e}, 使用默认配置") + logger.warning(f"Event configuration LLM generation failed: {e}, using default configuration") return { "hot_topics": [], "narrative_direction": "", "initial_posts": [], - "reasoning": "使用默认配置" + "reasoning": "Using default configuration" } def _parse_event_config(self, result: Dict[str, Any]) -> EventConfig: - """解析事件配置结果""" + """Parse event configuration result""" return EventConfig( initial_posts=result.get("initial_posts", []), scheduled_events=[], @@ -731,14 +734,14 @@ class SimulationConfigGenerator: agent_configs: List[AgentActivityConfig] ) -> EventConfig: """ - 为初始帖子分配合适的发布者 Agent + Assign suitable publisher Agents to initial posts - 根据每个帖子的 poster_type 匹配最合适的 agent_id + Match the most suitable agent_id based on each post's poster_type """ if not event_config.initial_posts: return event_config - # 按实体类型建立 agent 索引 + # Build an agent index by entity type agents_by_type: Dict[str, List[AgentActivityConfig]] = {} for agent in agent_configs: etype = agent.entity_type.lower() @@ -746,7 +749,7 @@ class SimulationConfigGenerator: agents_by_type[etype] = [] agents_by_type[etype].append(agent) - # 类型映射表(处理 LLM 可能输出的不同格式) + # Type alias table (handle different formats that LLM may output) type_aliases = { "official": ["official", "university", "governmentagency", "government"], "university": ["university", "official"], @@ -758,7 +761,7 @@ class SimulationConfigGenerator: "person": ["person", "student", "alumni"], } - # 记录每种类型已使用的 agent 索引,避免重复使用同一个 agent + # Record the agent index already used for each type to avoid reusing the same agent used_indices: Dict[str, int] = {} updated_posts = [] @@ -766,17 +769,17 @@ class SimulationConfigGenerator: poster_type = post.get("poster_type", "").lower() content = post.get("content", "") - # 尝试找到匹配的 agent + # Try to find a matching agent matched_agent_id = None - # 1. 直接匹配 + # 1. Direct match if poster_type in agents_by_type: agents = agents_by_type[poster_type] idx = used_indices.get(poster_type, 0) % len(agents) matched_agent_id = agents[idx].agent_id used_indices[poster_type] = idx + 1 else: - # 2. 使用别名匹配 + # 2. Use alias matching for alias_key, aliases in type_aliases.items(): if poster_type in aliases or alias_key == poster_type: for alias in aliases: @@ -789,11 +792,11 @@ class SimulationConfigGenerator: if matched_agent_id is not None: break - # 3. 如果仍未找到,使用影响力最高的 agent + # 3. If still not found, use the agent with the highest influence if matched_agent_id is None: - logger.warning(f"未找到类型 '{poster_type}' 的匹配 Agent,使用影响力最高的 Agent") + logger.warning(f"No matching Agent found for type '{poster_type}', using the agent with the highest influence") if agent_configs: - # 按影响力排序,选择影响力最高的 + # Sort by influence and select the one with the highest influence sorted_agents = sorted(agent_configs, key=lambda a: a.influence_weight, reverse=True) matched_agent_id = sorted_agents[0].agent_id else: @@ -805,7 +808,7 @@ class SimulationConfigGenerator: "poster_agent_id": matched_agent_id }) - logger.info(f"初始帖子分配: poster_type='{poster_type}' -> agent_id={matched_agent_id}") + logger.info(f"Initial post assignment: poster_type='{poster_type}' -> agent_id={matched_agent_id}") event_config.initial_posts = updated_posts return event_config @@ -817,9 +820,9 @@ class SimulationConfigGenerator: start_idx: int, simulation_requirement: str ) -> List[AgentActivityConfig]: - """分批生成Agent配置""" + """Generate Agent configurations in batches""" - # 构建实体信息(使用配置的摘要长度) + # Build entity information (using the configured summary length) entity_list = [] summary_len = self.AGENT_SUMMARY_LENGTH for i, e in enumerate(entities): @@ -830,59 +833,59 @@ class SimulationConfigGenerator: "summary": e.summary[:summary_len] if e.summary else "" }) - prompt = f"""基于以下信息,为每个实体生成社交媒体活动配置。 + prompt = f"""Based on the following information, generate social-media activity configuration for each entity. -模拟需求: {simulation_requirement} +Simulation requirement: {simulation_requirement} -## 实体列表 +## Entity list ```json {json.dumps(entity_list, ensure_ascii=False, indent=2)} ``` -## 任务 -为每个实体生成活动配置,注意: -- **时间符合目标用户群体作息**:以下为参考(东八区),请根据模拟场景调整 -- **官方机构**(University/GovernmentAgency):活跃度低(0.1-0.3),工作时间(9-17)活动,响应慢(60-240分钟),影响力高(2.5-3.0) -- **媒体**(MediaOutlet):活跃度中(0.4-0.6),全天活动(8-23),响应快(5-30分钟),影响力高(2.0-2.5) -- **个人**(Student/Person/Alumni):活跃度高(0.6-0.9),主要晚间活动(18-23),响应快(1-15分钟),影响力低(0.8-1.2) -- **公众人物/专家**:活跃度中(0.4-0.6),影响力中高(1.5-2.0) +## Task +Generate activity configuration for each entity. Note: +- **Time must match the daily-rhythm of the target user group**: the following are for reference (UTC+8), adjust based on the simulation scenario +- **Official agencies** (University/GovernmentAgency): low activity (0.1-0.3), active during work hours (9-17), slow response (60-240 minutes), high influence (2.5-3.0) +- **Media** (MediaOutlet): medium activity (0.4-0.6), active all day (8-23), fast response (5-30 minutes), high influence (2.0-2.5) +- **Individuals** (Student/Person/Alumni): high activity (0.6-0.9), mainly active in the evening (18-23), fast response (1-15 minutes), low influence (0.8-1.2) +- **Public figures/Experts**: medium activity (0.4-0.6), medium-high influence (1.5-2.0) -返回JSON格式(不要markdown): +Return JSON format (no markdown): {{ "agent_configs": [ {{ - "agent_id": <必须与输入一致>, + "agent_id": , "activity_level": <0.0-1.0>, - "posts_per_hour": <发帖频率>, - "comments_per_hour": <评论频率>, - "active_hours": [<活跃小时列表,考虑中国人作息>], - "response_delay_min": <最小响应延迟分钟>, - "response_delay_max": <最大响应延迟分钟>, - "sentiment_bias": <-1.0到1.0>, + "posts_per_hour": , + "comments_per_hour": , + "active_hours": [], + "response_delay_min": , + "response_delay_max": , + "sentiment_bias": <-1.0 to 1.0>, "stance": "", - "influence_weight": <影响力权重> + "influence_weight": }}, ... ] }}""" - system_prompt = "你是社交媒体行为分析专家。返回纯JSON,配置需符合模拟场景中目标用户群体的作息习惯。" + system_prompt = "You are a social-media behavior analysis expert. Return pure JSON; the configuration must match the daily-rhythm of the target user group in the simulation scenario." system_prompt = f"{system_prompt}\n\n{get_language_instruction()}\nIMPORTANT: The 'stance' field value MUST be one of the English strings: 'supportive', 'opposing', 'neutral', 'observer'. All JSON field names and numeric values must remain unchanged. Only natural language text fields should use the specified language." try: result = self._call_llm_with_retry(prompt, system_prompt) llm_configs = {cfg["agent_id"]: cfg for cfg in result.get("agent_configs", [])} except Exception as e: - logger.warning(f"Agent配置批次LLM生成失败: {e}, 使用规则生成") + logger.warning(f"Agent configuration batch LLM generation failed: {e}, using rule-based generation") llm_configs = {} - # 构建AgentActivityConfig对象 + # Build AgentActivityConfig object configs = [] for i, entity in enumerate(entities): agent_id = start_idx + i cfg = llm_configs.get(agent_id, {}) - # 如果LLM没有生成,使用规则生成 + # If LLM did not generate, use rule-based generation if not cfg: cfg = self._generate_agent_config_by_rule(entity) @@ -906,11 +909,11 @@ class SimulationConfigGenerator: return configs def _generate_agent_config_by_rule(self, entity: EntityNode) -> Dict[str, Any]: - """基于规则生成单个Agent配置(中国人作息)""" + """Generate a single Agent configuration based on rules (Chinese daily-rhythm)""" entity_type = (entity.get_entity_type() or "Unknown").lower() if entity_type in ["university", "governmentagency", "ngo"]: - # 官方机构:工作时间活动,低频率,高影响力 + # Official agency: active during work hours, low frequency, high influence return { "activity_level": 0.2, "posts_per_hour": 0.1, @@ -923,7 +926,7 @@ class SimulationConfigGenerator: "influence_weight": 3.0 } elif entity_type in ["mediaoutlet"]: - # 媒体:全天活动,中等频率,高影响力 + # Media: active all day, medium frequency, high influence return { "activity_level": 0.5, "posts_per_hour": 0.8, @@ -936,7 +939,7 @@ class SimulationConfigGenerator: "influence_weight": 2.5 } elif entity_type in ["professor", "expert", "official"]: - # 专家/教授:工作+晚间活动,中等频率 + # Expert/Professor: work + evening activity, medium frequency return { "activity_level": 0.4, "posts_per_hour": 0.3, @@ -949,12 +952,12 @@ class SimulationConfigGenerator: "influence_weight": 2.0 } elif entity_type in ["student"]: - # 学生:晚间为主,高频率 + # Student: mainly evening, high frequency return { "activity_level": 0.8, "posts_per_hour": 0.6, "comments_per_hour": 1.5, - "active_hours": [8, 9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23], # 上午+晚间 + "active_hours": [8, 9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23], # morning + evening "response_delay_min": 1, "response_delay_max": 15, "sentiment_bias": 0.0, @@ -962,12 +965,12 @@ class SimulationConfigGenerator: "influence_weight": 0.8 } elif entity_type in ["alumni"]: - # 校友:晚间为主 + # Alumni: mainly evening return { "activity_level": 0.6, "posts_per_hour": 0.4, "comments_per_hour": 0.8, - "active_hours": [12, 13, 19, 20, 21, 22, 23], # 午休+晚间 + "active_hours": [12, 13, 19, 20, 21, 22, 23], # lunch break + evening "response_delay_min": 5, "response_delay_max": 30, "sentiment_bias": 0.0, @@ -975,12 +978,12 @@ class SimulationConfigGenerator: "influence_weight": 1.0 } else: - # 普通人:晚间高峰 + # Ordinary person: evening peak return { "activity_level": 0.7, "posts_per_hour": 0.5, "comments_per_hour": 1.2, - "active_hours": [9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23], # 白天+晚间 + "active_hours": [9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23], # daytime + evening "response_delay_min": 2, "response_delay_max": 20, "sentiment_bias": 0.0, diff --git a/backend/app/services/simulation_ipc.py b/backend/app/services/simulation_ipc.py index 9d70d0be..49fe9aa2 100644 --- a/backend/app/services/simulation_ipc.py +++ b/backend/app/services/simulation_ipc.py @@ -1,11 +1,13 @@ """ -模拟IPC通信模块 -用于Flask后端和模拟脚本之间的进程间通信 +Simulation IPC communication module +Used for inter-process communication between the Flask backend +and the simulation script. -通过文件系统实现简单的命令/响应模式: -1. Flask写入命令到 commands/ 目录 -2. 模拟脚本轮询命令目录,执行命令并写入响应到 responses/ 目录 -3. Flask轮询响应目录获取结果 +Implements a simple command/response pattern via the filesystem: +1. Flask writes commands into the commands/ directory +2. The simulation script polls the commands directory, executes the + commands, and writes responses into the responses/ directory +3. Flask polls the responses directory to fetch results """ import os @@ -23,14 +25,14 @@ logger = get_logger('mirofish.simulation_ipc') class CommandType(str, Enum): - """命令类型""" - INTERVIEW = "interview" # 单个Agent采访 - BATCH_INTERVIEW = "batch_interview" # 批量采访 - CLOSE_ENV = "close_env" # 关闭环境 + """Command type""" + INTERVIEW = "interview" # Single-agent interview + BATCH_INTERVIEW = "batch_interview" # Batch interview + CLOSE_ENV = "close_env" # Close environment class CommandStatus(str, Enum): - """命令状态""" + """Command status""" PENDING = "pending" PROCESSING = "processing" COMPLETED = "completed" @@ -39,12 +41,12 @@ class CommandStatus(str, Enum): @dataclass class IPCCommand: - """IPC命令""" + """IPC command""" command_id: str command_type: CommandType args: Dict[str, Any] timestamp: str = field(default_factory=lambda: datetime.now().isoformat()) - + def to_dict(self) -> Dict[str, Any]: return { "command_id": self.command_id, @@ -52,7 +54,7 @@ class IPCCommand: "args": self.args, "timestamp": self.timestamp } - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'IPCCommand': return cls( @@ -65,13 +67,13 @@ class IPCCommand: @dataclass class IPCResponse: - """IPC响应""" + """IPC response""" command_id: str status: CommandStatus result: Optional[Dict[str, Any]] = None error: Optional[str] = None timestamp: str = field(default_factory=lambda: datetime.now().isoformat()) - + def to_dict(self) -> Dict[str, Any]: return { "command_id": self.command_id, @@ -80,7 +82,7 @@ class IPCResponse: "error": self.error, "timestamp": self.timestamp } - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'IPCResponse': return cls( @@ -94,26 +96,26 @@ class IPCResponse: class SimulationIPCClient: """ - 模拟IPC客户端(Flask端使用) - - 用于向模拟进程发送命令并等待响应 + Simulation IPC client (used on the Flask side) + + Sends commands to the simulation process and waits for responses. """ - + def __init__(self, simulation_dir: str): """ - 初始化IPC客户端 - + Initialize the IPC client. + Args: - simulation_dir: 模拟数据目录 + simulation_dir: Simulation data directory """ self.simulation_dir = simulation_dir self.commands_dir = os.path.join(simulation_dir, "ipc_commands") self.responses_dir = os.path.join(simulation_dir, "ipc_responses") - - # 确保目录存在 + + # Ensure directories exist os.makedirs(self.commands_dir, exist_ok=True) os.makedirs(self.responses_dir, exist_ok=True) - + def send_command( self, command_type: CommandType, @@ -122,19 +124,19 @@ class SimulationIPCClient: poll_interval: float = 0.5 ) -> IPCResponse: """ - 发送命令并等待响应 - + Send a command and wait for a response. + Args: - command_type: 命令类型 - args: 命令参数 - timeout: 超时时间(秒) - poll_interval: 轮询间隔(秒) - + command_type: Command type + args: Command arguments + timeout: Timeout in seconds + poll_interval: Poll interval in seconds + Returns: IPCResponse - + Raises: - TimeoutError: 等待响应超时 + TimeoutError: Timed out waiting for a response """ command_id = str(uuid.uuid4()) command = IPCCommand( @@ -142,50 +144,50 @@ class SimulationIPCClient: command_type=command_type, args=args ) - - # 写入命令文件 + + # Write the command file command_file = os.path.join(self.commands_dir, f"{command_id}.json") with open(command_file, 'w', encoding='utf-8') as f: json.dump(command.to_dict(), f, ensure_ascii=False, indent=2) - - logger.info(f"发送IPC命令: {command_type.value}, command_id={command_id}") - - # 等待响应 + + logger.info(f"Sent IPC command: {command_type.value}, command_id={command_id}") + + # Wait for the response response_file = os.path.join(self.responses_dir, f"{command_id}.json") start_time = time.time() - + while time.time() - start_time < timeout: if os.path.exists(response_file): try: with open(response_file, 'r', encoding='utf-8') as f: response_data = json.load(f) response = IPCResponse.from_dict(response_data) - - # 清理命令和响应文件 + + # Clean up command and response files try: os.remove(command_file) os.remove(response_file) except OSError: pass - - logger.info(f"收到IPC响应: command_id={command_id}, status={response.status.value}") + + logger.info(f"Received IPC response: command_id={command_id}, status={response.status.value}") return response except (json.JSONDecodeError, KeyError) as e: - logger.warning(f"解析响应失败: {e}") - + logger.warning(f"Failed to parse response: {e}") + time.sleep(poll_interval) - - # 超时 - logger.error(f"等待IPC响应超时: command_id={command_id}") - - # 清理命令文件 + + # Timeout + logger.error(f"Timeout waiting for IPC response: command_id={command_id}") + + # Clean up the command file try: os.remove(command_file) except OSError: pass - - raise TimeoutError(f"等待命令响应超时 ({timeout}秒)") - + + raise TimeoutError(f"Timed out waiting for command response ({timeout}s)") + def send_interview( self, agent_id: int, @@ -194,19 +196,21 @@ class SimulationIPCClient: timeout: float = 60.0 ) -> IPCResponse: """ - 发送单个Agent采访命令 - + Send a single-Agent interview command. + Args: agent_id: Agent ID - prompt: 采访问题 - platform: 指定平台(可选) - - "twitter": 只采访Twitter平台 - - "reddit": 只采访Reddit平台 - - None: 双平台模拟时同时采访两个平台,单平台模拟时采访该平台 - timeout: 超时时间 - + prompt: Interview question + platform: Specific platform (optional) + - "twitter": only interview the Twitter platform + - "reddit": only interview the Reddit platform + - None: in a dual-platform simulation interview both + platforms; in a single-platform simulation interview + that platform + timeout: Timeout + Returns: - IPCResponse,result字段包含采访结果 + IPCResponse; the result field contains the interview result """ args = { "agent_id": agent_id, @@ -214,13 +218,13 @@ class SimulationIPCClient: } if platform: args["platform"] = platform - + return self.send_command( command_type=CommandType.INTERVIEW, args=args, timeout=timeout ) - + def send_batch_interview( self, interviews: List[Dict[str, Any]], @@ -228,36 +232,39 @@ class SimulationIPCClient: timeout: float = 120.0 ) -> IPCResponse: """ - 发送批量采访命令 - + Send a batch interview command. + Args: - interviews: 采访列表,每个元素包含 {"agent_id": int, "prompt": str, "platform": str(可选)} - platform: 默认平台(可选,会被每个采访项的platform覆盖) - - "twitter": 默认只采访Twitter平台 - - "reddit": 默认只采访Reddit平台 - - None: 双平台模拟时每个Agent同时采访两个平台 - timeout: 超时时间 - + interviews: List of interviews; each item is + {"agent_id": int, "prompt": str, "platform": str (optional)} + platform: Default platform (optional; overridden by each + interview item's platform) + - "twitter": default to interviewing only Twitter + - "reddit": default to interviewing only Reddit + - None: in a dual-platform simulation interview each + Agent on both platforms + timeout: Timeout + Returns: - IPCResponse,result字段包含所有采访结果 + IPCResponse; the result field contains all interview results """ args = {"interviews": interviews} if platform: args["platform"] = platform - + return self.send_command( command_type=CommandType.BATCH_INTERVIEW, args=args, timeout=timeout ) - + def send_close_env(self, timeout: float = 30.0) -> IPCResponse: """ - 发送关闭环境命令 - + Send a close-environment command. + Args: - timeout: 超时时间 - + timeout: Timeout + Returns: IPCResponse """ @@ -266,17 +273,17 @@ class SimulationIPCClient: args={}, timeout=timeout ) - + def check_env_alive(self) -> bool: """ - 检查模拟环境是否存活 - - 通过检查 env_status.json 文件来判断 + Check whether the simulation environment is alive. + + Decides by checking the env_status.json file. """ status_file = os.path.join(self.simulation_dir, "env_status.json") if not os.path.exists(status_file): return False - + try: with open(status_file, 'r', encoding='utf-8') as f: status = json.load(f) @@ -287,106 +294,107 @@ class SimulationIPCClient: class SimulationIPCServer: """ - 模拟IPC服务器(模拟脚本端使用) - - 轮询命令目录,执行命令并返回响应 + Simulation IPC server (used on the simulation-script side) + + Polls the commands directory, executes commands, and returns + responses. """ - + def __init__(self, simulation_dir: str): """ - 初始化IPC服务器 - + Initialize the IPC server. + Args: - simulation_dir: 模拟数据目录 + simulation_dir: Simulation data directory """ self.simulation_dir = simulation_dir self.commands_dir = os.path.join(simulation_dir, "ipc_commands") self.responses_dir = os.path.join(simulation_dir, "ipc_responses") - - # 确保目录存在 + + # Ensure directories exist os.makedirs(self.commands_dir, exist_ok=True) os.makedirs(self.responses_dir, exist_ok=True) - - # 环境状态 + + # Environment status self._running = False - + def start(self): - """标记服务器为运行状态""" + """Mark the server as running""" self._running = True self._update_env_status("alive") - + def stop(self): - """标记服务器为停止状态""" + """Mark the server as stopped""" self._running = False self._update_env_status("stopped") - + def _update_env_status(self, status: str): - """更新环境状态文件""" + """Update the environment-status file""" status_file = os.path.join(self.simulation_dir, "env_status.json") with open(status_file, 'w', encoding='utf-8') as f: json.dump({ "status": status, "timestamp": datetime.now().isoformat() }, f, ensure_ascii=False, indent=2) - + def poll_commands(self) -> Optional[IPCCommand]: """ - 轮询命令目录,返回第一个待处理的命令 - + Poll the commands directory; return the first pending command. + Returns: - IPCCommand 或 None + IPCCommand or None """ if not os.path.exists(self.commands_dir): return None - - # 按时间排序获取命令文件 + + # Sort command files by modification time command_files = [] for filename in os.listdir(self.commands_dir): if filename.endswith('.json'): filepath = os.path.join(self.commands_dir, filename) command_files.append((filepath, os.path.getmtime(filepath))) - + command_files.sort(key=lambda x: x[1]) - + for filepath, _ in command_files: try: with open(filepath, 'r', encoding='utf-8') as f: data = json.load(f) return IPCCommand.from_dict(data) except (json.JSONDecodeError, KeyError, OSError) as e: - logger.warning(f"读取命令文件失败: {filepath}, {e}") + logger.warning(f"Failed to read command file: {filepath}, {e}") continue - + return None - + def send_response(self, response: IPCResponse): """ - 发送响应 - + Send a response. + Args: - response: IPC响应 + response: IPC response """ response_file = os.path.join(self.responses_dir, f"{response.command_id}.json") with open(response_file, 'w', encoding='utf-8') as f: json.dump(response.to_dict(), f, ensure_ascii=False, indent=2) - - # 删除命令文件 + + # Delete the command file command_file = os.path.join(self.commands_dir, f"{response.command_id}.json") try: os.remove(command_file) except OSError: pass - + def send_success(self, command_id: str, result: Dict[str, Any]): - """发送成功响应""" + """Send a success response""" self.send_response(IPCResponse( command_id=command_id, status=CommandStatus.COMPLETED, result=result )) - + def send_error(self, command_id: str, error: str): - """发送错误响应""" + """Send an error response""" self.send_response(IPCResponse( command_id=command_id, status=CommandStatus.FAILED, diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py index 0d161a90..31c06363 100644 --- a/backend/app/services/simulation_manager.py +++ b/backend/app/services/simulation_manager.py @@ -1,7 +1,7 @@ """ -OASIS模拟管理器 -管理Twitter和Reddit双平台并行模拟 -使用预设脚本 + LLM智能生成配置参数 +OASIS Simulation Manager +Manages parallel Twitter and Reddit dual-platform simulations +Uses preset scripts + LLM-intelligent configuration parameter generation """ import os @@ -23,60 +23,60 @@ logger = get_logger('mirofish.simulation') class SimulationStatus(str, Enum): - """模拟状态""" + """Simulation status""" CREATED = "created" PREPARING = "preparing" READY = "ready" RUNNING = "running" PAUSED = "paused" - STOPPED = "stopped" # 模拟被手动停止 - COMPLETED = "completed" # 模拟自然完成 + STOPPED = "stopped" # Simulation was manually stopped + COMPLETED = "completed" # Simulation completed naturally FAILED = "failed" class PlatformType(str, Enum): - """平台类型""" + """Platform type""" TWITTER = "twitter" REDDIT = "reddit" @dataclass class SimulationState: - """模拟状态""" + """Simulation state""" simulation_id: str project_id: str graph_id: str - # 平台启用状态 + # Platform enable status enable_twitter: bool = True enable_reddit: bool = True - - # 状态 + + # Status status: SimulationStatus = SimulationStatus.CREATED - - # 准备阶段数据 + + # Preparation stage data entities_count: int = 0 profiles_count: int = 0 entity_types: List[str] = field(default_factory=list) - - # 配置生成信息 + + # Config generation info config_generated: bool = False config_reasoning: str = "" - - # 运行时数据 + + # Runtime data current_round: int = 0 twitter_status: str = "not_started" reddit_status: str = "not_started" - - # 时间戳 + + # Timestamps created_at: str = field(default_factory=lambda: datetime.now().isoformat()) updated_at: str = field(default_factory=lambda: datetime.now().isoformat()) - - # 错误信息 + + # Error info error: Optional[str] = None - + def to_dict(self) -> Dict[str, Any]: - """完整状态字典(内部使用)""" + """Full state dictionary (for internal use)""" return { "simulation_id": self.simulation_id, "project_id": self.project_id, @@ -98,7 +98,7 @@ class SimulationState: } def to_simple_dict(self) -> Dict[str, Any]: - """简化状态字典(API返回使用)""" + """Simplified state dictionary (for API response)""" return { "simulation_id": self.simulation_id, "project_id": self.project_id, @@ -114,36 +114,36 @@ class SimulationState: class SimulationManager: """ - 模拟管理器 - - 核心功能: - 1. 从Zep图谱读取实体并过滤 - 2. 生成OASIS Agent Profile - 3. 使用LLM智能生成模拟配置参数 - 4. 准备预设脚本所需的所有文件 + Simulation Manager + + Core features: + 1. Read entities from Zep graph and filter + 2. Generate OASIS Agent Profile + 3. Use LLM to intelligently generate simulation configuration parameters + 4. Prepare all files required by the preset scripts """ - - # 模拟数据存储目录 + + # Simulation data storage directory SIMULATION_DATA_DIR = os.path.join( - os.path.dirname(__file__), + os.path.dirname(__file__), '../../uploads/simulations' ) - + def __init__(self): - # 确保目录存在 + # Ensure the directory exists os.makedirs(self.SIMULATION_DATA_DIR, exist_ok=True) - - # 内存中的模拟状态缓存 + + # In-memory simulation state cache self._simulations: Dict[str, SimulationState] = {} - + def _get_simulation_dir(self, simulation_id: str) -> str: - """获取模拟数据目录""" + """Get the simulation data directory""" sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id) os.makedirs(sim_dir, exist_ok=True) return sim_dir def _save_simulation_state(self, state: SimulationState): - """保存模拟状态到文件""" + """Save the simulation state to file""" sim_dir = self._get_simulation_dir(state.simulation_id) state_file = os.path.join(sim_dir, "state.json") @@ -155,7 +155,7 @@ class SimulationManager: self._simulations[state.simulation_id] = state def _load_simulation_state(self, simulation_id: str) -> Optional[SimulationState]: - """从文件加载模拟状态""" + """Load the simulation state from file""" if simulation_id in self._simulations: return self._simulations[simulation_id] @@ -199,20 +199,20 @@ class SimulationManager: enable_reddit: bool = True, ) -> SimulationState: """ - 创建新的模拟 - + Create a new simulation + Args: - project_id: 项目ID - graph_id: Zep图谱ID - enable_twitter: 是否启用Twitter模拟 - enable_reddit: 是否启用Reddit模拟 - + project_id: Project ID + graph_id: Zep graph ID + enable_twitter: Whether to enable Twitter simulation + enable_reddit: Whether to enable Reddit simulation + Returns: SimulationState """ import uuid simulation_id = f"sim_{uuid.uuid4().hex[:12]}" - + state = SimulationState( simulation_id=simulation_id, project_id=project_id, @@ -221,9 +221,9 @@ class SimulationManager: enable_reddit=enable_reddit, status=SimulationStatus.CREATED, ) - + self._save_simulation_state(state) - logger.info(f"创建模拟: {simulation_id}, project={project_id}, graph={graph_id}") + logger.info(f"Created simulation: {simulation_id}, project={project_id}, graph={graph_id}") return state @@ -238,30 +238,30 @@ class SimulationManager: parallel_profile_count: int = 3 ) -> SimulationState: """ - 准备模拟环境(全程自动化) - - 步骤: - 1. 从Zep图谱读取并过滤实体 - 2. 为每个实体生成OASIS Agent Profile(可选LLM增强,支持并行) - 3. 使用LLM智能生成模拟配置参数(时间、活跃度、发言频率等) - 4. 保存配置文件和Profile文件 - 5. 复制预设脚本到模拟目录 - + Prepare the simulation environment (fully automated) + + Steps: + 1. Read and filter entities from the Zep graph + 2. Generate OASIS Agent Profile for each entity (optional LLM enhancement, supports parallelism) + 3. Use LLM to intelligently generate simulation configuration parameters (time, activity, posting frequency, etc.) + 4. Save config and profile files + 5. Copy preset scripts to the simulation directory + Args: - simulation_id: 模拟ID - simulation_requirement: 模拟需求描述(用于LLM生成配置) - document_text: 原始文档内容(用于LLM理解背景) - defined_entity_types: 预定义的实体类型(可选) - use_llm_for_profiles: 是否使用LLM生成详细人设 - progress_callback: 进度回调函数 (stage, progress, message) - parallel_profile_count: 并行生成人设的数量,默认3 - + simulation_id: Simulation ID + simulation_requirement: Simulation requirement description (used for LLM config generation) + document_text: Original document content (used for LLM to understand context) + defined_entity_types: Predefined entity types (optional) + use_llm_for_profiles: Whether to use LLM to generate detailed personas + progress_callback: Progress callback function (stage, progress, message) + parallel_profile_count: Number of personas to generate in parallel, default 3 + Returns: SimulationState """ state = self._load_simulation_state(simulation_id) if not state: - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(f"Simulation not found: {simulation_id}") try: state.status = SimulationStatus.PREPARING @@ -269,24 +269,24 @@ class SimulationManager: sim_dir = self._get_simulation_dir(simulation_id) - # ========== 阶段1: 读取并过滤实体 ========== + # ========== Stage 1: Read and filter entities ========== if progress_callback: progress_callback("reading", 0, t('progress.connectingZepGraph')) - + reader = ZepEntityReader() - + if progress_callback: progress_callback("reading", 30, t('progress.readingNodeData')) - + filtered = reader.filter_defined_entities( graph_id=state.graph_id, defined_entity_types=defined_entity_types, enrich_with_edges=True ) - + state.entities_count = filtered.filtered_count state.entity_types = list(filtered.entity_types) - + if progress_callback: progress_callback( "reading", 100, @@ -294,16 +294,16 @@ class SimulationManager: current=filtered.filtered_count, total=filtered.filtered_count ) - + if filtered.filtered_count == 0: state.status = SimulationStatus.FAILED - state.error = "没有找到符合条件的实体,请检查图谱是否正确构建" + state.error = "No entities matching the criteria were found. Please check whether the graph was built correctly." self._save_simulation_state(state) return state - - # ========== 阶段2: 生成Agent Profile ========== + + # ========== Stage 2: Generate Agent Profile ========== total_entities = len(filtered.entities) - + if progress_callback: progress_callback( "generating_profiles", 0, @@ -311,22 +311,22 @@ class SimulationManager: current=0, total=total_entities ) - - # 传入graph_id以启用Zep检索功能,获取更丰富的上下文 + + # Pass graph_id to enable Zep retrieval for richer context generator = OasisProfileGenerator(graph_id=state.graph_id) - + def profile_progress(current, total, msg): if progress_callback: progress_callback( - "generating_profiles", - int(current / total * 100), + "generating_profiles", + int(current / total * 100), msg, current=current, total=total, item_name=msg ) - - # 设置实时保存的文件路径(优先使用 Reddit JSON 格式) + + # Configure real-time save path (prefer Reddit JSON format) realtime_output_path = None realtime_platform = "reddit" if state.enable_reddit: @@ -335,21 +335,21 @@ class SimulationManager: elif state.enable_twitter: realtime_output_path = os.path.join(sim_dir, "twitter_profiles.csv") realtime_platform = "twitter" - + profiles = generator.generate_profiles_from_entities( entities=filtered.entities, use_llm=use_llm_for_profiles, progress_callback=profile_progress, - graph_id=state.graph_id, # 传入graph_id用于Zep检索 - parallel_count=parallel_profile_count, # 并行生成数量 - realtime_output_path=realtime_output_path, # 实时保存路径 - output_platform=realtime_platform # 输出格式 + graph_id=state.graph_id, # Pass graph_id for Zep retrieval + parallel_count=parallel_profile_count, # Parallel generation count + realtime_output_path=realtime_output_path, # Real-time save path + output_platform=realtime_platform # Output format ) - + state.profiles_count = len(profiles) - - # 保存Profile文件(注意:Twitter使用CSV格式,Reddit使用JSON格式) - # Reddit 已经在生成过程中实时保存了,这里再保存一次确保完整性 + + # Save profile files (note: Twitter uses CSV, Reddit uses JSON) + # Reddit has already been saved in real time during generation; saving once more here ensures completeness if progress_callback: progress_callback( "generating_profiles", 95, @@ -357,16 +357,16 @@ class SimulationManager: current=total_entities, total=total_entities ) - + if state.enable_reddit: generator.save_profiles( profiles=profiles, file_path=os.path.join(sim_dir, "reddit_profiles.json"), platform="reddit" ) - + if state.enable_twitter: - # Twitter使用CSV格式!这是OASIS的要求 + # Twitter uses CSV format! This is required by OASIS generator.save_profiles( profiles=profiles, file_path=os.path.join(sim_dir, "twitter_profiles.csv"), @@ -381,7 +381,7 @@ class SimulationManager: total=len(profiles) ) - # ========== 阶段3: LLM智能生成模拟配置 ========== + # ========== Stage 3: LLM-intelligent generation of simulation config ========== if progress_callback: progress_callback( "generating_config", 0, @@ -389,9 +389,9 @@ class SimulationManager: current=0, total=3 ) - + config_generator = SimulationConfigGenerator() - + if progress_callback: progress_callback( "generating_config", 30, @@ -399,7 +399,7 @@ class SimulationManager: current=1, total=3 ) - + sim_params = config_generator.generate_config( simulation_id=simulation_id, project_id=state.project_id, @@ -410,7 +410,7 @@ class SimulationManager: enable_twitter=state.enable_twitter, enable_reddit=state.enable_reddit ) - + if progress_callback: progress_callback( "generating_config", 70, @@ -418,15 +418,15 @@ class SimulationManager: current=2, total=3 ) - - # 保存配置文件 + + # Save config file config_path = os.path.join(sim_dir, "simulation_config.json") with open(config_path, 'w', encoding='utf-8') as f: f.write(sim_params.to_json()) - + state.config_generated = True state.config_reasoning = sim_params.generation_reasoning - + if progress_callback: progress_callback( "generating_config", 100, @@ -434,82 +434,83 @@ class SimulationManager: current=3, total=3 ) - - # 注意:运行脚本保留在 backend/scripts/ 目录,不再复制到模拟目录 - # 启动模拟时,simulation_runner 会从 scripts/ 目录运行脚本 - - # 更新状态 + + # Note: run scripts are kept in backend/scripts/ and are no longer copied into the simulation dir + # When starting a simulation, simulation_runner runs scripts from the scripts/ directory + + # Update state state.status = SimulationStatus.READY self._save_simulation_state(state) - - logger.info(f"模拟准备完成: {simulation_id}, " + + logger.info(f"Simulation preparation complete: {simulation_id}, " f"entities={state.entities_count}, profiles={state.profiles_count}") - + return state - + except Exception as e: - logger.error(f"模拟准备失败: {simulation_id}, error={str(e)}") + logger.error(f"Simulation preparation failed: {simulation_id}, error={str(e)}") import traceback logger.error(traceback.format_exc()) state.status = SimulationStatus.FAILED state.error = str(e) self._save_simulation_state(state) raise - + def get_simulation(self, simulation_id: str) -> Optional[SimulationState]: - """获取模拟状态""" + """Get the simulation state""" + return self._load_simulation_state(simulation_id) - + def list_simulations(self, project_id: Optional[str] = None) -> List[SimulationState]: - """列出所有模拟""" + """List all simulations""" simulations = [] - + if os.path.exists(self.SIMULATION_DATA_DIR): for sim_id in os.listdir(self.SIMULATION_DATA_DIR): - # 跳过隐藏文件(如 .DS_Store)和非目录文件 + # Skip hidden files (e.g. .DS_Store) and non-directory entries sim_path = os.path.join(self.SIMULATION_DATA_DIR, sim_id) if sim_id.startswith('.') or not os.path.isdir(sim_path): continue - + state = self._load_simulation_state(sim_id) if state: if project_id is None or state.project_id == project_id: simulations.append(state) - + return simulations - + def get_profiles(self, simulation_id: str, platform: str = "reddit") -> List[Dict[str, Any]]: - """获取模拟的Agent Profile""" + """Get the Agent Profiles of a simulation""" state = self._load_simulation_state(simulation_id) if not state: - raise ValueError(f"模拟不存在: {simulation_id}") - + raise ValueError(f"Simulation not found: {simulation_id}") + sim_dir = self._get_simulation_dir(simulation_id) profile_path = os.path.join(sim_dir, f"{platform}_profiles.json") - + if not os.path.exists(profile_path): return [] - + with open(profile_path, 'r', encoding='utf-8') as f: return json.load(f) - + def get_simulation_config(self, simulation_id: str) -> Optional[Dict[str, Any]]: - """获取模拟配置""" + """Get the simulation config""" sim_dir = self._get_simulation_dir(simulation_id) config_path = os.path.join(sim_dir, "simulation_config.json") - + if not os.path.exists(config_path): return None - + with open(config_path, 'r', encoding='utf-8') as f: return json.load(f) - + def get_run_instructions(self, simulation_id: str) -> Dict[str, str]: - """获取运行说明""" + """Get the run instructions""" sim_dir = self._get_simulation_dir(simulation_id) config_path = os.path.join(sim_dir, "simulation_config.json") scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts')) - + return { "simulation_dir": sim_dir, "scripts_dir": scripts_dir, @@ -520,10 +521,10 @@ class SimulationManager: "parallel": f"python {scripts_dir}/run_parallel_simulation.py --config {config_path}", }, "instructions": ( - f"1. 激活conda环境: conda activate MiroFish\n" - f"2. 运行模拟 (脚本位于 {scripts_dir}):\n" - f" - 单独运行Twitter: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\n" - f" - 单独运行Reddit: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\n" - f" - 并行运行双平台: python {scripts_dir}/run_parallel_simulation.py --config {config_path}" + f"1. Activate the conda environment: conda activate MiroFish\n" + f"2. Run the simulation (scripts are at {scripts_dir}):\n" + f" - Twitter only: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\n" + f" - Reddit only: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\n" + f" - Both platforms in parallel: python {scripts_dir}/run_parallel_simulation.py --config {config_path}" ) } diff --git a/backend/app/services/simulation_runner.py b/backend/app/services/simulation_runner.py index e86021f8..209488f6 100644 --- a/backend/app/services/simulation_runner.py +++ b/backend/app/services/simulation_runner.py @@ -1,6 +1,6 @@ """ -OASIS模拟运行器 -在后台运行模拟并记录每个Agent的动作,支持实时状态监控 +OASISSimulation Runner +Run simulations in the background and record eachAgentaction,Support real-time status monitoring """ import os @@ -26,15 +26,15 @@ from .simulation_ipc import SimulationIPCClient, CommandType, IPCResponse logger = get_logger('mirofish.simulation_runner') -# 标记是否已注册清理函数 +# Flag indicating whether the cleanup function has been registered _cleanup_registered = False -# 平台检测 +# Platform detection IS_WINDOWS = sys.platform == 'win32' class RunnerStatus(str, Enum): - """运行器状态""" + """Runner state""" IDLE = "idle" STARTING = "starting" RUNNING = "running" @@ -47,7 +47,7 @@ class RunnerStatus(str, Enum): @dataclass class AgentAction: - """Agent动作记录""" + """Agentaction record""" round_num: int timestamp: str platform: str # twitter / reddit @@ -74,7 +74,7 @@ class AgentAction: @dataclass class RoundSummary: - """每轮摘要""" + """per-round summary""" round_num: int start_time: str end_time: Optional[str] = None @@ -100,52 +100,52 @@ class RoundSummary: @dataclass class SimulationRunState: - """模拟运行状态(实时)""" + """simulation running state(real-time)""" simulation_id: str runner_status: RunnerStatus = RunnerStatus.IDLE - # 进度信息 + # progress information current_round: int = 0 total_rounds: int = 0 simulated_hours: int = 0 total_simulation_hours: int = 0 - # 各平台独立轮次和模拟时间(用于双平台并行显示) + # Per-platform independent rounds and simulation time(Used for parallel display of both platforms) twitter_current_round: int = 0 reddit_current_round: int = 0 twitter_simulated_hours: int = 0 reddit_simulated_hours: int = 0 - # 平台状态 + # platform state twitter_running: bool = False reddit_running: bool = False twitter_actions_count: int = 0 reddit_actions_count: int = 0 - # 平台完成状态(通过检测 actions.jsonl 中的 simulation_end 事件) + # platform completion state(through detection actions.jsonl in the simulation_end event) twitter_completed: bool = False reddit_completed: bool = False - # 每轮摘要 + # per-round summary rounds: List[RoundSummary] = field(default_factory=list) - # 最近动作(用于前端实时展示) + # recent action(Used for real-time frontend display) recent_actions: List[AgentAction] = field(default_factory=list) max_recent_actions: int = 50 - # 时间戳 + # timestamp started_at: Optional[str] = None updated_at: str = field(default_factory=lambda: datetime.now().isoformat()) completed_at: Optional[str] = None - # 错误信息 + # error message error: Optional[str] = None - # 进程ID(用于停止) + # processID(used to stop) process_pid: Optional[int] = None def add_action(self, action: AgentAction): - """添加动作到最近动作列表""" + """Add action to the recent action list""" self.recent_actions.insert(0, action) if len(self.recent_actions) > self.max_recent_actions: self.recent_actions = self.recent_actions[:self.max_recent_actions] @@ -166,7 +166,7 @@ class SimulationRunState: "simulated_hours": self.simulated_hours, "total_simulation_hours": self.total_simulation_hours, "progress_percent": round(self.current_round / max(self.total_rounds, 1) * 100, 1), - # 各平台独立轮次和时间 + # Per-platform independent rounds and time "twitter_current_round": self.twitter_current_round, "reddit_current_round": self.reddit_current_round, "twitter_simulated_hours": self.twitter_simulated_hours, @@ -186,7 +186,7 @@ class SimulationRunState: } def to_detail_dict(self) -> Dict[str, Any]: - """包含最近动作的详细信息""" + """Contains detailed information of recent actions""" result = self.to_dict() result["recent_actions"] = [a.to_dict() for a in self.recent_actions] result["rounds_count"] = len(self.rounds) @@ -195,45 +195,45 @@ class SimulationRunState: class SimulationRunner: """ - 模拟运行器 + Simulation Runner - 负责: - 1. 在后台进程中运行OASIS模拟 - 2. 解析运行日志,记录每个Agent的动作 - 3. 提供实时状态查询接口 - 4. 支持暂停/停止/恢复操作 + responsible for: + 1. Run in a background processOASISsimulation + 2. Parse run logs,record eachAgentaction + 3. Provide a real-time status query interface + 4. Support pause/stop/resume operation """ - # 运行状态存储目录 + # Running state storage directory RUN_STATE_DIR = os.path.join( os.path.dirname(__file__), '../../uploads/simulations' ) - # 脚本目录 + # script directory SCRIPTS_DIR = os.path.join( os.path.dirname(__file__), '../../scripts' ) - # 内存中的运行状态 + # In-memory running state _run_states: Dict[str, SimulationRunState] = {} _processes: Dict[str, subprocess.Popen] = {} _action_queues: Dict[str, Queue] = {} _monitor_threads: Dict[str, threading.Thread] = {} - _stdout_files: Dict[str, Any] = {} # 存储 stdout 文件句柄 - _stderr_files: Dict[str, Any] = {} # 存储 stderr 文件句柄 + _stdout_files: Dict[str, Any] = {} # storage stdout file handle + _stderr_files: Dict[str, Any] = {} # storage stderr file handle - # 图谱记忆更新配置 + # Graph memory update configuration _graph_memory_enabled: Dict[str, bool] = {} # simulation_id -> enabled @classmethod def get_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]: - """获取运行状态""" + """Get the running state""" if simulation_id in cls._run_states: return cls._run_states[simulation_id] - # 尝试从文件加载 + # Try to load from file state = cls._load_run_state(simulation_id) if state: cls._run_states[simulation_id] = state @@ -241,7 +241,7 @@ class SimulationRunner: @classmethod def _load_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]: - """从文件加载运行状态""" + """Load running state from file""" state_file = os.path.join(cls.RUN_STATE_DIR, simulation_id, "run_state.json") if not os.path.exists(state_file): return None @@ -257,7 +257,7 @@ class SimulationRunner: total_rounds=data.get("total_rounds", 0), simulated_hours=data.get("simulated_hours", 0), total_simulation_hours=data.get("total_simulation_hours", 0), - # 各平台独立轮次和时间 + # Per-platform independent rounds and time twitter_current_round=data.get("twitter_current_round", 0), reddit_current_round=data.get("reddit_current_round", 0), twitter_simulated_hours=data.get("twitter_simulated_hours", 0), @@ -275,7 +275,7 @@ class SimulationRunner: process_pid=data.get("process_pid"), ) - # 加载最近动作 + # Load recent actions actions_data = data.get("recent_actions", []) for a in actions_data: state.recent_actions.append(AgentAction( @@ -292,12 +292,12 @@ class SimulationRunner: return state except Exception as e: - logger.error(f"加载运行状态失败: {str(e)}") + logger.error(f"Failed to load running state: {str(e)}") return None @classmethod def _save_run_state(cls, state: SimulationRunState): - """保存运行状态到文件""" + """Save running state to file""" sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id) os.makedirs(sim_dir, exist_ok=True) state_file = os.path.join(sim_dir, "run_state.json") @@ -314,50 +314,50 @@ class SimulationRunner: cls, simulation_id: str, platform: str = "parallel", # twitter / reddit / parallel - max_rounds: int = None, # 最大模拟轮数(可选,用于截断过长的模拟) - enable_graph_memory_update: bool = False, # 是否将活动更新到Zep图谱 - graph_id: str = None # Zep图谱ID(启用图谱更新时必需) + max_rounds: int = None, # maximum simulation rounds(optional,Used to truncate overly long simulations) + enable_graph_memory_update: bool = False, # whether to update activities toZepgraph + graph_id: str = None # ZepgraphID(Required when graph update is enabled) ) -> SimulationRunState: """ - 启动模拟 + Start simulation Args: - simulation_id: 模拟ID - platform: 运行平台 (twitter/reddit/parallel) - max_rounds: 最大模拟轮数(可选,用于截断过长的模拟) - enable_graph_memory_update: 是否将Agent活动动态更新到Zep图谱 - graph_id: Zep图谱ID(启用图谱更新时必需) + simulation_id: simulationID + platform: running platform (twitter/reddit/parallel) + max_rounds: maximum simulation rounds(optional,Used to truncate overly long simulations) + enable_graph_memory_update: whether toAgentactivity updates toZepgraph + graph_id: ZepgraphID(Required when graph update is enabled) Returns: SimulationRunState """ - # 检查是否已在运行 + # Check whether it is already running existing = cls.get_run_state(simulation_id) if existing and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]: - raise ValueError(f"模拟已在运行中: {simulation_id}") + raise ValueError(f"Simulation is already running: {simulation_id}") - # 加载模拟配置 + # Load simulation configuration sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) config_path = os.path.join(sim_dir, "simulation_config.json") if not os.path.exists(config_path): - raise ValueError(f"模拟配置不存在,请先调用 /prepare 接口") + raise ValueError(f"Simulation configuration does not exist,please first call /prepare interface") with open(config_path, 'r', encoding='utf-8') as f: config = json.load(f) - # 初始化运行状态 + # Initialize the running state time_config = config.get("time_config", {}) total_hours = time_config.get("total_simulation_hours", 72) minutes_per_round = time_config.get("minutes_per_round", 30) total_rounds = int(total_hours * 60 / minutes_per_round) - # 如果指定了最大轮数,则截断 + # If the maximum rounds are specified,then truncate if max_rounds is not None and max_rounds > 0: original_rounds = total_rounds total_rounds = min(total_rounds, max_rounds) if total_rounds < original_rounds: - logger.info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") + logger.info(f"Rounds have been truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") state = SimulationRunState( simulation_id=simulation_id, @@ -369,22 +369,22 @@ class SimulationRunner: cls._save_run_state(state) - # 如果启用图谱记忆更新,创建更新器 + # If graph memory update is enabled, create the updater if enable_graph_memory_update: if not graph_id: - raise ValueError("启用图谱记忆更新时必须提供 graph_id") + raise ValueError("Must be provided when graph memory update is enabled graph_id") try: ZepGraphMemoryManager.create_updater(simulation_id, graph_id) cls._graph_memory_enabled[simulation_id] = True - logger.info(f"已启用图谱记忆更新: simulation_id={simulation_id}, graph_id={graph_id}") + logger.info(f"Graph memory update has been enabled: simulation_id={simulation_id}, graph_id={graph_id}") except Exception as e: - logger.error(f"创建图谱记忆更新器失败: {e}") + logger.error(f"Failed to create graph memory updater: {e}") cls._graph_memory_enabled[simulation_id] = False else: cls._graph_memory_enabled[simulation_id] = False - # 确定运行哪个脚本(脚本位于 backend/scripts/ 目录) + # Determine which script to run(Script is located in backend/scripts/ directory) if platform == "twitter": script_name = "run_twitter_simulation.py" state.twitter_running = True @@ -399,57 +399,57 @@ class SimulationRunner: script_path = os.path.join(cls.SCRIPTS_DIR, script_name) if not os.path.exists(script_path): - raise ValueError(f"脚本不存在: {script_path}") + raise ValueError(f"Script does not exist: {script_path}") - # 创建动作队列 + # Create the action queue action_queue = Queue() cls._action_queues[simulation_id] = action_queue - # 启动模拟进程 + # Start the simulation process try: - # 构建运行命令,使用完整路径 - # 新的日志结构: - # twitter/actions.jsonl - Twitter 动作日志 - # reddit/actions.jsonl - Reddit 动作日志 - # simulation.log - 主进程日志 + # Build the run command,Use the full path + # New log structure: + # twitter/actions.jsonl - Twitter action log + # reddit/actions.jsonl - Reddit action log + # simulation.log - main process log cmd = [ - sys.executable, # Python解释器 + sys.executable, # Pythoninterpreter script_path, - "--config", config_path, # 使用完整配置文件路径 + "--config", config_path, # Use the full configuration file path ] - # 如果指定了最大轮数,添加到命令行参数 + # If the maximum rounds are specified,Add to command-line arguments if max_rounds is not None and max_rounds > 0: cmd.extend(["--max-rounds", str(max_rounds)]) - # 创建主日志文件,避免 stdout/stderr 管道缓冲区满导致进程阻塞 + # Create the main log file,avoid stdout/stderr pipeline buffer full causes the process to block main_log_path = os.path.join(sim_dir, "simulation.log") main_log_file = open(main_log_path, 'w', encoding='utf-8') - # 设置子进程环境变量,确保 Windows 上使用 UTF-8 编码 - # 这可以修复第三方库(如 OASIS)读取文件时未指定编码的问题 + # Set subprocess environment variables,ensure Windows on the UTF-8 encoding + # This can fix third-party libraries(e.g. OASIS)problem of un-specified encoding when reading files env = os.environ.copy() - env['PYTHONUTF8'] = '1' # Python 3.7+ 支持,让所有 open() 默认使用 UTF-8 - env['PYTHONIOENCODING'] = 'utf-8' # 确保 stdout/stderr 使用 UTF-8 + env['PYTHONUTF8'] = '1' # Python 3.7+ support,let all open() use by default UTF-8 + env['PYTHONIOENCODING'] = 'utf-8' # ensure stdout/stderr use UTF-8 - # 设置工作目录为模拟目录(数据库等文件会生成在此) - # 使用 start_new_session=True 创建新的进程组,确保可以通过 os.killpg 终止所有子进程 + # Set the working directory to the simulation directory(database and other files will be generated here) + # use start_new_session=True Create a new process group,ensure it can be terminated through os.killpg terminate all child processes process = subprocess.Popen( cmd, cwd=sim_dir, stdout=main_log_file, - stderr=subprocess.STDOUT, # stderr 也写入同一个文件 + stderr=subprocess.STDOUT, # stderr also written to the same file text=True, - encoding='utf-8', # 显式指定编码 + encoding='utf-8', # Explicitly specify encoding bufsize=1, - env=env, # 传递带有 UTF-8 设置的环境变量 - start_new_session=True, # 创建新进程组,确保服务器关闭时能终止所有相关进程 + env=env, # passing with UTF-8 set environment variables + start_new_session=True, # create a new process group,Ensure all related processes are terminated when the server shuts down ) - # 保存文件句柄以便后续关闭 + # Save file handle for later closing cls._stdout_files[simulation_id] = main_log_file - cls._stderr_files[simulation_id] = None # 不再需要单独的 stderr + cls._stderr_files[simulation_id] = None # No longer need a separate stderr state.process_pid = process.pid state.runner_status = RunnerStatus.RUNNING @@ -459,7 +459,7 @@ class SimulationRunner: # Capture locale before spawning monitor thread current_locale = get_locale() - # 启动监控线程 + # Start the monitor thread monitor_thread = threading.Thread( target=cls._monitor_simulation, args=(simulation_id, current_locale), @@ -468,7 +468,7 @@ class SimulationRunner: monitor_thread.start() cls._monitor_threads[simulation_id] = monitor_thread - logger.info(f"模拟启动成功: {simulation_id}, pid={process.pid}, platform={platform}") + logger.info(f"Simulation started successfully: {simulation_id}, pid={process.pid}, platform={platform}") except Exception as e: state.runner_status = RunnerStatus.FAILED @@ -480,11 +480,11 @@ class SimulationRunner: @classmethod def _monitor_simulation(cls, simulation_id: str, locale: str = 'zh'): - """监控模拟进程,解析动作日志""" + """Monitor the simulation process,Parse action logs""" set_locale(locale) sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) - # 新的日志结构:分平台的动作日志 + # New log structure:Per-platform action logs twitter_actions_log = os.path.join(sim_dir, "twitter", "actions.jsonl") reddit_actions_log = os.path.join(sim_dir, "reddit", "actions.jsonl") @@ -498,75 +498,75 @@ class SimulationRunner: reddit_position = 0 try: - while process.poll() is None: # 进程仍在运行 - # 读取 Twitter 动作日志 + while process.poll() is None: # Process is still running + # read Twitter action log if os.path.exists(twitter_actions_log): twitter_position = cls._read_action_log( twitter_actions_log, twitter_position, state, "twitter" ) - # 读取 Reddit 动作日志 + # read Reddit action log if os.path.exists(reddit_actions_log): reddit_position = cls._read_action_log( reddit_actions_log, reddit_position, state, "reddit" ) - # 更新状态 + # Update state cls._save_run_state(state) time.sleep(2) - # 进程结束后,最后读取一次日志 + # After the process ends,Read the log one last time if os.path.exists(twitter_actions_log): cls._read_action_log(twitter_actions_log, twitter_position, state, "twitter") if os.path.exists(reddit_actions_log): cls._read_action_log(reddit_actions_log, reddit_position, state, "reddit") - # 进程结束 + # process end exit_code = process.returncode if exit_code == 0: state.runner_status = RunnerStatus.COMPLETED state.completed_at = datetime.now().isoformat() - logger.info(f"模拟完成: {simulation_id}") + logger.info(f"Simulation complete: {simulation_id}") else: state.runner_status = RunnerStatus.FAILED - # 从主日志文件读取错误信息 + # Read error information from the main log file main_log_path = os.path.join(sim_dir, "simulation.log") error_info = "" try: if os.path.exists(main_log_path): with open(main_log_path, 'r', encoding='utf-8') as f: - error_info = f.read()[-2000:] # 取最后2000字符 + error_info = f.read()[-2000:] # take the last2000characters except Exception: pass - state.error = f"进程退出码: {exit_code}, 错误: {error_info}" - logger.error(f"模拟失败: {simulation_id}, error={state.error}") + state.error = f"Process exit code: {exit_code}, error: {error_info}" + logger.error(f"Simulation failed: {simulation_id}, error={state.error}") state.twitter_running = False state.reddit_running = False cls._save_run_state(state) except Exception as e: - logger.error(f"监控线程异常: {simulation_id}, error={str(e)}") + logger.error(f"Monitor thread exception: {simulation_id}, error={str(e)}") state.runner_status = RunnerStatus.FAILED state.error = str(e) cls._save_run_state(state) finally: - # 停止图谱记忆更新器 + # Stop the graph memory updater if cls._graph_memory_enabled.get(simulation_id, False): try: ZepGraphMemoryManager.stop_updater(simulation_id) - logger.info(f"已停止图谱记忆更新: simulation_id={simulation_id}") + logger.info(f"Graph memory update has been stopped: simulation_id={simulation_id}") except Exception as e: - logger.error(f"停止图谱记忆更新器失败: {e}") + logger.error(f"Failed to stop graph memory updater: {e}") cls._graph_memory_enabled.pop(simulation_id, None) - # 清理进程资源 + # Clean up process resources cls._processes.pop(simulation_id, None) cls._action_queues.pop(simulation_id, None) - # 关闭日志文件句柄 + # Close the log file handle if simulation_id in cls._stdout_files: try: cls._stdout_files[simulation_id].close() @@ -589,18 +589,18 @@ class SimulationRunner: platform: str ) -> int: """ - 读取动作日志文件 + Read the action log file Args: - log_path: 日志文件路径 - position: 上次读取位置 - state: 运行状态对象 - platform: 平台名称 (twitter/reddit) + log_path: log file path + position: last read position + state: running state object + platform: platform name (twitter/reddit) Returns: - 新的读取位置 + new read position """ - # 检查是否启用了图谱记忆更新 + # Check whether graph memory update is enabled graph_memory_enabled = cls._graph_memory_enabled.get(state.simulation_id, False) graph_updater = None if graph_memory_enabled: @@ -615,36 +615,36 @@ class SimulationRunner: try: action_data = json.loads(line) - # 处理事件类型的条目 + # Process event-type entries if "event_type" in action_data: event_type = action_data.get("event_type") - # 检测 simulation_end 事件,标记平台已完成 + # detection simulation_end event,Mark the platform as completed if event_type == "simulation_end": if platform == "twitter": state.twitter_completed = True state.twitter_running = False - logger.info(f"Twitter 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}") + logger.info(f"Twitter Simulation has completed: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}") elif platform == "reddit": state.reddit_completed = True state.reddit_running = False - logger.info(f"Reddit 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}") + logger.info(f"Reddit Simulation has completed: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}") - # 检查是否所有启用的平台都已完成 - # 如果只运行了一个平台,只检查那个平台 - # 如果运行了两个平台,需要两个都完成 + # Check whether all enabled platforms have completed + # If only one platform is running,only check that platform + # If two platforms are running,need both to be completed all_completed = cls._check_all_platforms_completed(state) if all_completed: state.runner_status = RunnerStatus.COMPLETED state.completed_at = datetime.now().isoformat() - logger.info(f"所有平台模拟已完成: {state.simulation_id}") + logger.info(f"All platforms simulation has completed: {state.simulation_id}") - # 更新轮次信息(从 round_end 事件) + # Update round information(from round_end event) elif event_type == "round_end": round_num = action_data.get("round", 0) simulated_hours = action_data.get("simulated_hours", 0) - # 更新各平台独立的轮次和时间 + # Update per-platform independent rounds and time if platform == "twitter": if round_num > state.twitter_current_round: state.twitter_current_round = round_num @@ -654,10 +654,10 @@ class SimulationRunner: state.reddit_current_round = round_num state.reddit_simulated_hours = simulated_hours - # 总体轮次取两个平台的最大值 + # Overall rounds take the maximum of the two platforms if round_num > state.current_round: state.current_round = round_num - # 总体时间取两个平台的最大值 + # Overall time takes the maximum of the two platforms state.simulated_hours = max(state.twitter_simulated_hours, state.reddit_simulated_hours) continue @@ -675,11 +675,11 @@ class SimulationRunner: ) state.add_action(action) - # 更新轮次 + # update round if action.round_num and action.round_num > state.current_round: state.current_round = action.round_num - # 如果启用了图谱记忆更新,将活动发送到Zep + # If graph memory update is enabled,send activities toZep if graph_updater: graph_updater.add_activity_from_dict(action_data, platform) @@ -687,52 +687,52 @@ class SimulationRunner: pass return f.tell() except Exception as e: - logger.warning(f"读取动作日志失败: {log_path}, error={e}") + logger.warning(f"Failed to read the action log: {log_path}, error={e}") return position @classmethod def _check_all_platforms_completed(cls, state: SimulationRunState) -> bool: """ - 检查所有启用的平台是否都已完成模拟 + Check whether all enabled platforms have completed simulation - 通过检查对应的 actions.jsonl 文件是否存在来判断平台是否被启用 + by checking the corresponding actions.jsonl check whether a file exists to determine whether a platform is enabled Returns: - True 如果所有启用的平台都已完成 + True If all enabled platforms have completed """ sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id) twitter_log = os.path.join(sim_dir, "twitter", "actions.jsonl") reddit_log = os.path.join(sim_dir, "reddit", "actions.jsonl") - # 检查哪些平台被启用(通过文件是否存在判断) + # Check which platforms are enabled(determined by file existence) twitter_enabled = os.path.exists(twitter_log) reddit_enabled = os.path.exists(reddit_log) - # 如果平台被启用但未完成,则返回 False + # If the platform is enabled but not completed,then return False if twitter_enabled and not state.twitter_completed: return False if reddit_enabled and not state.reddit_completed: return False - # 至少有一个平台被启用且已完成 + # At least one platform is enabled and has completed return twitter_enabled or reddit_enabled @classmethod def _terminate_process(cls, process: subprocess.Popen, simulation_id: str, timeout: int = 10): """ - 跨平台终止进程及其子进程 + Cross-platform terminate a process and its child processes Args: - process: 要终止的进程 - simulation_id: 模拟ID(用于日志) - timeout: 等待进程退出的超时时间(秒) + process: process to terminate + simulation_id: simulationID(used for logs) + timeout: Timeout for waiting for the process to exit(seconds) """ if IS_WINDOWS: - # Windows: 使用 taskkill 命令终止进程树 - # /F = 强制终止, /T = 终止进程树(包括子进程) - logger.info(f"终止进程树 (Windows): simulation={simulation_id}, pid={process.pid}") + # Windows: use taskkill command to terminate the process tree + # /F = force terminate, /T = terminate the process tree(including child processes) + logger.info(f"terminate the process tree (Windows): simulation={simulation_id}, pid={process.pid}") try: - # 先尝试优雅终止 + # First try graceful termination subprocess.run( ['taskkill', '/PID', str(process.pid), '/T'], capture_output=True, @@ -741,8 +741,8 @@ class SimulationRunner: try: process.wait(timeout=timeout) except subprocess.TimeoutExpired: - # 强制终止 - logger.warning(f"进程未响应,强制终止: {simulation_id}") + # force terminate + logger.warning(f"Process did not respond,force terminate: {simulation_id}") subprocess.run( ['taskkill', '/F', '/PID', str(process.pid), '/T'], capture_output=True, @@ -750,53 +750,53 @@ class SimulationRunner: ) process.wait(timeout=5) except Exception as e: - logger.warning(f"taskkill 失败,尝试 terminate: {e}") + logger.warning(f"taskkill failed,try terminate: {e}") process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() else: - # Unix: 使用进程组终止 - # 由于使用了 start_new_session=True,进程组 ID 等于主进程 PID + # Unix: Use process group to terminate + # Because we used start_new_session=True,process group ID equal to the main process PID pgid = os.getpgid(process.pid) - logger.info(f"终止进程组 (Unix): simulation={simulation_id}, pgid={pgid}") + logger.info(f"terminate the process group (Unix): simulation={simulation_id}, pgid={pgid}") - # 先发送 SIGTERM 给整个进程组 + # first send SIGTERM to the entire process group os.killpg(pgid, signal.SIGTERM) try: process.wait(timeout=timeout) except subprocess.TimeoutExpired: - # 如果超时后还没结束,强制发送 SIGKILL - logger.warning(f"进程组未响应 SIGTERM,强制终止: {simulation_id}") + # If not finished after timeout,force send SIGKILL + logger.warning(f"Process group did not respond SIGTERM,force terminate: {simulation_id}") os.killpg(pgid, signal.SIGKILL) process.wait(timeout=5) @classmethod def stop_simulation(cls, simulation_id: str) -> SimulationRunState: - """停止模拟""" + """Stop simulation""" state = cls.get_run_state(simulation_id) if not state: - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(f"Simulation does not exist: {simulation_id}") if state.runner_status not in [RunnerStatus.RUNNING, RunnerStatus.PAUSED]: - raise ValueError(f"模拟未在运行: {simulation_id}, status={state.runner_status}") + raise ValueError(f"Simulation is not running: {simulation_id}, status={state.runner_status}") state.runner_status = RunnerStatus.STOPPING cls._save_run_state(state) - # 终止进程 + # terminate process process = cls._processes.get(simulation_id) if process and process.poll() is None: try: cls._terminate_process(process, simulation_id) except ProcessLookupError: - # 进程已经不存在 + # Process has already been gone pass except Exception as e: - logger.error(f"终止进程组失败: {simulation_id}, error={e}") - # 回退到直接终止进程 + logger.error(f"Failed to terminate the process group: {simulation_id}, error={e}") + # Fall back to terminating the process directly try: process.terminate() process.wait(timeout=5) @@ -809,16 +809,16 @@ class SimulationRunner: state.completed_at = datetime.now().isoformat() cls._save_run_state(state) - # 停止图谱记忆更新器 + # Stop the graph memory updater if cls._graph_memory_enabled.get(simulation_id, False): try: ZepGraphMemoryManager.stop_updater(simulation_id) - logger.info(f"已停止图谱记忆更新: simulation_id={simulation_id}") + logger.info(f"Graph memory update has been stopped: simulation_id={simulation_id}") except Exception as e: - logger.error(f"停止图谱记忆更新器失败: {e}") + logger.error(f"Failed to stop graph memory updater: {e}") cls._graph_memory_enabled.pop(simulation_id, None) - logger.info(f"模拟已停止: {simulation_id}") + logger.info(f"Simulation has been stopped: {simulation_id}") return state @classmethod @@ -831,14 +831,14 @@ class SimulationRunner: round_num: Optional[int] = None ) -> List[AgentAction]: """ - 从单个动作文件中读取动作 + Read actions from a single action file Args: - file_path: 动作日志文件路径 - default_platform: 默认平台(当动作记录中没有 platform 字段时使用) - platform_filter: 过滤平台 - agent_id: 过滤 Agent ID - round_num: 过滤轮次 + file_path: Action log file path + default_platform: default platform(When the action record has no platform field is used) + platform_filter: filter platform + agent_id: filter Agent ID + round_num: filter rounds """ if not os.path.exists(file_path): return [] @@ -854,18 +854,18 @@ class SimulationRunner: try: data = json.loads(line) - # 跳过非动作记录(如 simulation_start, round_start, round_end 等事件) + # Skip non-action records(e.g. simulation_start, round_start, round_end and other events) if "event_type" in data: continue - # 跳过没有 agent_id 的记录(非 Agent 动作) + # skip records without agent_id record(non- Agent action) if "agent_id" not in data: continue - # 获取平台:优先使用记录中的 platform,否则使用默认平台 + # get platform:Prefer the one in the record platform,otherwise use the default platform record_platform = data.get("platform") or default_platform or "" - # 过滤 + # filter if platform_filter and record_platform != platform_filter: continue if agent_id is not None and data.get("agent_id") != agent_id: @@ -899,54 +899,54 @@ class SimulationRunner: round_num: Optional[int] = None ) -> List[AgentAction]: """ - 获取所有平台的完整动作历史(无分页限制) + Get complete action history of all platforms(no pagination limit) Args: - simulation_id: 模拟ID - platform: 过滤平台(twitter/reddit) - agent_id: 过滤Agent - round_num: 过滤轮次 + simulation_id: simulationID + platform: filter platform(twitter/reddit) + agent_id: filterAgent + round_num: filter rounds Returns: - 完整的动作列表(按时间戳排序,新的在前) + Complete action list(sort by timestamp,newest first) """ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) actions = [] - # 读取 Twitter 动作文件(根据文件路径自动设置 platform 为 twitter) + # read Twitter action file(Automatically set based on the file path platform as twitter) twitter_actions_log = os.path.join(sim_dir, "twitter", "actions.jsonl") if not platform or platform == "twitter": actions.extend(cls._read_actions_from_file( twitter_actions_log, - default_platform="twitter", # 自动填充 platform 字段 + default_platform="twitter", # auto-populate platform field platform_filter=platform, agent_id=agent_id, round_num=round_num )) - # 读取 Reddit 动作文件(根据文件路径自动设置 platform 为 reddit) + # read Reddit action file(Automatically set based on the file path platform as reddit) reddit_actions_log = os.path.join(sim_dir, "reddit", "actions.jsonl") if not platform or platform == "reddit": actions.extend(cls._read_actions_from_file( reddit_actions_log, - default_platform="reddit", # 自动填充 platform 字段 + default_platform="reddit", # auto-populate platform field platform_filter=platform, agent_id=agent_id, round_num=round_num )) - # 如果分平台文件不存在,尝试读取旧的单一文件格式 + # If the per-platform file does not exist,Try to read the old single-file format if not actions: actions_log = os.path.join(sim_dir, "actions.jsonl") actions = cls._read_actions_from_file( actions_log, - default_platform=None, # 旧格式文件中应该有 platform 字段 + default_platform=None, # The old format file should have platform field platform_filter=platform, agent_id=agent_id, round_num=round_num ) - # 按时间戳排序(新的在前) + # sort by timestamp(newest first) actions.sort(key=lambda x: x.timestamp, reverse=True) return actions @@ -962,18 +962,18 @@ class SimulationRunner: round_num: Optional[int] = None ) -> List[AgentAction]: """ - 获取动作历史(带分页) + Get action history(with pagination) Args: - simulation_id: 模拟ID - limit: 返回数量限制 - offset: 偏移量 - platform: 过滤平台 - agent_id: 过滤Agent - round_num: 过滤轮次 + simulation_id: simulationID + limit: quantity return limit + offset: offset + platform: filter platform + agent_id: filterAgent + round_num: filter rounds Returns: - 动作列表 + action list """ actions = cls.get_all_actions( simulation_id=simulation_id, @@ -982,7 +982,7 @@ class SimulationRunner: round_num=round_num ) - # 分页 + # pagination return actions[offset:offset + limit] @classmethod @@ -993,19 +993,19 @@ class SimulationRunner: end_round: Optional[int] = None ) -> List[Dict[str, Any]]: """ - 获取模拟时间线(按轮次汇总) + Get the simulation timeline(summarized by round) Args: - simulation_id: 模拟ID - start_round: 起始轮次 - end_round: 结束轮次 + simulation_id: simulationID + start_round: start round + end_round: end round Returns: - 每轮的汇总信息 + Summary information per round """ actions = cls.get_actions(simulation_id, limit=10000) - # 按轮次分组 + # group by round rounds: Dict[int, Dict[str, Any]] = {} for action in actions: @@ -1038,7 +1038,7 @@ class SimulationRunner: r["action_types"][action.action_type] = r["action_types"].get(action.action_type, 0) + 1 r["last_action_time"] = action.timestamp - # 转换为列表 + # Convert to list result = [] for round_num in sorted(rounds.keys()): r = rounds[round_num] @@ -1059,10 +1059,10 @@ class SimulationRunner: @classmethod def get_agent_stats(cls, simulation_id: str) -> List[Dict[str, Any]]: """ - 获取每个Agent的统计信息 + get eachAgentstatistics Returns: - Agent统计列表 + Agentstatistics list """ actions = cls.get_actions(simulation_id, limit=10000) @@ -1094,7 +1094,7 @@ class SimulationRunner: stats["action_types"][action.action_type] = stats["action_types"].get(action.action_type, 0) + 1 stats["last_action_time"] = action.timestamp - # 按总动作数排序 + # Sort by total action count result = sorted(agent_stats.values(), key=lambda x: x["total_actions"], reverse=True) return result @@ -1102,51 +1102,51 @@ class SimulationRunner: @classmethod def cleanup_simulation_logs(cls, simulation_id: str) -> Dict[str, Any]: """ - 清理模拟的运行日志(用于强制重新开始模拟) + Clean up simulation runtime logs(Used to force-restart the simulation) - 会删除以下文件: + will delete the following files: - run_state.json - twitter/actions.jsonl - reddit/actions.jsonl - simulation.log - stdout.log / stderr.log - - twitter_simulation.db(模拟数据库) - - reddit_simulation.db(模拟数据库) - - env_status.json(环境状态) + - twitter_simulation.db(simulation database) + - reddit_simulation.db(simulation database) + - env_status.json(environment state) - 注意:不会删除配置文件(simulation_config.json)和 profile 文件 + Note:will not delete the configuration file(simulation_config.json) and profile file Args: - simulation_id: 模拟ID + simulation_id: simulationID Returns: - 清理结果信息 + cleanup result information """ import shutil sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): - return {"success": True, "message": "模拟目录不存在,无需清理"} + return {"success": True, "message": "Simulation directory does not exist,no need to clean up"} cleaned_files = [] errors = [] - # 要删除的文件列表(包括数据库文件) + # List of files to delete(including database files) files_to_delete = [ "run_state.json", "simulation.log", "stdout.log", "stderr.log", - "twitter_simulation.db", # Twitter 平台数据库 - "reddit_simulation.db", # Reddit 平台数据库 - "env_status.json", # 环境状态文件 + "twitter_simulation.db", # Twitter platform database + "reddit_simulation.db", # Reddit platform database + "env_status.json", # environment state file ] - # 要删除的目录列表(包含动作日志) + # List of directories to delete(contains action logs) dirs_to_clean = ["twitter", "reddit"] - # 删除文件 + # delete files for filename in files_to_delete: file_path = os.path.join(sim_dir, filename) if os.path.exists(file_path): @@ -1154,9 +1154,9 @@ class SimulationRunner: os.remove(file_path) cleaned_files.append(filename) except Exception as e: - errors.append(f"删除 {filename} 失败: {str(e)}") + errors.append(f"delete {filename} failed: {str(e)}") - # 清理平台目录中的动作日志 + # Clean up action logs in the platform directory for dir_name in dirs_to_clean: dir_path = os.path.join(sim_dir, dir_name) if os.path.exists(dir_path): @@ -1166,13 +1166,13 @@ class SimulationRunner: os.remove(actions_file) cleaned_files.append(f"{dir_name}/actions.jsonl") except Exception as e: - errors.append(f"删除 {dir_name}/actions.jsonl 失败: {str(e)}") + errors.append(f"delete {dir_name}/actions.jsonl failed: {str(e)}") - # 清理内存中的运行状态 + # Clean up the in-memory running state if simulation_id in cls._run_states: del cls._run_states[simulation_id] - logger.info(f"清理模拟日志完成: {simulation_id}, 删除文件: {cleaned_files}") + logger.info(f"Simulation log cleanup completed: {simulation_id}, delete files: {cleaned_files}") return { "success": len(errors) == 0, @@ -1180,71 +1180,71 @@ class SimulationRunner: "errors": errors if errors else None } - # 防止重复清理的标志 + # Flag to prevent repeated cleanup _cleanup_done = False @classmethod def cleanup_all_simulations(cls): """ - 清理所有运行中的模拟进程 + Clean up all running simulation processes - 在服务器关闭时调用,确保所有子进程被终止 + Called when the server shuts down,Ensure all child processes are terminated """ - # 防止重复清理 + # prevent repeated cleanup if cls._cleanup_done: return cls._cleanup_done = True - # 检查是否有内容需要清理(避免空进程的进程打印无用日志) + # Check whether there is anything to clean up(Avoid empty processes printing useless logs) has_processes = bool(cls._processes) has_updaters = bool(cls._graph_memory_enabled) if not has_processes and not has_updaters: - return # 没有需要清理的内容,静默返回 + return # No content to clean up,silently return - logger.info("正在清理所有模拟进程...") + logger.info("Cleaning up all simulation processes...") - # 首先停止所有图谱记忆更新器(stop_all 内部会打印日志) + # First stop all graph memory updaters(stop_all logs will be printed internally) try: ZepGraphMemoryManager.stop_all() except Exception as e: - logger.error(f"停止图谱记忆更新器失败: {e}") + logger.error(f"Failed to stop graph memory updater: {e}") cls._graph_memory_enabled.clear() - # 复制字典以避免在迭代时修改 + # Copy the dictionary to avoid modification during iteration processes = list(cls._processes.items()) for simulation_id, process in processes: try: - if process.poll() is None: # 进程仍在运行 - logger.info(f"终止模拟进程: {simulation_id}, pid={process.pid}") + if process.poll() is None: # Process is still running + logger.info(f"Terminate the simulation process: {simulation_id}, pid={process.pid}") try: - # 使用跨平台的进程终止方法 + # Use a cross-platform process termination method cls._terminate_process(process, simulation_id, timeout=5) except (ProcessLookupError, OSError): - # 进程可能已经不存在,尝试直接终止 + # Process may have already been gone,try to terminate directly try: process.terminate() process.wait(timeout=3) except Exception: process.kill() - # 更新 run_state.json + # update run_state.json state = cls.get_run_state(simulation_id) if state: state.runner_status = RunnerStatus.STOPPED state.twitter_running = False state.reddit_running = False state.completed_at = datetime.now().isoformat() - state.error = "服务器关闭,模拟被终止" + state.error = "Server shutdown,Simulation has been terminated" cls._save_run_state(state) - # 同时更新 state.json,将状态设为 stopped + # also update state.json,set the status to stopped try: sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) state_file = os.path.join(sim_dir, "state.json") - logger.info(f"尝试更新 state.json: {state_file}") + logger.info(f"try to update state.json: {state_file}") if os.path.exists(state_file): with open(state_file, 'r', encoding='utf-8') as f: state_data = json.load(f) @@ -1252,16 +1252,16 @@ class SimulationRunner: state_data['updated_at'] = datetime.now().isoformat() with open(state_file, 'w', encoding='utf-8') as f: json.dump(state_data, f, indent=2, ensure_ascii=False) - logger.info(f"已更新 state.json 状态为 stopped: {simulation_id}") + logger.info(f"updated state.json status is stopped: {simulation_id}") else: - logger.warning(f"state.json 不存在: {state_file}") + logger.warning(f"state.json does not exist: {state_file}") except Exception as state_err: - logger.warning(f"更新 state.json 失败: {simulation_id}, error={state_err}") + logger.warning(f"update state.json failed: {simulation_id}, error={state_err}") except Exception as e: - logger.error(f"清理进程失败: {simulation_id}, error={e}") + logger.error(f"failed to clean up the process: {simulation_id}, error={e}") - # 清理文件句柄 + # clean up file handles for simulation_id, file_handle in list(cls._stdout_files.items()): try: if file_handle: @@ -1278,89 +1278,89 @@ class SimulationRunner: pass cls._stderr_files.clear() - # 清理内存中的状态 + # Clean up in-memory state cls._processes.clear() cls._action_queues.clear() - logger.info("模拟进程清理完成") + logger.info("Simulation process cleanup completed") @classmethod def register_cleanup(cls): """ - 注册清理函数 + Register cleanup function - 在 Flask 应用启动时调用,确保服务器关闭时清理所有模拟进程 + in Flask Called at application startup,Ensure all simulation processes are cleaned up when the server shuts down """ global _cleanup_registered if _cleanup_registered: return - # Flask debug 模式下,只在 reloader 子进程中注册清理(实际运行应用的进程) - # WERKZEUG_RUN_MAIN=true 表示是 reloader 子进程 - # 如果不是 debug 模式,则没有这个环境变量,也需要注册 + # Flask debug mode,only in reloader register cleanup in the child process(the process actually running the application) + # WERKZEUG_RUN_MAIN=true indicates it is reloader child process + # if it is not debug mode,this environment variable is not set,also needs to register is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true' is_debug_mode = os.environ.get('FLASK_DEBUG') == '1' or os.environ.get('WERKZEUG_RUN_MAIN') is not None - # 在 debug 模式下,只在 reloader 子进程中注册;非 debug 模式下始终注册 + # in debug mode,only in reloader register in the child process;non- debug always register in this mode if is_debug_mode and not is_reloader_process: - _cleanup_registered = True # 标记已注册,防止子进程再次尝试 + _cleanup_registered = True # Mark as registered,Prevent the child process from trying again return - # 保存原有的信号处理器 + # Save the original signal handler original_sigint = signal.getsignal(signal.SIGINT) original_sigterm = signal.getsignal(signal.SIGTERM) - # SIGHUP 只在 Unix 系统存在(macOS/Linux),Windows 没有 + # SIGHUP only in Unix system exists(macOS/Linux),Windows have no original_sighup = None has_sighup = hasattr(signal, 'SIGHUP') if has_sighup: original_sighup = signal.getsignal(signal.SIGHUP) def cleanup_handler(signum=None, frame=None): - """信号处理器:先清理模拟进程,再调用原处理器""" - # 只有在有进程需要清理时才打印日志 + """signal handler:first clean up the simulation processes,then call the original handler""" + # Only print logs when there are processes to clean up if cls._processes or cls._graph_memory_enabled: - logger.info(f"收到信号 {signum},开始清理...") + logger.info(f"received signal {signum},start cleanup...") cls.cleanup_all_simulations() - # 调用原有的信号处理器,让 Flask 正常退出 + # Call the original signal handler,let Flask exit normally if signum == signal.SIGINT and callable(original_sigint): original_sigint(signum, frame) elif signum == signal.SIGTERM and callable(original_sigterm): original_sigterm(signum, frame) elif has_sighup and signum == signal.SIGHUP: - # SIGHUP: 终端关闭时发送 + # SIGHUP: sent when the terminal is closed if callable(original_sighup): original_sighup(signum, frame) else: - # 默认行为:正常退出 + # default behavior:exit normally sys.exit(0) else: - # 如果原处理器不可调用(如 SIG_DFL),则使用默认行为 + # If the original handler is not callable(e.g. SIG_DFL),use the default behavior raise KeyboardInterrupt - # 注册 atexit 处理器(作为备用) + # register atexit handler(as a fallback) atexit.register(cls.cleanup_all_simulations) - # 注册信号处理器(仅在主线程中) + # Register signal handler(only in the main thread) try: - # SIGTERM: kill 命令默认信号 + # SIGTERM: kill command default signal signal.signal(signal.SIGTERM, cleanup_handler) # SIGINT: Ctrl+C signal.signal(signal.SIGINT, cleanup_handler) - # SIGHUP: 终端关闭(仅 Unix 系统) + # SIGHUP: terminal close (Unix systems only) if has_sighup: signal.signal(signal.SIGHUP, cleanup_handler) except ValueError: - # 不在主线程中,只能使用 atexit - logger.warning("无法注册信号处理器(不在主线程),仅使用 atexit") + # not in the main thread,can only use atexit + logger.warning("Cannot register signal handler(not in the main thread),only use atexit") _cleanup_registered = True @classmethod def get_running_simulations(cls) -> List[str]: """ - 获取所有正在运行的模拟ID列表 + Get all running simulationsIDlist """ running = [] for sim_id, process in cls._processes.items(): @@ -1368,18 +1368,18 @@ class SimulationRunner: running.append(sim_id) return running - # ============== Interview 功能 ============== + # ============== Interview function ============== @classmethod def check_env_alive(cls, simulation_id: str) -> bool: """ - 检查模拟环境是否存活(可以接收Interview命令) + Check whether the simulation environment is alive(can receiveInterviewcommand) Args: - simulation_id: 模拟ID + simulation_id: simulationID Returns: - True 表示环境存活,False 表示环境已关闭 + True indicates the environment is alive,False indicates the environment has been closed """ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): @@ -1391,13 +1391,13 @@ class SimulationRunner: @classmethod def get_env_status_detail(cls, simulation_id: str) -> Dict[str, Any]: """ - 获取模拟环境的详细状态信息 + Get detailed status information of the simulation environment Args: - simulation_id: 模拟ID + simulation_id: simulationID Returns: - 状态详情字典,包含 status, twitter_available, reddit_available, timestamp + status detail dictionary,contains status, twitter_available, reddit_available, timestamp """ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) status_file = os.path.join(sim_dir, "env_status.json") @@ -1434,35 +1434,35 @@ class SimulationRunner: timeout: float = 60.0 ) -> Dict[str, Any]: """ - 采访单个Agent + interview a singleAgent Args: - simulation_id: 模拟ID + simulation_id: simulationID agent_id: Agent ID - prompt: 采访问题 - platform: 指定平台(可选) - - "twitter": 只采访Twitter平台 - - "reddit": 只采访Reddit平台 - - None: 双平台模拟时同时采访两个平台,返回整合结果 - timeout: 超时时间(秒) + prompt: interview question + platform: specify platform(optional) + - "twitter": only interviewTwitterplatform + - "reddit": only interviewRedditplatform + - None: In dual-platform simulations, interview both platforms simultaneously,return integrated result + timeout: timeout(seconds) Returns: - 采访结果字典 + interview result dictionary Raises: - ValueError: 模拟不存在或环境未运行 - TimeoutError: 等待响应超时 + ValueError: Simulation does not exist or environment is not running + TimeoutError: Waiting for response timed out """ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(f"Simulation does not exist: {simulation_id}") ipc_client = SimulationIPCClient(sim_dir) if not ipc_client.check_env_alive(): - raise ValueError(f"模拟环境未运行或已关闭,无法执行Interview: {simulation_id}") + raise ValueError(f"Simulation environment not running or has been closed,cannot executeInterview: {simulation_id}") - logger.info(f"发送Interview命令: simulation_id={simulation_id}, agent_id={agent_id}, platform={platform}") + logger.info(f"sendInterviewcommand: simulation_id={simulation_id}, agent_id={agent_id}, platform={platform}") response = ipc_client.send_interview( agent_id=agent_id, @@ -1497,34 +1497,34 @@ class SimulationRunner: timeout: float = 120.0 ) -> Dict[str, Any]: """ - 批量采访多个Agent + Batch interview multipleAgent Args: - simulation_id: 模拟ID - interviews: 采访列表,每个元素包含 {"agent_id": int, "prompt": str, "platform": str(可选)} - platform: 默认平台(可选,会被每个采访项的platform覆盖) - - "twitter": 默认只采访Twitter平台 - - "reddit": 默认只采访Reddit平台 - - None: 双平台模拟时每个Agent同时采访两个平台 - timeout: 超时时间(秒) + simulation_id: simulationID + interviews: interview list,Each element contains {"agent_id": int, "prompt": str, "platform": str(optional)} + platform: default platform(optional,will be overridden by each interview item'splatformoverride) + - "twitter": by default only interviewTwitterplatform + - "reddit": by default only interviewRedditplatform + - None: In dual-platform simulations, eachAgentInterview both platforms simultaneously + timeout: timeout(seconds) Returns: - 批量采访结果字典 + Batch interview result dictionary Raises: - ValueError: 模拟不存在或环境未运行 - TimeoutError: 等待响应超时 + ValueError: Simulation does not exist or environment is not running + TimeoutError: Waiting for response timed out """ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(f"Simulation does not exist: {simulation_id}") ipc_client = SimulationIPCClient(sim_dir) if not ipc_client.check_env_alive(): - raise ValueError(f"模拟环境未运行或已关闭,无法执行Interview: {simulation_id}") + raise ValueError(f"Simulation environment not running or has been closed,cannot executeInterview: {simulation_id}") - logger.info(f"发送批量Interview命令: simulation_id={simulation_id}, count={len(interviews)}, platform={platform}") + logger.info(f"send batchInterviewcommand: simulation_id={simulation_id}, count={len(interviews)}, platform={platform}") response = ipc_client.send_batch_interview( interviews=interviews, @@ -1556,39 +1556,39 @@ class SimulationRunner: timeout: float = 180.0 ) -> Dict[str, Any]: """ - 采访所有Agent(全局采访) + interview allAgent(global interview) - 使用相同的问题采访模拟中的所有Agent + Use the same question to interview all simulatedAgent Args: - simulation_id: 模拟ID - prompt: 采访问题(所有Agent使用相同问题) - platform: 指定平台(可选) - - "twitter": 只采访Twitter平台 - - "reddit": 只采访Reddit平台 - - None: 双平台模拟时每个Agent同时采访两个平台 - timeout: 超时时间(秒) + simulation_id: simulationID + prompt: interview question(allAgentuse the same question) + platform: specify platform(optional) + - "twitter": only interviewTwitterplatform + - "reddit": only interviewRedditplatform + - None: In dual-platform simulations, eachAgentInterview both platforms simultaneously + timeout: timeout(seconds) Returns: - 全局采访结果字典 + Global interview result dictionary """ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(f"Simulation does not exist: {simulation_id}") - # 从配置文件获取所有Agent信息 + # Get all from the configuration fileAgentinformation config_path = os.path.join(sim_dir, "simulation_config.json") if not os.path.exists(config_path): - raise ValueError(f"模拟配置不存在: {simulation_id}") + raise ValueError(f"Simulation configuration does not exist: {simulation_id}") with open(config_path, 'r', encoding='utf-8') as f: config = json.load(f) agent_configs = config.get("agent_configs", []) if not agent_configs: - raise ValueError(f"模拟配置中没有Agent: {simulation_id}") + raise ValueError(f"Simulation configuration has noAgent: {simulation_id}") - # 构建批量采访列表 + # Build the batch interview list interviews = [] for agent_config in agent_configs: agent_id = agent_config.get("agent_id") @@ -1598,7 +1598,7 @@ class SimulationRunner: "prompt": prompt }) - logger.info(f"发送全局Interview命令: simulation_id={simulation_id}, agent_count={len(interviews)}, platform={platform}") + logger.info(f"send globalInterviewcommand: simulation_id={simulation_id}, agent_count={len(interviews)}, platform={platform}") return cls.interview_agents_batch( simulation_id=simulation_id, @@ -1614,45 +1614,45 @@ class SimulationRunner: timeout: float = 30.0 ) -> Dict[str, Any]: """ - 关闭模拟环境(而不是停止模拟进程) + close the simulation environment(rather than stopping the simulation process) - 向模拟发送关闭环境命令,使其优雅退出等待命令模式 + Send a close-environment command to the simulation,cause it to gracefully exit the wait-for-command mode Args: - simulation_id: 模拟ID - timeout: 超时时间(秒) + simulation_id: simulationID + timeout: timeout(seconds) Returns: - 操作结果字典 + operation result dictionary """ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) if not os.path.exists(sim_dir): - raise ValueError(f"模拟不存在: {simulation_id}") + raise ValueError(f"Simulation does not exist: {simulation_id}") ipc_client = SimulationIPCClient(sim_dir) if not ipc_client.check_env_alive(): return { "success": True, - "message": "环境已经关闭" + "message": "Environment has already been closed" } - logger.info(f"发送关闭环境命令: simulation_id={simulation_id}") + logger.info(f"Send the close-environment command: simulation_id={simulation_id}") try: response = ipc_client.send_close_env(timeout=timeout) return { "success": response.status.value == "completed", - "message": "环境关闭命令已发送", + "message": "Environment close command has been sent", "result": response.result, "timestamp": response.timestamp } except TimeoutError: - # 超时可能是因为环境正在关闭 + # Timeout may be because the environment is shutting down return { "success": True, - "message": "环境关闭命令已发送(等待响应超时,环境可能正在关闭)" + "message": "Environment close command has been sent(Waiting for response timed out,Environment may be shutting down)" } @classmethod @@ -1663,7 +1663,7 @@ class SimulationRunner: agent_id: Optional[int] = None, limit: int = 100 ) -> List[Dict[str, Any]]: - """从单个数据库获取Interview历史""" + """Get from a single databaseInterviewhistory""" import sqlite3 if not os.path.exists(db_path): @@ -1709,7 +1709,7 @@ class SimulationRunner: conn.close() except Exception as e: - logger.error(f"读取Interview历史失败 ({platform_name}): {e}") + logger.error(f"readInterviewhistory failed ({platform_name}): {e}") return results @@ -1722,29 +1722,29 @@ class SimulationRunner: limit: int = 100 ) -> List[Dict[str, Any]]: """ - 获取Interview历史记录(从数据库读取) + getInterviewhistory record(read from database) Args: - simulation_id: 模拟ID - platform: 平台类型(reddit/twitter/None) - - "reddit": 只获取Reddit平台的历史 - - "twitter": 只获取Twitter平台的历史 - - None: 获取两个平台的所有历史 - agent_id: 指定Agent ID(可选,只获取该Agent的历史) - limit: 每个平台返回数量限制 + simulation_id: simulationID + platform: platform type(reddit/twitter/None) + - "reddit": only getRedditplatform history + - "twitter": only getTwitterplatform history + - None: Get history of both platforms + agent_id: specifyAgent ID(optional,only get theAgenthistory) + limit: Quantity limit per platform return Returns: - Interview历史记录列表 + Interviewhistory record list """ sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) results = [] - # 确定要查询的平台 + # Determine the platforms to query if platform in ("reddit", "twitter"): platforms = [platform] else: - # 不指定platform时,查询两个平台 + # not specifiedplatformtime,Query both platforms platforms = ["twitter", "reddit"] for p in platforms: @@ -1757,10 +1757,10 @@ class SimulationRunner: ) results.extend(platform_results) - # 按时间降序排序 + # Sort by time in descending order results.sort(key=lambda x: x.get("timestamp", ""), reverse=True) - # 如果查询了多个平台,限制总数 + # If multiple platforms are queried,limit total count if len(platforms) > 1 and len(results) > limit: results = results[:limit] diff --git a/backend/app/services/text_processor.py b/backend/app/services/text_processor.py index 91e32acc..ff9a22f8 100644 --- a/backend/app/services/text_processor.py +++ b/backend/app/services/text_processor.py @@ -1,5 +1,5 @@ """ -文本处理服务 +Text processing service """ from typing import List, Optional @@ -7,11 +7,11 @@ from ..utils.file_parser import FileParser, split_text_into_chunks class TextProcessor: - """文本处理器""" + """Text processor""" @staticmethod def extract_from_files(file_paths: List[str]) -> str: - """从多个文件提取文本""" + """Extract text from multiple files""" return FileParser.extract_from_multiple(file_paths) @staticmethod @@ -21,40 +21,40 @@ class TextProcessor: overlap: int = 50 ) -> List[str]: """ - 分割文本 + Split text into chunks Args: - text: 原始文本 - chunk_size: 块大小 - overlap: 重叠大小 + text: raw text + chunk_size: chunk size + overlap: overlap size Returns: - 文本块列表 + list of text chunks """ return split_text_into_chunks(text, chunk_size, overlap) @staticmethod def preprocess_text(text: str) -> str: """ - 预处理文本 - - 移除多余空白 - - 标准化换行 + Preprocess text + - Strip extra whitespace + - Normalize newlines Args: - text: 原始文本 + text: raw text Returns: - 处理后的文本 + processed text """ import re - # 标准化换行 + # Normalize newlines text = text.replace('\r\n', '\n').replace('\r', '\n') - # 移除连续空行(保留最多两个换行) + # Collapse consecutive blank lines(Keep at most two newlines in a row) text = re.sub(r'\n{3,}', '\n\n', text) - # 移除行首行尾空白 + # Strip leading/trailing whitespace per line lines = [line.strip() for line in text.split('\n')] text = '\n'.join(lines) @@ -62,7 +62,7 @@ class TextProcessor: @staticmethod def get_text_stats(text: str) -> dict: - """获取文本统计信息""" + """Get text statistics""" return { "total_chars": len(text), "total_lines": text.count('\n') + 1, diff --git a/backend/app/services/zep_entity_reader.py b/backend/app/services/zep_entity_reader.py index 69ea04fd..c457c75b 100644 --- a/backend/app/services/zep_entity_reader.py +++ b/backend/app/services/zep_entity_reader.py @@ -1,6 +1,7 @@ """ -Zep实体读取与过滤服务 -从Zep图谱中读取节点,筛选出符合预定义实体类型的节点 +Zep entity read & filter service +Reads nodes from the Zep graph and filters out nodes that match +predefined entity types. """ import time @@ -25,23 +26,23 @@ from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges logger = get_logger('mirofish.zep_entity_reader') -# 用于泛型返回类型 +# Used for generic return type T = TypeVar('T') @dataclass class EntityNode: - """实体节点数据结构""" + """Entity node data structure""" uuid: str name: str labels: List[str] summary: str attributes: Dict[str, Any] - # 相关的边信息 + # Related edge info related_edges: List[Dict[str, Any]] = field(default_factory=list) - # 相关的其他节点信息 + # Other related-node info related_nodes: List[Dict[str, Any]] = field(default_factory=list) - + def to_dict(self) -> Dict[str, Any]: return { "uuid": self.uuid, @@ -52,9 +53,9 @@ class EntityNode: "related_edges": self.related_edges, "related_nodes": self.related_nodes, } - + def get_entity_type(self) -> Optional[str]: - """获取实体类型(排除默认的Entity标签)""" + """Get the entity type (excluding the default Entity label)""" for label in self.labels: if label not in ["Entity", "Node"]: return label @@ -63,12 +64,12 @@ class EntityNode: @dataclass class FilteredEntities: - """过滤后的实体集合""" + """Filtered entity collection""" entities: List[EntityNode] entity_types: Set[str] total_count: int filtered_count: int - + def to_dict(self) -> Dict[str, Any]: return { "entities": [e.to_dict() for e in self.entities], @@ -80,12 +81,13 @@ class FilteredEntities: class ZepEntityReader: """ - Zep实体读取与过滤服务 - - 主要功能: - 1. 从Zep图谱读取所有节点 - 2. 筛选出符合预定义实体类型的节点(Labels不只是Entity的节点) - 3. 获取每个实体的相关边和关联节点信息 + Zep entity read & filter service + + Main features: + 1. Read all nodes from the Zep graph + 2. Filter out nodes that match predefined entity types (nodes whose + labels are not just "Entity") + 3. Get the related edges and adjacent node info for each entity """ def __init__(self, api_key: Optional[str] = None): @@ -94,27 +96,27 @@ class ZepEntityReader: self.client = get_graphiti_adapter() def _call_with_retry( - self, - func: Callable[[], T], + self, + func: Callable[[], T], operation_name: str, max_retries: int = 3, initial_delay: float = 2.0 ) -> T: """ - 带重试机制的Zep API调用 - + Zep API call with retry logic. + Args: - func: 要执行的函数(无参数的lambda或callable) - operation_name: 操作名称,用于日志 - max_retries: 最大重试次数(默认3次,即最多尝试3次) - initial_delay: 初始延迟秒数 - + func: Function (parameterless lambda or callable) to execute + operation_name: Operation name, used in logs + max_retries: Maximum number of retries (default 3, i.e. at most 3 attempts) + initial_delay: Initial delay in seconds + Returns: - API调用结果 + The result of the API call """ last_exception = None delay = initial_delay - + for attempt in range(max_retries): try: return func() @@ -122,27 +124,27 @@ class ZepEntityReader: last_exception = e if attempt < max_retries - 1: logger.warning( - f"Zep {operation_name} 第 {attempt + 1} 次尝试失败: {str(e)[:100]}, " - f"{delay:.1f}秒后重试..." + f"Zep {operation_name} attempt {attempt + 1} failed: {str(e)[:100]}, " + f"retrying in {delay:.1f}s..." ) time.sleep(delay) - delay *= 2 # 指数退避 + delay *= 2 # Exponential backoff else: - logger.error(f"Zep {operation_name} 在 {max_retries} 次尝试后仍失败: {str(e)}") - + logger.error(f"Zep {operation_name} still failing after {max_retries} attempts: {str(e)}") + raise last_exception - + def get_all_nodes(self, graph_id: str) -> List[Dict[str, Any]]: """ - 获取图谱的所有节点(分页获取) + Get all nodes of the graph (paginated). Args: - graph_id: 图谱ID + graph_id: Graph ID Returns: - 节点列表 + List of nodes """ - logger.info(f"获取图谱 {graph_id} 的所有节点...") + logger.info(f"Fetching all nodes for graph {graph_id}...") nodes = fetch_all_nodes(self.client, graph_id) @@ -156,20 +158,20 @@ class ZepEntityReader: "attributes": node.attributes or {}, }) - logger.info(f"共获取 {len(nodes_data)} 个节点") + logger.info(f"Fetched {len(nodes_data)} nodes in total") return nodes_data def get_all_edges(self, graph_id: str) -> List[Dict[str, Any]]: """ - 获取图谱的所有边(分页获取) + Get all edges of the graph (paginated). Args: - graph_id: 图谱ID + graph_id: Graph ID Returns: - 边列表 + List of edges """ - logger.info(f"获取图谱 {graph_id} 的所有边...") + logger.info(f"Fetching all edges for graph {graph_id}...") edges = fetch_all_edges(self.client, graph_id) @@ -184,26 +186,26 @@ class ZepEntityReader: "attributes": edge.attributes or {}, }) - logger.info(f"共获取 {len(edges_data)} 条边") + logger.info(f"Fetched {len(edges_data)} edges in total") return edges_data - + def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]: """ - 获取指定节点的所有相关边(带重试机制) - + Get all related edges of a given node (with retry logic). + Args: - node_uuid: 节点UUID - + node_uuid: Node UUID + Returns: - 边列表 + List of edges """ try: # Graphiti via adapter edges = self._call_with_retry( func=lambda: self.client.get_node_edges(node_uuid=node_uuid), - operation_name=f"获取节点边(node={node_uuid[:8]}...)" + operation_name=f"get_node_edges(node={node_uuid[:8]}...)" ) - + edges_data = [] for edge in edges: edges_data.append({ @@ -214,60 +216,63 @@ class ZepEntityReader: "target_node_uuid": edge.target_node_uuid, "attributes": edge.attributes or {}, }) - + return edges_data except Exception as e: - logger.warning(f"获取节点 {node_uuid} 的边失败: {str(e)}") + logger.warning(f"Failed to fetch edges for node {node_uuid}: {str(e)}") return [] def filter_defined_entities( - self, + self, graph_id: str, defined_entity_types: Optional[List[str]] = None, enrich_with_edges: bool = True ) -> FilteredEntities: """ - 筛选出符合预定义实体类型的节点 - - 筛选逻辑: - - 如果节点的Labels只有一个"Entity",说明这个实体不符合我们预定义的类型,跳过 - - 如果节点的Labels包含除"Entity"和"Node"之外的标签,说明符合预定义类型,保留 - + Filter out nodes that match predefined entity types. + + Filter logic: + - If a node's labels contain only "Entity", it does not match + any of our predefined types and is skipped. + - If a node's labels include something other than "Entity" and + "Node", it matches a predefined type and is kept. + Args: - graph_id: 图谱ID - defined_entity_types: 预定义的实体类型列表(可选,如果提供则只保留这些类型) - enrich_with_edges: 是否获取每个实体的相关边信息 - + graph_id: Graph ID + defined_entity_types: List of predefined entity types (optional; if + provided, only these types are kept) + enrich_with_edges: Whether to fetch related edges for each entity + Returns: - FilteredEntities: 过滤后的实体集合 + FilteredEntities: the filtered entity collection """ - logger.info(f"开始筛选图谱 {graph_id} 的实体...") - - # 获取所有节点 + logger.info(f"Starting entity filtering for graph {graph_id}...") + + # Get all nodes all_nodes = self.get_all_nodes(graph_id) total_count = len(all_nodes) - - # 获取所有边(用于后续关联查找) + + # Get all edges (used later for adjacency lookup) all_edges = self.get_all_edges(graph_id) if enrich_with_edges else [] - - # 构建节点UUID到节点数据的映射 + + # Build a UUID -> node map node_map = {n["uuid"]: n for n in all_nodes} - - # 筛选符合条件的实体 + + # Filter entities that meet the criteria filtered_entities = [] entity_types_found = set() - + for node in all_nodes: labels = node.get("labels", []) - - # 筛选逻辑:Labels必须包含除"Entity"和"Node"之外的标签 + + # Filter logic: labels must contain something other than "Entity" and "Node" custom_labels = [l for l in labels if l not in ["Entity", "Node"]] - + if not custom_labels: - # 只有默认标签,跳过 + # Only the default labels, skip continue - - # 如果指定了预定义类型,检查是否匹配 + + # If predefined types are specified, check for a match if defined_entity_types: matching_labels = [l for l in custom_labels if l in defined_entity_types] if not matching_labels: @@ -275,10 +280,10 @@ class ZepEntityReader: entity_type = matching_labels[0] else: entity_type = custom_labels[0] - + entity_types_found.add(entity_type) - - # 创建实体节点对象 + + # Build the entity node object entity = EntityNode( uuid=node["uuid"], name=node["name"], @@ -286,12 +291,12 @@ class ZepEntityReader: summary=node["summary"], attributes=node["attributes"], ) - - # 获取相关边和节点 + + # Fetch related edges and nodes if enrich_with_edges: related_edges = [] related_node_uuids = set() - + for edge in all_edges: if edge["source_node_uuid"] == node["uuid"]: related_edges.append({ @@ -309,10 +314,10 @@ class ZepEntityReader: "source_node_uuid": edge["source_node_uuid"], }) related_node_uuids.add(edge["source_node_uuid"]) - + entity.related_edges = related_edges - - # 获取关联节点的基本信息 + + # Get basic info for related nodes related_nodes = [] for related_uuid in related_node_uuids: if related_uuid in node_map: @@ -323,57 +328,57 @@ class ZepEntityReader: "labels": related_node["labels"], "summary": related_node.get("summary", ""), }) - + entity.related_nodes = related_nodes - + filtered_entities.append(entity) - - logger.info(f"筛选完成: 总节点 {total_count}, 符合条件 {len(filtered_entities)}, " - f"实体类型: {entity_types_found}") - + + logger.info(f"Filtering complete: total nodes {total_count}, matching {len(filtered_entities)}, " + f"entity types: {entity_types_found}") + return FilteredEntities( entities=filtered_entities, entity_types=entity_types_found, total_count=total_count, filtered_count=len(filtered_entities), ) - + def get_entity_with_context( - self, - graph_id: str, + self, + graph_id: str, entity_uuid: str ) -> Optional[EntityNode]: """ - 获取单个实体及其完整上下文(边和关联节点,带重试机制) - + Get a single entity with its full context (edges and related nodes, with retry). + Args: - graph_id: 图谱ID - entity_uuid: 实体UUID - + graph_id: Graph ID + entity_uuid: Entity UUID + Returns: - EntityNode或None + EntityNode or None """ try: # Graphiti via adapter node = self._call_with_retry( func=lambda: self.client.get_node(node_uuid=entity_uuid), - operation_name=f"获取节点详情(uuid={entity_uuid[:8]}...)" + operation_name=f"get_node_detail(uuid={entity_uuid[:8]}...)" ) - + if not node: return None - - # 获取节点的边 + + # Get the node's edges edges = self.get_node_edges(entity_uuid) - - # 获取所有节点用于关联查找 + + # Get all nodes for adjacency lookup all_nodes = self.get_all_nodes(graph_id) node_map = {n["uuid"]: n for n in all_nodes} - - # 处理相关边和节点 + + # Process related edges and nodes related_edges = [] related_node_uuids = set() - + for edge in edges: if edge["source_node_uuid"] == entity_uuid: related_edges.append({ @@ -391,8 +396,8 @@ class ZepEntityReader: "source_node_uuid": edge["source_node_uuid"], }) related_node_uuids.add(edge["source_node_uuid"]) - - # 获取关联节点信息 + + # Get related node info related_nodes = [] for related_uuid in related_node_uuids: if related_uuid in node_map: @@ -403,7 +408,7 @@ class ZepEntityReader: "labels": related_node["labels"], "summary": related_node.get("summary", ""), }) - + return EntityNode( uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''), name=node.name or "", @@ -413,27 +418,27 @@ class ZepEntityReader: related_edges=related_edges, related_nodes=related_nodes, ) - + except Exception as e: - logger.error(f"获取实体 {entity_uuid} 失败: {str(e)}") + logger.error(f"Failed to fetch entity {entity_uuid}: {str(e)}") return None - + def get_entities_by_type( - self, - graph_id: str, + self, + graph_id: str, entity_type: str, enrich_with_edges: bool = True ) -> List[EntityNode]: """ - 获取指定类型的所有实体 - + Get all entities of a given type. + Args: - graph_id: 图谱ID - entity_type: 实体类型(如 "Student", "PublicFigure" 等) - enrich_with_edges: 是否获取相关边信息 - + graph_id: Graph ID + entity_type: Entity type (e.g. "Student", "PublicFigure") + enrich_with_edges: Whether to fetch related edges + Returns: - 实体列表 + List of entities """ result = self.filter_defined_entities( graph_id=graph_id, diff --git a/backend/app/services/zep_graph_memory_updater.py b/backend/app/services/zep_graph_memory_updater.py index 5973d785..12e6b396 100644 --- a/backend/app/services/zep_graph_memory_updater.py +++ b/backend/app/services/zep_graph_memory_updater.py @@ -1,6 +1,6 @@ """ -Zep图谱记忆更新服务 -将模拟中的Agent活动动态更新到Zep图谱中 +Zep Graph Memory Updater Service +Dynamically updates agent activities from the simulation into the Zep graph """ import os @@ -29,7 +29,7 @@ logger = get_logger('mirofish.zep_graph_memory_updater') @dataclass class AgentActivity: - """Agent活动记录""" + """Agent activity record""" platform: str # twitter / reddit agent_id: int agent_name: str @@ -37,15 +37,15 @@ class AgentActivity: action_args: Dict[str, Any] round_num: int timestamp: str - + def to_episode_text(self) -> str: """ - 将活动转换为可以发送给Zep的文本描述 - - 采用自然语言描述格式,让Zep能够从中提取实体和关系 - 不添加模拟相关的前缀,避免误导图谱更新 + Convert the activity into a text description that can be sent to Zep. + + Uses a natural-language description format so Zep can extract entities and relations from it. + No simulation-related prefix is added to avoid misleading the graph update. """ - # 根据不同的动作类型生成不同的描述 + # Generate a different description for each action type action_descriptions = { "CREATE_POST": self._describe_create_post, "LIKE_POST": self._describe_like_post, @@ -60,224 +60,226 @@ class AgentActivity: "SEARCH_USER": self._describe_search_user, "MUTE": self._describe_mute, } - + describe_func = action_descriptions.get(self.action_type, self._describe_generic) description = describe_func() - - # 直接返回 "agent名称: 活动描述" 格式,不添加模拟前缀 + + # Return directly in "agent_name: activity description" format, no simulation prefix return f"{self.agent_name}: {description}" def _describe_create_post(self) -> str: content = self.action_args.get("content", "") if content: - return f"发布了一条帖子:「{content}」" - return "发布了一条帖子" - + return f"published a post: \"{content}\"" + return "published a post" + def _describe_like_post(self) -> str: - """点赞帖子 - 包含帖子原文和作者信息""" + """Like a post — includes the post text and author info""" post_content = self.action_args.get("post_content", "") post_author = self.action_args.get("post_author_name", "") - + if post_content and post_author: - return f"点赞了{post_author}的帖子:「{post_content}」" + return f"liked {post_author}'s post: \"{post_content}\"" elif post_content: - return f"点赞了一条帖子:「{post_content}」" + return f"liked a post: \"{post_content}\"" elif post_author: - return f"点赞了{post_author}的一条帖子" - return "点赞了一条帖子" - + return f"liked a post by {post_author}" + return "liked a post" + def _describe_dislike_post(self) -> str: - """踩帖子 - 包含帖子原文和作者信息""" + """Dislike a post — includes the post text and author info""" post_content = self.action_args.get("post_content", "") post_author = self.action_args.get("post_author_name", "") - + if post_content and post_author: - return f"踩了{post_author}的帖子:「{post_content}」" + return f"disliked {post_author}'s post: \"{post_content}\"" elif post_content: - return f"踩了一条帖子:「{post_content}」" + return f"disliked a post: \"{post_content}\"" elif post_author: - return f"踩了{post_author}的一条帖子" - return "踩了一条帖子" - + return f"disliked a post by {post_author}" + return "disliked a post" + def _describe_repost(self) -> str: - """转发帖子 - 包含原帖内容和作者信息""" + """Repost — includes the original post text and author info""" original_content = self.action_args.get("original_content", "") original_author = self.action_args.get("original_author_name", "") - + if original_content and original_author: - return f"转发了{original_author}的帖子:「{original_content}」" + return f"reposted {original_author}'s post: \"{original_content}\"" elif original_content: - return f"转发了一条帖子:「{original_content}」" + return f"reposted a post: \"{original_content}\"" elif original_author: - return f"转发了{original_author}的一条帖子" - return "转发了一条帖子" - + return f"reposted a post by {original_author}" + return "reposted a post" + def _describe_quote_post(self) -> str: - """引用帖子 - 包含原帖内容、作者信息和引用评论""" + """Quote a post — includes the original post text, author info, and quote comment""" original_content = self.action_args.get("original_content", "") original_author = self.action_args.get("original_author_name", "") quote_content = self.action_args.get("quote_content", "") or self.action_args.get("content", "") - + base = "" if original_content and original_author: - base = f"引用了{original_author}的帖子「{original_content}」" + base = f"quoted {original_author}'s post \"{original_content}\"" elif original_content: - base = f"引用了一条帖子「{original_content}」" + base = f"quoted a post \"{original_content}\"" elif original_author: - base = f"引用了{original_author}的一条帖子" + base = f"quoted a post by {original_author}" else: - base = "引用了一条帖子" - + base = "quoted a post" + if quote_content: - base += f",并评论道:「{quote_content}」" + base += f", and commented: \"{quote_content}\"" return base - + def _describe_follow(self) -> str: - """关注用户 - 包含被关注用户的名称""" + """Follow a user — includes the name of the followed user""" target_user_name = self.action_args.get("target_user_name", "") - + if target_user_name: - return f"关注了用户「{target_user_name}」" - return "关注了一个用户" - + return f"followed user \"{target_user_name}\"" + return "followed a user" + def _describe_create_comment(self) -> str: - """发表评论 - 包含评论内容和所评论的帖子信息""" + """Post a comment — includes the comment content and the commented post info""" content = self.action_args.get("content", "") post_content = self.action_args.get("post_content", "") post_author = self.action_args.get("post_author_name", "") - + if content: if post_content and post_author: - return f"在{post_author}的帖子「{post_content}」下评论道:「{content}」" + return f"commented on {post_author}'s post \"{post_content}\": \"{content}\"" elif post_content: - return f"在帖子「{post_content}」下评论道:「{content}」" + return f"commented on the post \"{post_content}\": \"{content}\"" elif post_author: - return f"在{post_author}的帖子下评论道:「{content}」" - return f"评论道:「{content}」" - return "发表了评论" - + return f"commented on {post_author}'s post: \"{content}\"" + return f"commented: \"{content}\"" + return "posted a comment" + def _describe_like_comment(self) -> str: - """点赞评论 - 包含评论内容和作者信息""" + """Like a comment — includes the comment text and author info""" comment_content = self.action_args.get("comment_content", "") comment_author = self.action_args.get("comment_author_name", "") - + if comment_content and comment_author: - return f"点赞了{comment_author}的评论:「{comment_content}」" + return f"liked {comment_author}'s comment: \"{comment_content}\"" elif comment_content: - return f"点赞了一条评论:「{comment_content}」" + return f"liked a comment: \"{comment_content}\"" elif comment_author: - return f"点赞了{comment_author}的一条评论" - return "点赞了一条评论" - + return f"liked a comment by {comment_author}" + return "liked a comment" + def _describe_dislike_comment(self) -> str: - """踩评论 - 包含评论内容和作者信息""" + """Dislike a comment — includes the comment text and author info""" comment_content = self.action_args.get("comment_content", "") comment_author = self.action_args.get("comment_author_name", "") - + if comment_content and comment_author: - return f"踩了{comment_author}的评论:「{comment_content}」" + return f"disliked {comment_author}'s comment: \"{comment_content}\"" elif comment_content: - return f"踩了一条评论:「{comment_content}」" + return f"disliked a comment: \"{comment_content}\"" elif comment_author: - return f"踩了{comment_author}的一条评论" - return "踩了一条评论" - + return f"disliked a comment by {comment_author}" + return "disliked a comment" + def _describe_search(self) -> str: - """搜索帖子 - 包含搜索关键词""" + """Search posts — includes the search keyword""" query = self.action_args.get("query", "") or self.action_args.get("keyword", "") - return f"搜索了「{query}」" if query else "进行了搜索" - + return f"searched \"{query}\"" if query else "performed a search" + def _describe_search_user(self) -> str: - """搜索用户 - 包含搜索关键词""" + """Search users — includes the search keyword""" query = self.action_args.get("query", "") or self.action_args.get("username", "") - return f"搜索了用户「{query}」" if query else "搜索了用户" - + return f"searched for user \"{query}\"" if query else "searched for a user" + def _describe_mute(self) -> str: - """屏蔽用户 - 包含被屏蔽用户的名称""" + """Mute a user — includes the name of the muted user""" target_user_name = self.action_args.get("target_user_name", "") - + if target_user_name: - return f"屏蔽了用户「{target_user_name}」" - return "屏蔽了一个用户" - + return f"muted user \"{target_user_name}\"" + return "muted a user" + def _describe_generic(self) -> str: - # 对于未知的动作类型,生成通用描述 - return f"执行了{self.action_type}操作" + # For unknown action types, generate a generic description + return f"performed {self.action_type} operation" class ZepGraphMemoryUpdater: """ - Zep图谱记忆更新器 - - 监控模拟的actions日志文件,将新的agent活动实时更新到Zep图谱中。 - 按平台分组,每累积BATCH_SIZE条活动后批量发送到Zep。 - - 所有有意义的行为都会被更新到Zep,action_args中会包含完整的上下文信息: - - 点赞/踩的帖子原文 - - 转发/引用的帖子原文 - - 关注/屏蔽的用户名 - - 点赞/踩的评论原文 + Zep graph memory updater. + + Monitors the simulation's actions log file and updates new agent activities + into the Zep graph in real time. Activities are grouped by platform, and + a batch is sent to Zep once BATCH_SIZE items have accumulated. + + All meaningful behaviors are pushed to Zep; action_args carries the full + context for each one: + - the original text of liked/disliked posts + - the original text of reposted/quoted posts + - the name of followed/muted users + - the original text of liked/disliked comments """ - - # 批量发送大小(每个平台累积多少条后发送) + + # Batch send size (how many activities to accumulate per platform before sending) BATCH_SIZE = 5 - - # 平台名称映射(用于控制台显示) + + # Platform name mapping (for console display) PLATFORM_DISPLAY_NAMES = { - 'twitter': '世界1', - 'reddit': '世界2', + 'twitter': 'World 1', + 'reddit': 'World 2', } - - # 发送间隔(秒),避免请求过快 + + # Send interval (seconds) to avoid hitting the API too fast SEND_INTERVAL = 0.5 - - # 重试配置 + + # Retry configuration MAX_RETRIES = 3 - RETRY_DELAY = 2 # 秒 - + RETRY_DELAY = 2 # seconds + def __init__(self, graph_id: str, api_key: Optional[str] = None): """ - 初始化更新器 - + Initialize the updater. + Args: - graph_id: Zep图谱ID - api_key: Zep API Key(可选,默认从配置读取) + graph_id: Zep graph ID + api_key: Zep API key (optional; read from config by default) """ self.graph_id = graph_id self.api_key = api_key # kept for signature compat; no longer required from .graphiti_service import get_graphiti_adapter self.client = get_graphiti_adapter() - - # 活动队列 + + # Activity queue self._activity_queue: Queue = Queue() - - # 按平台分组的活动缓冲区(每个平台各自累积到BATCH_SIZE后批量发送) + + # Per-platform activity buffer (each platform accumulates to BATCH_SIZE then sends) self._platform_buffers: Dict[str, List[AgentActivity]] = { 'twitter': [], 'reddit': [], } self._buffer_lock = threading.Lock() - - # 控制标志 + + # Control flags self._running = False self._worker_thread: Optional[threading.Thread] = None - - # 统计 - self._total_activities = 0 # 实际添加到队列的活动数 - self._total_sent = 0 # 成功发送到Zep的批次数 - self._total_items_sent = 0 # 成功发送到Zep的活动条数 - self._failed_count = 0 # 发送失败的批次数 - self._skipped_count = 0 # 被过滤跳过的活动数(DO_NOTHING) - - logger.info(f"ZepGraphMemoryUpdater 初始化完成: graph_id={graph_id}, batch_size={self.BATCH_SIZE}") - + + # Stats + self._total_activities = 0 # number of activities actually added to the queue + self._total_sent = 0 # number of batches successfully sent to Zep + self._total_items_sent = 0 # number of activities successfully sent to Zep + self._failed_count = 0 # number of failed batches + self._skipped_count = 0 # activities filtered out (DO_NOTHING) + + logger.info(f"ZepGraphMemoryUpdater initialization complete: graph_id={graph_id}, batch_size={self.BATCH_SIZE}") + def _get_platform_display_name(self, platform: str) -> str: - """获取平台的显示名称""" + """Get the display name of a platform""" return self.PLATFORM_DISPLAY_NAMES.get(platform.lower(), platform) - + def start(self): - """启动后台工作线程""" + """Start the background worker thread""" if self._running: return @@ -292,19 +294,19 @@ class ZepGraphMemoryUpdater: name=f"ZepMemoryUpdater-{self.graph_id[:8]}" ) self._worker_thread.start() - logger.info(f"ZepGraphMemoryUpdater 已启动: graph_id={self.graph_id}") - + logger.info(f"ZepGraphMemoryUpdater started: graph_id={self.graph_id}") + def stop(self): - """停止后台工作线程""" + """Stop the background worker thread""" self._running = False - - # 发送剩余的活动 + + # Flush remaining activities self._flush_remaining() - + if self._worker_thread and self._worker_thread.is_alive(): self._worker_thread.join(timeout=10) - - logger.info(f"ZepGraphMemoryUpdater 已停止: graph_id={self.graph_id}, " + + logger.info(f"ZepGraphMemoryUpdater stopped: graph_id={self.graph_id}, " f"total_activities={self._total_activities}, " f"batches_sent={self._total_sent}, " f"items_sent={self._total_items_sent}, " @@ -313,46 +315,46 @@ class ZepGraphMemoryUpdater: def add_activity(self, activity: AgentActivity): """ - 添加一个agent活动到队列 - - 所有有意义的行为都会被添加到队列,包括: - - CREATE_POST(发帖) - - CREATE_COMMENT(评论) - - QUOTE_POST(引用帖子) - - SEARCH_POSTS(搜索帖子) - - SEARCH_USER(搜索用户) - - LIKE_POST/DISLIKE_POST(点赞/踩帖子) - - REPOST(转发) - - FOLLOW(关注) - - MUTE(屏蔽) - - LIKE_COMMENT/DISLIKE_COMMENT(点赞/踩评论) - - action_args中会包含完整的上下文信息(如帖子原文、用户名等)。 - + Add an agent activity to the queue. + + All meaningful behaviors are added to the queue, including: + - CREATE_POST (publish a post) + - CREATE_COMMENT (post a comment) + - QUOTE_POST (quote a post) + - SEARCH_POSTS (search posts) + - SEARCH_USER (search users) + - LIKE_POST/DISLIKE_POST (like/dislike a post) + - REPOST (repost) + - FOLLOW (follow) + - MUTE (mute) + - LIKE_COMMENT/DISLIKE_COMMENT (like/dislike a comment) + + action_args carries the full context (original post text, username, etc.). + Args: - activity: Agent活动记录 + activity: Agent activity record """ - # 跳过DO_NOTHING类型的活动 + # Skip DO_NOTHING activities if activity.action_type == "DO_NOTHING": self._skipped_count += 1 return - + self._activity_queue.put(activity) self._total_activities += 1 - logger.debug(f"添加活动到Zep队列: {activity.agent_name} - {activity.action_type}") - + logger.debug(f"Added activity to Zep queue: {activity.agent_name} - {activity.action_type}") + def add_activity_from_dict(self, data: Dict[str, Any], platform: str): """ - 从字典数据添加活动 - + Add an activity from a dictionary. + Args: - data: 从actions.jsonl解析的字典数据 - platform: 平台名称 (twitter/reddit) + data: Dictionary parsed from actions.jsonl + platform: Platform name (twitter/reddit) """ - # 跳过事件类型的条目 + # Skip entries that are events if "event_type" in data: return - + activity = AgentActivity( platform=platform, agent_id=data.get("agent_id", 0), @@ -362,57 +364,57 @@ class ZepGraphMemoryUpdater: round_num=data.get("round", 0), timestamp=data.get("timestamp", datetime.now().isoformat()), ) - + self.add_activity(activity) - + def _worker_loop(self, locale: str = 'zh'): - """后台工作循环 - 按平台批量发送活动到Zep""" + """Background worker loop — batch-sends activities to Zep per platform""" set_locale(locale) while self._running or not self._activity_queue.empty(): try: - # 尝试从队列获取活动(超时1秒) + # Try to fetch an activity from the queue (1s timeout) try: activity = self._activity_queue.get(timeout=1) - - # 将活动添加到对应平台的缓冲区 + + # Append the activity to the corresponding platform buffer platform = activity.platform.lower() with self._buffer_lock: if platform not in self._platform_buffers: self._platform_buffers[platform] = [] self._platform_buffers[platform].append(activity) - - # 检查该平台是否达到批量大小 + + # Check if this platform reached the batch size if len(self._platform_buffers[platform]) >= self.BATCH_SIZE: batch = self._platform_buffers[platform][:self.BATCH_SIZE] self._platform_buffers[platform] = self._platform_buffers[platform][self.BATCH_SIZE:] - # 释放锁后再发送 + # Release the lock before sending self._send_batch_activities(batch, platform) - # 发送间隔,避免请求过快 + # Send interval to avoid hitting the API too fast time.sleep(self.SEND_INTERVAL) - + except Empty: pass - + except Exception as e: - logger.error(f"工作循环异常: {e}") + logger.error(f"Worker loop error: {e}") time.sleep(1) - + def _send_batch_activities(self, activities: List[AgentActivity], platform: str): """ - 批量发送活动到Zep图谱(合并为一条文本) - + Batch-send activities to the Zep graph (merged into a single text). + Args: - activities: Agent活动列表 - platform: 平台名称 + activities: List of agent activities + platform: Platform name """ if not activities: return - - # 将多条活动合并为一条文本,用换行分隔 + + # Merge multiple activities into one text, separated by newlines episode_texts = [activity.to_episode_text() for activity in activities] combined_text = "\n".join(episode_texts) - - # 带重试的发送 + + # Send with retry for attempt in range(self.MAX_RETRIES): try: # Graphiti via adapter: one episode per batch (extraction is sync) @@ -420,25 +422,25 @@ class ZepGraphMemoryUpdater: graph_id=self.graph_id, episodes=[{"data": combined_text, "type": "text"}], ) - + self._total_sent += 1 self._total_items_sent += len(activities) display_name = self._get_platform_display_name(platform) - logger.info(f"成功批量发送 {len(activities)} 条{display_name}活动到图谱 {self.graph_id}") - logger.debug(f"批量内容预览: {combined_text[:200]}...") + logger.info(f"Successfully batch-sent {len(activities)} {display_name} activities to graph {self.graph_id}") + logger.debug(f"Batch content preview: {combined_text[:200]}...") return - + except Exception as e: if attempt < self.MAX_RETRIES - 1: - logger.warning(f"批量发送到Zep失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}") + logger.warning(f"Batch send to Zep failed (attempt {attempt + 1}/{self.MAX_RETRIES}): {e}") time.sleep(self.RETRY_DELAY * (attempt + 1)) else: - logger.error(f"批量发送到Zep失败,已重试{self.MAX_RETRIES}次: {e}") + logger.error(f"Batch send to Zep failed after {self.MAX_RETRIES} retries: {e}") self._failed_count += 1 - + def _flush_remaining(self): - """发送队列和缓冲区中剩余的活动""" - # 首先处理队列中剩余的活动,添加到缓冲区 + """Flush remaining activities in the queue and buffers""" + # First, drain the queue into the buffers while not self._activity_queue.empty(): try: activity = self._activity_queue.get_nowait() @@ -449,110 +451,110 @@ class ZepGraphMemoryUpdater: self._platform_buffers[platform].append(activity) except Empty: break - - # 然后发送各平台缓冲区中剩余的活动(即使不足BATCH_SIZE条) + + # Then send any remaining activities in the per-platform buffers (even if < BATCH_SIZE) with self._buffer_lock: for platform, buffer in self._platform_buffers.items(): if buffer: display_name = self._get_platform_display_name(platform) - logger.info(f"发送{display_name}平台剩余的 {len(buffer)} 条活动") + logger.info(f"Flushing remaining {len(buffer)} activities from {display_name}") self._send_batch_activities(buffer, platform) - # 清空所有缓冲区 + # Clear all buffers for platform in self._platform_buffers: self._platform_buffers[platform] = [] - + def get_stats(self) -> Dict[str, Any]: - """获取统计信息""" + """Get stats""" with self._buffer_lock: buffer_sizes = {p: len(b) for p, b in self._platform_buffers.items()} - + return { "graph_id": self.graph_id, "batch_size": self.BATCH_SIZE, - "total_activities": self._total_activities, # 添加到队列的活动总数 - "batches_sent": self._total_sent, # 成功发送的批次数 - "items_sent": self._total_items_sent, # 成功发送的活动条数 - "failed_count": self._failed_count, # 发送失败的批次数 - "skipped_count": self._skipped_count, # 被过滤跳过的活动数(DO_NOTHING) + "total_activities": self._total_activities, # total activities added to the queue + "batches_sent": self._total_sent, # successfully sent batches + "items_sent": self._total_items_sent, # successfully sent activities + "failed_count": self._failed_count, # failed batches + "skipped_count": self._skipped_count, # activities filtered out (DO_NOTHING) "queue_size": self._activity_queue.qsize(), - "buffer_sizes": buffer_sizes, # 各平台缓冲区大小 + "buffer_sizes": buffer_sizes, # per-platform buffer sizes "running": self._running, } class ZepGraphMemoryManager: """ - 管理多个模拟的Zep图谱记忆更新器 - - 每个模拟可以有自己的更新器实例 + Manages Zep graph memory updaters for multiple simulations. + + Each simulation can have its own updater instance. """ - + _updaters: Dict[str, ZepGraphMemoryUpdater] = {} _lock = threading.Lock() - + @classmethod def create_updater(cls, simulation_id: str, graph_id: str) -> ZepGraphMemoryUpdater: """ - 为模拟创建图谱记忆更新器 - + Create a graph memory updater for a simulation. + Args: - simulation_id: 模拟ID - graph_id: Zep图谱ID - + simulation_id: Simulation ID + graph_id: Zep graph ID + Returns: - ZepGraphMemoryUpdater实例 + A ZepGraphMemoryUpdater instance """ with cls._lock: - # 如果已存在,先停止旧的 + # If one already exists, stop it first if simulation_id in cls._updaters: cls._updaters[simulation_id].stop() - + updater = ZepGraphMemoryUpdater(graph_id) updater.start() cls._updaters[simulation_id] = updater - - logger.info(f"创建图谱记忆更新器: simulation_id={simulation_id}, graph_id={graph_id}") + + logger.info(f"Created graph memory updater: simulation_id={simulation_id}, graph_id={graph_id}") return updater - + @classmethod def get_updater(cls, simulation_id: str) -> Optional[ZepGraphMemoryUpdater]: - """获取模拟的更新器""" + """Get the updater for a simulation""" return cls._updaters.get(simulation_id) - + @classmethod def stop_updater(cls, simulation_id: str): - """停止并移除模拟的更新器""" + """Stop and remove the updater for a simulation""" with cls._lock: if simulation_id in cls._updaters: cls._updaters[simulation_id].stop() del cls._updaters[simulation_id] - logger.info(f"已停止图谱记忆更新器: simulation_id={simulation_id}") - - # 防止 stop_all 重复调用的标志 + logger.info(f"Stopped graph memory updater: simulation_id={simulation_id}") + + # Flag to prevent repeated stop_all calls _stop_all_done = False - + @classmethod def stop_all(cls): - """停止所有更新器""" - # 防止重复调用 + """Stop all updaters""" + # Prevent repeated calls if cls._stop_all_done: return cls._stop_all_done = True - + with cls._lock: if cls._updaters: for simulation_id, updater in list(cls._updaters.items()): try: updater.stop() except Exception as e: - logger.error(f"停止更新器失败: simulation_id={simulation_id}, error={e}") + logger.error(f"Failed to stop updater: simulation_id={simulation_id}, error={e}") cls._updaters.clear() - logger.info("已停止所有图谱记忆更新器") - + logger.info("Stopped all graph memory updaters") + @classmethod def get_all_stats(cls) -> Dict[str, Dict[str, Any]]: - """获取所有更新器的统计信息""" + """Get stats for all updaters""" return { - sim_id: updater.get_stats() + sim_id: updater.get_stats() for sim_id, updater in cls._updaters.items() } diff --git a/backend/app/services/zep_tools.py b/backend/app/services/zep_tools.py index add1a181..3f34f43f 100644 --- a/backend/app/services/zep_tools.py +++ b/backend/app/services/zep_tools.py @@ -1,11 +1,11 @@ """ -Zep检索工具服务 -封装图谱搜索、节点读取、边查询等工具,供Report Agent使用 +ZepZep Retrieval Tool Service +Encapsulates graph search,, node reading,, edge query, and other tools, for use by the Report Agent -核心检索工具(优化后): -1. InsightForge(深度洞察检索)- 最强大的混合检索,自动生成子问题并多维度检索 -2. PanoramaSearch(广度搜索)- 获取全貌,包括过期内容 -3. QuickSearch(简单搜索)- 快速检索 +Core retrieval tools ((optimized):): +1. InsightForge (InsightForge (deep insight retrieval) -)- the most powerful hybrid retrieval,, automatically generates sub-questions and performs multi-dimensional retrieval +2. PanoramaSearch (Broad search)- get the full picture,, including expired content +3. QuickSearch (Simple search)- quick retrieval """ import time @@ -34,7 +34,7 @@ logger = get_logger('mirofish.zep_tools') @dataclass class SearchResult: - """搜索结果""" + """Search result""" facts: List[str] edges: List[Dict[str, Any]] nodes: List[Dict[str, Any]] @@ -51,11 +51,11 @@ class SearchResult: } def to_text(self) -> str: - """转换为文本格式,供LLM理解""" - text_parts = [f"搜索查询: {self.query}", f"找到 {self.total_count} 条相关信息"] + """Convert to text format, for LLM understanding""" + text_parts = [f"Search query: {self.query}", f"Found {self.total_count} related items of information"] if self.facts: - text_parts.append("\n### 相关事实:") + text_parts.append("\n### Related facts:") for i, fact in enumerate(self.facts, 1): text_parts.append(f"{i}. {fact}") @@ -64,7 +64,7 @@ class SearchResult: @dataclass class NodeInfo: - """节点信息""" + """Node information""" uuid: str name: str labels: List[str] @@ -81,14 +81,14 @@ class NodeInfo: } def to_text(self) -> str: - """转换为文本格式""" - entity_type = next((l for l in self.labels if l not in ["Entity", "Node"]), "未知类型") - return f"实体: {self.name} (类型: {entity_type})\n摘要: {self.summary}" + """Convert to text format""" + entity_type = next((l for l in self.labels if l not in ["Entity", "Node"]), "Unknown type") + return f"Entity: {self.name} (Type: {entity_type})\nSummary: {self.summary}" @dataclass class EdgeInfo: - """边信息""" + """Edge information""" uuid: str name: str fact: str @@ -96,7 +96,7 @@ class EdgeInfo: target_node_uuid: str source_node_name: Optional[str] = None target_node_name: Optional[str] = None - # 时间信息 + # Temporal information created_at: Optional[str] = None valid_at: Optional[str] = None invalid_at: Optional[str] = None @@ -118,47 +118,47 @@ class EdgeInfo: } def to_text(self, include_temporal: bool = False) -> str: - """转换为文本格式""" + """Convert to text format""" source = self.source_node_name or self.source_node_uuid[:8] target = self.target_node_name or self.target_node_uuid[:8] - base_text = f"关系: {source} --[{self.name}]--> {target}\n事实: {self.fact}" + base_text = f"Relation: {source} --[{self.name}]--> {target}\nfact: {self.fact}" if include_temporal: - valid_at = self.valid_at or "未知" - invalid_at = self.invalid_at or "至今" - base_text += f"\n时效: {valid_at} - {invalid_at}" + valid_at = self.valid_at or "unknown" + invalid_at = self.invalid_at or "present" + base_text += f"\nValidity: {valid_at} - {invalid_at}" if self.expired_at: - base_text += f" (已过期: {self.expired_at})" + base_text += f" (expired: {self.expired_at})" return base_text @property def is_expired(self) -> bool: - """是否已过期""" + """Whether expired""" return self.expired_at is not None @property def is_invalid(self) -> bool: - """是否已失效""" + """Whether invalidated""" return self.invalid_at is not None @dataclass class InsightForgeResult: """ - 深度洞察检索结果 (InsightForge) - 包含多个子问题的检索结果,以及综合分析 + Deep insight retrieval result (InsightForge) (InsightForge) + Contains results from multiple sub-questions,, plus a comprehensive analysis """ query: str simulation_requirement: str sub_queries: List[str] - # 各维度检索结果 - semantic_facts: List[str] = field(default_factory=list) # 语义搜索结果 - entity_insights: List[Dict[str, Any]] = field(default_factory=list) # 实体洞察 - relationship_chains: List[str] = field(default_factory=list) # 关系链 + # Per-dimension retrieval results + semantic_facts: List[str] = field(default_factory=list) # semantic search results + entity_insights: List[Dict[str, Any]] = field(default_factory=list) # entity insights + relationship_chains: List[str] = field(default_factory=list) # relationship chains - # 统计信息 + # Statistics total_facts: int = 0 total_entities: int = 0 total_relationships: int = 0 @@ -177,42 +177,42 @@ class InsightForgeResult: } def to_text(self) -> str: - """转换为详细的文本格式,供LLM理解""" + """Convert to detailed text format, for LLM understanding""" text_parts = [ - f"## 未来预测深度分析", - f"分析问题: {self.query}", - f"预测场景: {self.simulation_requirement}", - f"\n### 预测数据统计", - f"- 相关预测事实: {self.total_facts}条", - f"- 涉及实体: {self.total_entities}个", - f"- 关系链: {self.total_relationships}条" + f"## Deep analysis of future prediction", + f"Analysis question: {self.query}", + f"Prediction scenario: {self.simulation_requirement}", + f"\n### Prediction data statistics", + f"- Related prediction facts: {self.total_facts}items", + f"- Involved entities: {self.total_entities}", + f"- relationship chains: {self.total_relationships}items" ] - # 子问题 + # sub-question if self.sub_queries: - text_parts.append(f"\n### 分析的子问题") + text_parts.append(f"\n### Analyzed sub-questions") for i, sq in enumerate(self.sub_queries, 1): text_parts.append(f"{i}. {sq}") - # 语义搜索结果 + # semantic search results if self.semantic_facts: - text_parts.append(f"\n### 【关键事实】(请在报告中引用这些原文)") + text_parts.append(f"\n### [Key facts](Please quote the original text in the report)") for i, fact in enumerate(self.semantic_facts, 1): text_parts.append(f"{i}. \"{fact}\"") - # 实体洞察 + # entity insights if self.entity_insights: - text_parts.append(f"\n### 【核心实体】") + text_parts.append(f"\n### [Core entities]") for entity in self.entity_insights: - text_parts.append(f"- **{entity.get('name', '未知')}** ({entity.get('type', '实体')})") + text_parts.append(f"- **{entity.get('name', 'unknown')}** ({entity.get('type', 'Entity')})") if entity.get('summary'): - text_parts.append(f" 摘要: \"{entity.get('summary')}\"") + text_parts.append(f" Summary: \"{entity.get('summary')}\"") if entity.get('related_facts'): - text_parts.append(f" 相关事实: {len(entity.get('related_facts', []))}条") + text_parts.append(f" Related facts: {len(entity.get('related_facts', []))}items") - # 关系链 + # relationship chains if self.relationship_chains: - text_parts.append(f"\n### 【关系链】") + text_parts.append(f"\n### [relationship chains]") for chain in self.relationship_chains: text_parts.append(f"- {chain}") @@ -222,21 +222,21 @@ class InsightForgeResult: @dataclass class PanoramaResult: """ - 广度搜索结果 (Panorama) - 包含所有相关信息,包括过期内容 + Broad search result (Panorama) + Contains all related information,, including expired content """ query: str - # 全部节点 + # All nodes all_nodes: List[NodeInfo] = field(default_factory=list) - # 全部边(包括过期的) + # All edges (including expired ones) all_edges: List[EdgeInfo] = field(default_factory=list) - # 当前有效的事实 + # Current valid facts active_facts: List[str] = field(default_factory=list) - # 已过期/失效的事实(历史记录) + # expired/invalidated facts (history records) historical_facts: List[str] = field(default_factory=list) - # 统计 + # Statistics total_nodes: int = 0 total_edges: int = 0 active_count: int = 0 @@ -256,34 +256,34 @@ class PanoramaResult: } def to_text(self) -> str: - """转换为文本格式(完整版本,不截断)""" + """Convert to text format (complete version,, not truncated)""" text_parts = [ - f"## 广度搜索结果(未来全景视图)", - f"查询: {self.query}", - f"\n### 统计信息", - f"- 总节点数: {self.total_nodes}", - f"- 总边数: {self.total_edges}", - f"- 当前有效事实: {self.active_count}条", - f"- 历史/过期事实: {self.historical_count}条" + f"## Broad search result (future panoramic view)", + f"Query: {self.query}", + f"\n### Statistics", + f"- Total nodes: {self.total_nodes}", + f"- Total edges: {self.total_edges}", + f"- Current valid facts: {self.active_count}items", + f"- History/expired facts: {self.historical_count}items" ] - # 当前有效的事实(完整输出,不截断) + # Current valid facts (Complete output,, not truncated) if self.active_facts: - text_parts.append(f"\n### 【当前有效事实】(模拟结果原文)") + text_parts.append(f"\n### [Current valid facts](original simulation result text)") for i, fact in enumerate(self.active_facts, 1): text_parts.append(f"{i}. \"{fact}\"") - # 历史/过期事实(完整输出,不截断) + # History/expired facts (Complete output,, not truncated) if self.historical_facts: - text_parts.append(f"\n### 【历史/过期事实】(演变过程记录)") + text_parts.append(f"\n### [History/expired facts](evolution process record)") for i, fact in enumerate(self.historical_facts, 1): text_parts.append(f"{i}. \"{fact}\"") - # 关键实体(完整输出,不截断) + # Key entities (Complete output,, not truncated) if self.all_nodes: - text_parts.append(f"\n### 【涉及实体】") + text_parts.append(f"\n### [Involved entities]") for node in self.all_nodes: - entity_type = next((l for l in node.labels if l not in ["Entity", "Node"]), "实体") + entity_type = next((l for l in node.labels if l not in ["Entity", "Node"]), "Entity") text_parts.append(f"- **{node.name}** ({entity_type})") return "\n".join(text_parts) @@ -291,13 +291,13 @@ class PanoramaResult: @dataclass class AgentInterview: - """单个Agent的采访结果""" + """Single Agent interview result""" agent_name: str - agent_role: str # 角色类型(如:学生、教师、媒体等) - agent_bio: str # 简介 - question: str # 采访问题 - response: str # 采访回答 - key_quotes: List[str] = field(default_factory=list) # 关键引言 + agent_role: str # Role type (e.g., Student, Teacher, Media, etc.) + agent_bio: str # Bio + question: str # Interview question + response: str # Interview answer + key_quotes: List[str] = field(default_factory=list) # Key quotes def to_dict(self) -> Dict[str, Any]: return { @@ -311,21 +311,21 @@ class AgentInterview: def to_text(self) -> str: text = f"**{self.agent_name}** ({self.agent_role})\n" - # 显示完整的agent_bio,不截断 - text += f"_简介: {self.agent_bio}_\n\n" + # Display the complete agent_bio,, not truncated + text += f"_Bio: {self.agent_bio}_\n\n" text += f"**Q:** {self.question}\n\n" text += f"**A:** {self.response}\n" if self.key_quotes: - text += "\n**关键引言:**\n" + text += "\n**Key quotes:**\n" for quote in self.key_quotes: - # 清理各种引号 + # Clean various quotation marks clean_quote = quote.replace('\u201c', '').replace('\u201d', '').replace('"', '') clean_quote = clean_quote.replace('\u300c', '').replace('\u300d', '') clean_quote = clean_quote.strip() - # 去掉开头的标点 - while clean_quote and clean_quote[0] in ',,;;::、。!?\n\r\t ': + # Remove leading punctuation + while clean_quote and clean_quote[0] in ', ,; ;: :, . !?\n\r\t ': clean_quote = clean_quote[1:] - # 过滤包含问题编号的垃圾内容(问题1-9) + # Filter junk content containing question numbers (question 1-9) skip = False for d in '123456789': if f'\u95ee\u9898{d}' in clean_quote: @@ -333,7 +333,7 @@ class AgentInterview: break if skip: continue - # 截断过长内容(按句号截断,而非硬截断) + # Truncate overly long content (truncate by sentence period, not by hard truncation) if len(clean_quote) > 150: dot_pos = clean_quote.find('\u3002', 80) if dot_pos > 0: @@ -348,23 +348,23 @@ class AgentInterview: @dataclass class InterviewResult: """ - 采访结果 (Interview) - 包含多个模拟Agent的采访回答 + Interview result (Interview) (Interview) + Contains interview answers from multiple simulated Agents """ - interview_topic: str # 采访主题 - interview_questions: List[str] # 采访问题列表 + interview_topic: str # Interview topic + interview_questions: List[str] # Interview question list - # 采访选择的Agent + # Selected Agents for interview selected_agents: List[Dict[str, Any]] = field(default_factory=list) - # 各Agent的采访回答 + # Interview answers from each Agent interviews: List[AgentInterview] = field(default_factory=list) - # 选择Agent的理由 + # Reasoning for selecting Agents selection_reasoning: str = "" - # 整合后的采访摘要 + # Integrated interview summary summary: str = "" - # 统计 + # Statistics total_agents: int = 0 interviewed_count: int = 0 @@ -381,52 +381,52 @@ class InterviewResult: } def to_text(self) -> str: - """转换为详细的文本格式,供LLM理解和报告引用""" + """Convert to detailed text format, for LLM understanding and report reference""" text_parts = [ - "## 深度采访报告", - f"**采访主题:** {self.interview_topic}", - f"**采访人数:** {self.interviewed_count} / {self.total_agents} 位模拟Agent", - "\n### 采访对象选择理由", - self.selection_reasoning or "(自动选择)", + "## In-depth interview report", + f"**Interview topic:** {self.interview_topic}", + f"**Number of interviewees:** {self.interviewed_count} / {self.total_agents} simulated Agents", + "\n### Reasoning for interviewee selection", + self.selection_reasoning or " ((automatic selection))", "\n---", - "\n### 采访实录", + "\n### Interview transcript", ] if self.interviews: for i, interview in enumerate(self.interviews, 1): - text_parts.append(f"\n#### 采访 #{i}: {interview.agent_name}") + text_parts.append(f"\n#### Interview #{i}: {interview.agent_name}") text_parts.append(interview.to_text()) text_parts.append("\n---") else: - text_parts.append("(无采访记录)\n\n---") + text_parts.append(" ((no interview records))\n\n---") - text_parts.append("\n### 采访摘要与核心观点") - text_parts.append(self.summary or "(无摘要)") + text_parts.append("\n### Interview summary and core viewpoints") + text_parts.append(self.summary or " ((no summary))") return "\n".join(text_parts) class ZepToolsService: """ - Zep检索工具服务 + ZepZep Retrieval Tool Service - 【核心检索工具 - 优化后】 - 1. insight_forge - 深度洞察检索(最强大,自动生成子问题,多维度检索) - 2. panorama_search - 广度搜索(获取全貌,包括过期内容) - 3. quick_search - 简单搜索(快速检索) - 4. interview_agents - 深度采访(采访模拟Agent,获取多视角观点) + [Core retrieval tools - (optimized):] + 1. insight_forge - InsightForge (deep insight retrieval) - (most powerful, automatically generates sub-questions, multi-dimensional retrieval) + 2. panorama_search - Broad search (get the full picture,, including expired content) + 3. quick_search - Simple search (quick retrieval) + 4. interview_agents - In-depth interview (interview simulated Agents, get multi-perspective views) - 【基础工具】 - - search_graph - 图谱语义搜索 - - get_all_nodes - 获取图谱所有节点 - - get_all_edges - 获取图谱所有边(含时间信息) - - get_node_detail - 获取节点详细信息 - - get_node_edges - 获取节点相关的边 - - get_entities_by_type - 按类型获取实体 - - get_entity_summary - 获取实体的关系摘要 + [Basic tools] + - search_graph - Graph semantic search + - get_all_nodes - get all graph nodes + - get_all_edges - get all graph edges (including temporal information) + - get_node_detail - Get node detailed information + - get_node_edges - Get edges related to the node + - get_entities_by_type - Get entities by type + - get_entity_summary - get entity's relationship summary """ - # 重试配置 + # Retry configuration MAX_RETRIES = 3 RETRY_DELAY = 2.0 @@ -434,19 +434,19 @@ class ZepToolsService: self.api_key = api_key # kept for signature compat; no longer required from .graphiti_service import get_graphiti_adapter self.client = get_graphiti_adapter() - # LLM客户端用于InsightForge生成子问题 + # LLMClient used forInsightForgegenerating sub-questions self._llm_client = llm_client logger.info(t("console.zepToolsInitialized")) @property def llm(self) -> LLMClient: - """延迟初始化LLM客户端""" + """Lazily initializeLLMclient""" if self._llm_client is None: self._llm_client = LLMClient() return self._llm_client def _call_with_retry(self, func, operation_name: str, max_retries: int = None): - """带重试机制的API调用""" + """API call with retry mechanismAPIcall""" max_retries = max_retries or self.MAX_RETRIES last_exception = None delay = self.RETRY_DELAY @@ -475,23 +475,23 @@ class ZepToolsService: scope: str = "edges" ) -> SearchResult: """ - 图谱语义搜索 + Graph semantic search - 使用混合搜索(语义+BM25)在图谱中搜索相关信息。 - 如果Zep Cloud的search API不可用,则降级为本地关键词匹配。 + Use hybrid search (semantic+BM25)search related information in the graph. + If the Zep Cloud search API is unavailable, fall back to local keyword matching. Args: - graph_id: 图谱ID (Standalone Graph) - query: 搜索查询 - limit: 返回结果数量 - scope: 搜索范围,"edges" 或 "nodes" + graph_id: Graph ID (Standalone Graph) + query: Search query + limit: Number of returned results + scope: Search scope, "edges" or "nodes" Returns: - SearchResult: 搜索结果 + SearchResult: Search result """ logger.info(t("console.graphSearch", graphId=graph_id, query=query[:50])) - # 尝试使用 Graphiti search + # Try to use Graphiti search try: search_results = self._call_with_retry( func=lambda: self.client.search( @@ -507,7 +507,7 @@ class ZepToolsService: edges = [] nodes = [] - # 解析边搜索结果 + # Parse edge search results if hasattr(search_results, 'edges') and search_results.edges: for edge in search_results.edges: if hasattr(edge, 'fact') and edge.fact: @@ -520,7 +520,7 @@ class ZepToolsService: "target_node_uuid": getattr(edge, 'target_node_uuid', ''), }) - # 解析节点搜索结果 + # Parse node search results if hasattr(search_results, 'nodes') and search_results.nodes: for node in search_results.nodes: nodes.append({ @@ -529,7 +529,7 @@ class ZepToolsService: "labels": getattr(node, 'labels', []), "summary": getattr(node, 'summary', ''), }) - # 节点摘要也算作事实 + # Node summaries also count as facts if hasattr(node, 'summary') and node.summary: facts.append(f"[{node.name}]: {node.summary}") @@ -545,7 +545,7 @@ class ZepToolsService: except Exception as e: logger.warning(t("console.zepSearchApiFallback", error=str(e))) - # 降级:使用本地关键词匹配搜索 + # Fallback: use local keyword matching search return self._local_search(graph_id, query, limit, scope) def _local_search( @@ -556,18 +556,18 @@ class ZepToolsService: scope: str = "edges" ) -> SearchResult: """ - 本地关键词匹配搜索(作为Zep Search API的降级方案) + Local keyword matching search (as a fallback for the Zep Search API) - 获取所有边/节点,然后在本地进行关键词匹配 + Get all edges/nodes, then perform keyword matching locally Args: - graph_id: 图谱ID - query: 搜索查询 - limit: 返回结果数量 - scope: 搜索范围 + graph_id: Graph ID + query: Search query + limit: Number of returned results + scope: Search scope Returns: - SearchResult: 搜索结果 + SearchResult: Search result """ logger.info(t("console.usingLocalSearch", query=query[:30])) @@ -575,19 +575,19 @@ class ZepToolsService: edges_result = [] nodes_result = [] - # 提取查询关键词(简单分词) + # Extract query keywords (simple tokenization) query_lower = query.lower() - keywords = [w.strip() for w in query_lower.replace(',', ' ').replace(',', ' ').split() if len(w.strip()) > 1] + keywords = [w.strip() for w in query_lower.replace(',', ' ').replace(', ', ' ').split() if len(w.strip()) > 1] def match_score(text: str) -> int: - """计算文本与查询的匹配分数""" + """Calculate the match score between text and query""" if not text: return 0 text_lower = text.lower() - # 完全匹配查询 + # Exact match of query if query_lower in text_lower: return 100 - # 关键词匹配 + # keyword matching score = 0 for keyword in keywords: if keyword in text_lower: @@ -596,7 +596,7 @@ class ZepToolsService: try: if scope in ["edges", "both"]: - # 获取所有边并匹配 + # Get all edges and match all_edges = self.get_all_edges(graph_id) scored_edges = [] for edge in all_edges: @@ -604,7 +604,7 @@ class ZepToolsService: if score > 0: scored_edges.append((score, edge)) - # 按分数排序 + # Sort by score scored_edges.sort(key=lambda x: x[0], reverse=True) for score, edge in scored_edges[:limit]: @@ -619,7 +619,7 @@ class ZepToolsService: }) if scope in ["nodes", "both"]: - # 获取所有节点并匹配 + # Get all nodes and match all_nodes = self.get_all_nodes(graph_id) scored_nodes = [] for node in all_nodes: @@ -654,13 +654,13 @@ class ZepToolsService: def get_all_nodes(self, graph_id: str) -> List[NodeInfo]: """ - 获取图谱的所有节点(分页获取) + Get all nodes of the graph (retrieved with pagination) Args: - graph_id: 图谱ID + graph_id: Graph ID Returns: - 节点列表 + Node list """ logger.info(t("console.fetchingAllNodes", graphId=graph_id)) @@ -682,14 +682,14 @@ class ZepToolsService: def get_all_edges(self, graph_id: str, include_temporal: bool = True) -> List[EdgeInfo]: """ - 获取图谱的所有边(分页获取,包含时间信息) + Get all edges of the graph (retrieved with pagination, include temporal information) Args: - graph_id: 图谱ID - include_temporal: 是否包含时间信息(默认True) + graph_id: Graph ID + include_temporal: Whether to include temporal information (default True) Returns: - 边列表(包含created_at, valid_at, invalid_at, expired_at) + Edge list (containing created_at, valid_at, invalid_at, expired_at) """ logger.info(t("console.fetchingAllEdges", graphId=graph_id)) @@ -706,7 +706,7 @@ class ZepToolsService: target_node_uuid=edge.target_node_uuid or "" ) - # 添加时间信息 + # Add temporal information if include_temporal: edge_info.created_at = getattr(edge, 'created_at', None) edge_info.valid_at = getattr(edge, 'valid_at', None) @@ -720,13 +720,13 @@ class ZepToolsService: def get_node_detail(self, node_uuid: str) -> Optional[NodeInfo]: """ - 获取单个节点的详细信息 + Get detailed information of a single node Args: - node_uuid: 节点UUID + node_uuid: Node UUID Returns: - 节点信息或None + Node information or None """ logger.info(t("console.fetchingNodeDetail", uuid=node_uuid[:8])) @@ -752,26 +752,26 @@ class ZepToolsService: def get_node_edges(self, graph_id: str, node_uuid: str) -> List[EdgeInfo]: """ - 获取节点相关的所有边 + Get all edges related to a node - 通过获取图谱所有边,然后过滤出与指定节点相关的边 + By getting all edges of the graph,, then filtering out edges related to the specified node Args: - graph_id: 图谱ID - node_uuid: 节点UUID + graph_id: Graph ID + node_uuid: Node UUID Returns: - 边列表 + Edge list """ logger.info(t("console.fetchingNodeEdges", uuid=node_uuid[:8])) try: - # 获取图谱所有边,然后过滤 + # Get all graph edges, then filter all_edges = self.get_all_edges(graph_id) result = [] for edge in all_edges: - # 检查边是否与指定节点相关(作为源或目标) + # Check if an edge is related to the specified node (as source or target) if edge.source_node_uuid == node_uuid or edge.target_node_uuid == node_uuid: result.append(edge) @@ -788,14 +788,14 @@ class ZepToolsService: entity_type: str ) -> List[NodeInfo]: """ - 按类型获取实体 + Get entities by type Args: - graph_id: 图谱ID - entity_type: 实体类型(如 Student, PublicFigure 等) + graph_id: Graph ID + entity_type: Entity type (e.g., Student, PublicFigure, etc.) Returns: - 符合类型的实体列表 + List of entities matching the type """ logger.info(t("console.fetchingEntitiesByType", type=entity_type)) @@ -803,7 +803,7 @@ class ZepToolsService: filtered = [] for node in all_nodes: - # 检查labels是否包含指定类型 + # Check whether labels contain the specified type if entity_type in node.labels: filtered.append(node) @@ -816,27 +816,27 @@ class ZepToolsService: entity_name: str ) -> Dict[str, Any]: """ - 获取指定实体的关系摘要 + Get the relationship summary of the specified entity - 搜索与该实体相关的所有信息,并生成摘要 + Search all information related to the entity,, and generate a summary Args: - graph_id: 图谱ID - entity_name: 实体名称 + graph_id: Graph ID + entity_name: Entity name Returns: - 实体摘要信息 + Entity summary information """ logger.info(t("console.fetchingEntitySummary", name=entity_name)) - # 先搜索该实体相关的信息 + # First search for information related to the entity search_result = self.search_graph( graph_id=graph_id, query=entity_name, limit=20 ) - # 尝试在所有节点中找到该实体 + # Try to find the entity among all nodes all_nodes = self.get_all_nodes(graph_id) entity_node = None for node in all_nodes: @@ -846,7 +846,7 @@ class ZepToolsService: related_edges = [] if entity_node: - # 传入graph_id参数 + # Pass in graph_id parameter related_edges = self.get_node_edges(graph_id, entity_node.uuid) return { @@ -859,27 +859,27 @@ class ZepToolsService: def get_graph_statistics(self, graph_id: str) -> Dict[str, Any]: """ - 获取图谱的统计信息 + Get statistics of the graph Args: - graph_id: 图谱ID + graph_id: Graph ID Returns: - 统计信息 + Statistics """ logger.info(t("console.fetchingGraphStats", graphId=graph_id)) nodes = self.get_all_nodes(graph_id) edges = self.get_all_edges(graph_id) - # 统计实体类型分布 + # Count entity type distribution entity_types = {} for node in nodes: for label in node.labels: if label not in ["Entity", "Node"]: entity_types[label] = entity_types.get(label, 0) + 1 - # 统计关系类型分布 + # Count relationship type distribution relation_types = {} for edge in edges: relation_types[edge.name] = relation_types.get(edge.name, 0) + 1 @@ -899,34 +899,34 @@ class ZepToolsService: limit: int = 30 ) -> Dict[str, Any]: """ - 获取模拟相关的上下文信息 + Get context information related to the simulation - 综合搜索与模拟需求相关的所有信息 + Comprehensively search all information related to the simulation requirement Args: - graph_id: 图谱ID - simulation_requirement: 模拟需求描述 - limit: 每类信息的数量限制 + graph_id: Graph ID + simulation_requirement: Simulation requirement description + limit: Quantity limit per type of information Returns: - 模拟上下文信息 + Simulation context information """ logger.info(t("console.fetchingSimContext", requirement=simulation_requirement[:50])) - # 搜索与模拟需求相关的信息 + # Search for information related to the simulation requirement search_result = self.search_graph( graph_id=graph_id, query=simulation_requirement, limit=limit ) - # 获取图谱统计 + # Get graph statistics stats = self.get_graph_statistics(graph_id) - # 获取所有实体节点 + # Get all entity nodes all_nodes = self.get_all_nodes(graph_id) - # 筛选有实际类型的实体(非纯Entity节点) + # Filter entities with actual types (non-pure Entity nodes) entities = [] for node in all_nodes: custom_labels = [l for l in node.labels if l not in ["Entity", "Node"]] @@ -941,11 +941,11 @@ class ZepToolsService: "simulation_requirement": simulation_requirement, "related_facts": search_result.facts, "graph_statistics": stats, - "entities": entities[:limit], # 限制数量 + "entities": entities[:limit], # limit quantity "total_entities": len(entities) } - # ========== 核心检索工具(优化后) ========== + # ========== Core retrieval tools ((optimized):) ========== def insight_forge( self, @@ -956,24 +956,24 @@ class ZepToolsService: max_sub_queries: int = 5 ) -> InsightForgeResult: """ - 【InsightForge - 深度洞察检索】 + [InsightForge - InsightForge (deep insight retrieval) -] - 最强大的混合检索函数,自动分解问题并多维度检索: - 1. 使用LLM将问题分解为多个子问题 - 2. 对每个子问题进行语义搜索 - 3. 提取相关实体并获取其详细信息 - 4. 追踪关系链 - 5. 整合所有结果,生成深度洞察 + The most powerful hybrid retrieval function,, automatically decomposes questions and performs multi-dimensional retrieval:: + 1. useLLMdecompose the question into multiple sub-questions + 2. perform semantic search for each sub-question + 3. extract related entities and get their detailed information + 4. trace relationship chains + 5. integrate all results,, generate deep insights Args: - graph_id: 图谱ID - query: 用户问题 - simulation_requirement: 模拟需求描述 - report_context: 报告上下文(可选,用于更精准的子问题生成) - max_sub_queries: 最大子问题数量 + graph_id: Graph ID + query: user question + simulation_requirement: Simulation requirement description + report_context: report context (optional, used for more accurate sub-question generation) + max_sub_queries: Maximum number of sub-questions Returns: - InsightForgeResult: 深度洞察检索结果 + InsightForgeResult: Deep insight retrieval result (InsightForge) """ logger.info(t("console.insightForgeStart", query=query[:50])) @@ -983,7 +983,7 @@ class ZepToolsService: sub_queries=[] ) - # Step 1: 使用LLM生成子问题 + # Step 1: useLLMgenerating sub-questions sub_queries = self._generate_sub_queries( query=query, simulation_requirement=simulation_requirement, @@ -993,7 +993,7 @@ class ZepToolsService: result.sub_queries = sub_queries logger.info(t("console.generatedSubQueries", count=len(sub_queries))) - # Step 2: 对每个子问题进行语义搜索 + # Step 2: perform semantic search for each sub-question all_facts = [] all_edges = [] seen_facts = set() @@ -1013,7 +1013,7 @@ class ZepToolsService: all_edges.extend(search_result.edges) - # 对原始问题也进行搜索 + # Also search for the original question main_search = self.search_graph( graph_id=graph_id, query=query, @@ -1028,7 +1028,7 @@ class ZepToolsService: result.semantic_facts = all_facts result.total_facts = len(all_facts) - # Step 3: 从边中提取相关实体UUID,只获取这些实体的信息(不获取全部节点) + # Step 3: Extract related entities from edgesUUID, only get information for these entities (do not get all nodes) entity_uuids = set() for edge_data in all_edges: if isinstance(edge_data, dict): @@ -1039,21 +1039,21 @@ class ZepToolsService: if target_uuid: entity_uuids.add(target_uuid) - # 获取所有相关实体的详情(不限制数量,完整输出) + # Get details of all related entities (no quantity limit,, Complete output,) entity_insights = [] - node_map = {} # 用于后续关系链构建 + node_map = {} # Used for subsequent relationship chain construction - for uuid in list(entity_uuids): # 处理所有实体,不截断 + for uuid in list(entity_uuids): # Process all entities,, not truncated if not uuid: continue try: - # 单独获取每个相关节点的信息 + # Get information for each related node individually node = self.get_node_detail(uuid) if node: node_map[uuid] = node - entity_type = next((l for l in node.labels if l not in ["Entity", "Node"]), "实体") + entity_type = next((l for l in node.labels if l not in ["Entity", "Node"]), "Entity") - # 获取该实体相关的所有事实(不截断) + # Get all facts related to the entity (not truncated) related_facts = [ f for f in all_facts if node.name.lower() in f.lower() @@ -1064,18 +1064,18 @@ class ZepToolsService: "name": node.name, "type": entity_type, "summary": node.summary, - "related_facts": related_facts # 完整输出,不截断 + "related_facts": related_facts # Complete output,, not truncated }) except Exception as e: - logger.debug(f"获取节点 {uuid} 失败: {e}") + logger.debug(f"Getting node {uuid} failed: {e}") continue result.entity_insights = entity_insights result.total_entities = len(entity_insights) - # Step 4: 构建所有关系链(不限制数量) + # Step 4: Build all relationship chains (no quantity limit,) relationship_chains = [] - for edge_data in all_edges: # 处理所有边,不截断 + for edge_data in all_edges: # Process all edges,, not truncated if isinstance(edge_data, dict): source_uuid = edge_data.get('source_node_uuid', '') target_uuid = edge_data.get('target_node_uuid', '') @@ -1102,27 +1102,27 @@ class ZepToolsService: max_queries: int = 5 ) -> List[str]: """ - 使用LLM生成子问题 + useLLMgenerating sub-questions - 将复杂问题分解为多个可以独立检索的子问题 + Decompose complex questions into multiple sub-questions that can be retrieved independently """ - system_prompt = """你是一个专业的问题分析专家。你的任务是将一个复杂问题分解为多个可以在模拟世界中独立观察的子问题。 + system_prompt = """You are a professional question analysis expert.. Your task is to decompose a complex question into multiple sub-questions that can be independently observed in the simulation world. -要求: -1. 每个子问题应该足够具体,可以在模拟世界中找到相关的Agent行为或事件 -2. 子问题应该覆盖原问题的不同维度(如:谁、什么、为什么、怎么样、何时、何地) -3. 子问题应该与模拟场景相关 -4. 返回JSON格式:{"sub_queries": ["子问题1", "子问题2", ...]}""" +Requirements: +1. Each sub-question should be specific enough, so that related Agent behaviors or events can be found in the simulation world +2. Sub-questions should cover different dimensions of the original question (e.g., who, what, why, how, when, where) +3. Sub-questions should be related to the simulation scenario +4. Return in JSON format: {"sub_queries": ["sub-question 1", "sub-question 2", ...]}""" - user_prompt = f"""模拟需求背景: + user_prompt = f"""Simulation requirement context: {simulation_requirement} -{f"报告上下文:{report_context[:500]}" if report_context else ""} +{f"report context: {report_context[:500]}" if report_context else ""} -请将以下问题分解为{max_queries}个子问题: +Please decompose the following question into{max_queries}sub-questions: {query} -返回JSON格式的子问题列表。""" +Return in JSON format sub-question list. """ try: response = self.llm.chat_json( @@ -1134,17 +1134,17 @@ class ZepToolsService: ) sub_queries = response.get("sub_queries", []) - # 确保是字符串列表 + # Ensure it is a list of strings return [str(sq) for sq in sub_queries[:max_queries]] except Exception as e: logger.warning(t("console.generateSubQueriesFailed", error=str(e))) - # 降级:返回基于原问题的变体 + # Fallback: return variants based on the original question return [ query, - f"{query} 的主要参与者", - f"{query} 的原因和影响", - f"{query} 的发展过程" + f"{query} 's main participants", + f"{query} 's causes and impact", + f"{query} 's development process" ][:max_queries] def panorama_search( @@ -1155,40 +1155,40 @@ class ZepToolsService: limit: int = 50 ) -> PanoramaResult: """ - 【PanoramaSearch - 广度搜索】 + [PanoramaSearch - Broad search] - 获取全貌视图,包括所有相关内容和历史/过期信息: - 1. 获取所有相关节点 - 2. 获取所有边(包括已过期/失效的) - 3. 分类整理当前有效和历史信息 + Get a panoramic view,, including all related content and history/expired information: + 1. Get all related nodes + 2. Get all edges (including expired/invalidated) + 3. Classify and organize current valid and historical information - 这个工具适用于需要了解事件全貌、追踪演变过程的场景。 + This tool is suitable for scenarios that need to understand the full picture of an event,, trace the evolution process. Args: - graph_id: 图谱ID - query: 搜索查询(用于相关性排序) - include_expired: 是否包含过期内容(默认True) - limit: 返回结果数量限制 + graph_id: Graph ID + query: Search query (used for relevance ranking) + include_expired: Whether to include expired content (default True) + limit: Limit on number of returned results Returns: - PanoramaResult: 广度搜索结果 + PanoramaResult: Broad search result """ logger.info(t("console.panoramaSearchStart", query=query[:50])) result = PanoramaResult(query=query) - # 获取所有节点 + # Get all nodes all_nodes = self.get_all_nodes(graph_id) node_map = {n.uuid: n for n in all_nodes} result.all_nodes = all_nodes result.total_nodes = len(all_nodes) - # 获取所有边(包含时间信息) + # Get all edges (include temporal information) all_edges = self.get_all_edges(graph_id, include_temporal=True) result.all_edges = all_edges result.total_edges = len(all_edges) - # 分类事实 + # Classify facts active_facts = [] historical_facts = [] @@ -1196,26 +1196,26 @@ class ZepToolsService: if not edge.fact: continue - # 为事实添加实体名称 + # Add entity names to facts source_name = node_map.get(edge.source_node_uuid, NodeInfo('', '', [], '', {})).name or edge.source_node_uuid[:8] target_name = node_map.get(edge.target_node_uuid, NodeInfo('', '', [], '', {})).name or edge.target_node_uuid[:8] - # 判断是否过期/失效 + # Determine if expired/invalid is_historical = edge.is_expired or edge.is_invalid if is_historical: - # 历史/过期事实,添加时间标记 - valid_at = edge.valid_at or "未知" - invalid_at = edge.invalid_at or edge.expired_at or "未知" + # History/expired facts, add time marker + valid_at = edge.valid_at or "unknown" + invalid_at = edge.invalid_at or edge.expired_at or "unknown" fact_with_time = f"[{valid_at} - {invalid_at}] {edge.fact}" historical_facts.append(fact_with_time) else: - # 当前有效事实 + # Current valid facts active_facts.append(edge.fact) - # 基于查询进行相关性排序 + # Perform relevance sorting based on query query_lower = query.lower() - keywords = [w.strip() for w in query_lower.replace(',', ' ').replace(',', ' ').split() if len(w.strip()) > 1] + keywords = [w.strip() for w in query_lower.replace(',', ' ').replace(', ', ' ').split() if len(w.strip()) > 1] def relevance_score(fact: str) -> int: fact_lower = fact.lower() @@ -1227,7 +1227,7 @@ class ZepToolsService: score += 10 return score - # 排序并限制数量 + # Sort and limit quantity active_facts.sort(key=relevance_score, reverse=True) historical_facts.sort(key=relevance_score, reverse=True) @@ -1246,24 +1246,24 @@ class ZepToolsService: limit: int = 10 ) -> SearchResult: """ - 【QuickSearch - 简单搜索】 + [QuickSearch - Simple search] - 快速、轻量级的检索工具: - 1. 直接调用Zep语义搜索 - 2. 返回最相关的结果 - 3. 适用于简单、直接的检索需求 + Quick,, lightweight retrieval tool:: + 1. Directly call Zep semantic search + 2. return the most relevant results + 3. Suitable for simple,, direct retrieval needs Args: - graph_id: 图谱ID - query: 搜索查询 - limit: 返回结果数量 + graph_id: Graph ID + query: Search query + limit: Number of returned results Returns: - SearchResult: 搜索结果 + SearchResult: Search result """ logger.info(t("console.quickSearchStart", query=query[:50])) - # 直接调用现有的search_graph方法 + # Directly call the existingsearch_graphmethod result = self.search_graph( graph_id=graph_id, query=query, @@ -1283,31 +1283,31 @@ class ZepToolsService: custom_questions: List[str] = None ) -> InterviewResult: """ - 【InterviewAgents - 深度采访】 + [InterviewAgents - In-depth interview] - 调用真实的OASIS采访API,采访模拟中正在运行的Agent: - 1. 自动读取人设文件,了解所有模拟Agent - 2. 使用LLM分析采访需求,智能选择最相关的Agent - 3. 使用LLM生成采访问题 - 4. 调用 /api/simulation/interview/batch 接口进行真实采访(双平台同时采访) - 5. 整合所有采访结果,生成采访报告 + Call the real OASIS interview API,, interview running Agents in the simulation:: + 1. Automatically read persona files,, understand all simulated Agents + 2. Use LLM to analyze interview requirements,, intelligently select the most relevant Agents + 3. Use LLM to generate interview questions + 4. Call the /api/simulation/interview/batch interface for real interviews ((both platforms simultaneously)) + 5. Integrate all interview results,, generate interview report - 【重要】此功能需要模拟环境处于运行状态(OASIS环境未关闭) + [Important]this feature requires the simulation environment to be running ((OASIS environment not closed)) - 【使用场景】 - - 需要从不同角色视角了解事件看法 - - 需要收集多方意见和观点 - - 需要获取模拟Agent的真实回答(非LLM模拟) + [Use cases] + - Need to understand event views from different role perspectives + - Need to collect opinions and views from multiple parties + - Need to get real answers from simulated Agents ((not LLM simulation)) Args: - simulation_id: 模拟ID(用于定位人设文件和调用采访API) - interview_requirement: 采访需求描述(非结构化,如"了解学生对事件的看法") - simulation_requirement: 模拟需求背景(可选) - max_agents: 最多采访的Agent数量 - custom_questions: 自定义采访问题(可选,若不提供则自动生成) + simulation_id: Simulation ID (used to locate persona files and call the interview API) + interview_requirement: Interview requirement description (unstructured, e.g. "understand students' views on the event") + simulation_requirement: Simulation requirement context (optional) + max_agents: Maximum number of Agents to interview + custom_questions: Custom interview questions (optional, if not provided, auto-generate) Returns: - InterviewResult: 采访结果 + InterviewResult: Interview result (Interview) """ from .simulation_runner import SimulationRunner @@ -1318,18 +1318,18 @@ class ZepToolsService: interview_questions=custom_questions or [] ) - # Step 1: 读取人设文件 + # Step 1: Read persona file profiles = self._load_agent_profiles(simulation_id) if not profiles: logger.warning(t("console.profilesNotFound", simId=simulation_id)) - result.summary = "未找到可采访的Agent人设文件" + result.summary = "No interviewable Agent persona files found" return result result.total_agents = len(profiles) logger.info(t("console.loadedProfiles", count=len(profiles))) - # Step 2: 使用LLM选择要采访的Agent(返回agent_id列表) + # Step 2: Use LLM to select Agents to interview (return agent_id list) selected_agents, selected_indices, selection_reasoning = self._select_agents_for_interview( profiles=profiles, interview_requirement=interview_requirement, @@ -1341,7 +1341,7 @@ class ZepToolsService: result.selection_reasoning = selection_reasoning logger.info(t("console.selectedAgentsForInterview", count=len(selected_agents), indices=selected_indices)) - # Step 3: 生成采访问题(如果没有提供) + # Step 3: Generate interview questions (if not provided) if not result.interview_questions: result.interview_questions = self._generate_interview_questions( interview_requirement=interview_requirement, @@ -1350,112 +1350,112 @@ class ZepToolsService: ) logger.info(t("console.generatedInterviewQuestions", count=len(result.interview_questions))) - # 将问题合并为一个采访prompt + # merge questions into one interview prompt combined_prompt = "\n".join([f"{i+1}. {q}" for i, q in enumerate(result.interview_questions)]) - # 添加优化前缀,约束Agent回复格式 + # Add optimization prefix, constrain Agent reply format INTERVIEW_PROMPT_PREFIX = ( - "你正在接受一次采访。请结合你的人设、所有的过往记忆与行动," - "以纯文本方式直接回答以下问题。\n" - "回复要求:\n" - "1. 直接用自然语言回答,不要调用任何工具\n" - "2. 不要返回JSON格式或工具调用格式\n" - "3. 不要使用Markdown标题(如#、##、###)\n" - "4. 按问题编号逐一回答,每个回答以「问题X:」开头(X为问题编号)\n" - "5. 每个问题的回答之间用空行分隔\n" - "6. 回答要有实质内容,每个问题至少回答2-3句话\n\n" + "You are being interviewed. Please combine your persona, all past memories and actions, " + "and directly answer the following questions in plain text.\n" + "Reply requirements:\n" + "1. Directly answer in natural language, do not call any tools\n" + "2. do not return JSON format or tool call format\n" + "3. do not use Markdown headings (e.g. #, ##, ###)\n" + "4. Answer each question in order by question number, each answer starts with 'Question X:' (X is the question number)\n" + "5. Separate answers for each question with a blank line\n" + "6. Answers must have substantial content, each question should be answered with at least 2-3 sentences\n\n" ) optimized_prompt = f"{INTERVIEW_PROMPT_PREFIX}{combined_prompt}" - # Step 4: 调用真实的采访API(不指定platform,默认双平台同时采访) + # Step 4: Call the real interview API (do not specify platform, default: both platforms simultaneously) try: - # 构建批量采访列表(不指定platform,双平台采访) + # Build the batch interview list (do not specify platform, both-platform interview) interviews_request = [] for agent_idx in selected_indices: interviews_request.append({ "agent_id": agent_idx, - "prompt": optimized_prompt # 使用优化后的prompt - # 不指定platform,API会在twitter和reddit两个平台都采访 + "prompt": optimized_prompt # use the optimized prompt + # do not specify platform, the API will interview on both twitter and reddit }) logger.info(t("console.callingBatchInterviewApi", count=len(interviews_request))) - # 调用 SimulationRunner 的批量采访方法(不传platform,双平台采访) + # Call the SimulationRunner batch interview method (do not pass platform, both-platform interview) api_result = SimulationRunner.interview_agents_batch( simulation_id=simulation_id, interviews=interviews_request, - platform=None, # 不指定platform,双平台采访 - timeout=180.0 # 双平台需要更长超时 + platform=None, # do not specify platform, both-platform interview + timeout=180.0 # both platforms need a longer timeout ) logger.info(t("console.interviewApiReturned", count=api_result.get('interviews_count', 0), success=api_result.get('success'))) - # 检查API调用是否成功 + # Check whether the API call succeeded if not api_result.get("success", False): - error_msg = api_result.get("error", "未知错误") + error_msg = api_result.get("error", "Unknown error") logger.warning(t("console.interviewApiReturnedFailure", error=error_msg)) - result.summary = f"采访API调用失败:{error_msg}。请检查OASIS模拟环境状态。" + result.summary = f"Interview API call failed: {error_msg}. Please check the OASIS simulation environment status. " return result - # Step 5: 解析API返回结果,构建AgentInterview对象 - # 双平台模式返回格式: {"twitter_0": {...}, "reddit_0": {...}, "twitter_1": {...}, ...} + # Step 5: Parse API return result,, build AgentInterview object + # Both-platform mode return format: {"twitter_0": {...}, "reddit_0": {...}, "twitter_1": {...}, ...} api_data = api_result.get("result", {}) results_dict = api_data.get("results", {}) if isinstance(api_data, dict) else {} for i, agent_idx in enumerate(selected_indices): agent = selected_agents[i] agent_name = agent.get("realname", agent.get("username", f"Agent_{agent_idx}")) - agent_role = agent.get("profession", "未知") + agent_role = agent.get("profession", "unknown") agent_bio = agent.get("bio", "") - # 获取该Agent在两个平台的采访结果 + # get the Agent's interview results on both platforms twitter_result = results_dict.get(f"twitter_{agent_idx}", {}) reddit_result = results_dict.get(f"reddit_{agent_idx}", {}) twitter_response = twitter_result.get("response", "") reddit_response = reddit_result.get("response", "") - # 清理可能的工具调用 JSON 包裹 + # Clean possible tool call JSON wrappers twitter_response = self._clean_tool_call_response(twitter_response) reddit_response = self._clean_tool_call_response(reddit_response) - # 始终输出双平台标记 - twitter_text = twitter_response if twitter_response else "(该平台未获得回复)" - reddit_text = reddit_response if reddit_response else "(该平台未获得回复)" - response_text = f"【Twitter平台回答】\n{twitter_text}\n\n【Reddit平台回答】\n{reddit_text}" + # Always output both-platform markers + twitter_text = twitter_response if twitter_response else " (No reply from this platform)" + reddit_text = reddit_response if reddit_response else " (No reply from this platform)" + response_text = f"[Twitterplatform reply]\n{twitter_text}\n\n[Redditplatform reply]\n{reddit_text}" - # 提取关键引言(从两个平台的回答中) + # Extract key quotes (from the replies of both platforms) import re combined_responses = f"{twitter_response} {reddit_response}" - # 清理响应文本:去掉标记、编号、Markdown 等干扰 + # Clean response text: remove markers, numbers, Markdown, and other interference clean_text = re.sub(r'#{1,6}\s+', '', combined_responses) clean_text = re.sub(r'\{[^}]*tool_name[^}]*\}', '', clean_text) clean_text = re.sub(r'[*_`|>~\-]{2,}', '', clean_text) - clean_text = re.sub(r'问题\d+[::]\s*', '', clean_text) - clean_text = re.sub(r'【[^】]+】', '', clean_text) + clean_text = re.sub(r'Question\s*\d+\s*[: :]\s*', '', clean_text) + clean_text = re.sub(r'[[^]]+]', '', clean_text) - # 策略1(主): 提取完整的有实质内容的句子 - sentences = re.split(r'[。!?]', clean_text) + # Strategy 1 (main): Extract complete sentences with substantial content + sentences = re.split(r'[. !?]', clean_text) meaningful = [ s.strip() for s in sentences if 20 <= len(s.strip()) <= 150 - and not re.match(r'^[\s\W,,;;::、]+', s.strip()) - and not s.strip().startswith(('{', '问题')) + and not re.match(r'^[\s\W, ,; ;: :, ]+', s.strip()) + and not s.strip().startswith(('{', 'Question')) ] meaningful.sort(key=len, reverse=True) - key_quotes = [s + "。" for s in meaningful[:3]] + key_quotes = [s + ". " for s in meaningful[:3]] - # 策略2(补充): 正确配对的中文引号「」内长文本 + # Strategy 2 (supplementary): correctly paired Chinese quotation marks "" wrapping long text if not key_quotes: paired = re.findall(r'\u201c([^\u201c\u201d]{15,100})\u201d', clean_text) paired += re.findall(r'\u300c([^\u300c\u300d]{15,100})\u300d', clean_text) - key_quotes = [q for q in paired if not re.match(r'^[,,;;::、]', q)][:3] + key_quotes = [q for q in paired if not re.match(r'^[, ,; ;: :, ]', q)][:3] interview = AgentInterview( agent_name=agent_name, agent_role=agent_role, - agent_bio=agent_bio[:1000], # 扩大bio长度限制 + agent_bio=agent_bio[:1000], # expand bio length limit question=combined_prompt, response=response_text, key_quotes=key_quotes[:5] @@ -1465,18 +1465,18 @@ class ZepToolsService: result.interviewed_count = len(result.interviews) except ValueError as e: - # 模拟环境未运行 + # Simulation environment is not running logger.warning(t("console.interviewApiCallFailed", error=e)) - result.summary = f"采访失败:{str(e)}。模拟环境可能已关闭,请确保OASIS环境正在运行。" + result.summary = f"Interview failed: {str(e)}. simulation environment may have been closed, Please ensure the OASIS environment is running. " return result except Exception as e: logger.error(t("console.interviewApiCallException", error=e)) import traceback logger.error(traceback.format_exc()) - result.summary = f"采访过程发生错误:{str(e)}" + result.summary = f"Error occurred during interview process: {str(e)}" return result - # Step 6: 生成采访摘要 + # Step 6: Generate interview summary if result.interviews: result.summary = self._generate_interview_summary( interviews=result.interviews, @@ -1488,7 +1488,7 @@ class ZepToolsService: @staticmethod def _clean_tool_call_response(response: str) -> str: - """清理 Agent 回复中的 JSON 工具调用包裹,提取实际内容""" + """Clean JSON tool call wrappers in Agent replies,, extract actual content""" if not response or not response.strip().startswith('{'): return response text = response.strip() @@ -1508,11 +1508,11 @@ class ZepToolsService: return response def _load_agent_profiles(self, simulation_id: str) -> List[Dict[str, Any]]: - """加载模拟的Agent人设文件""" + """Load the simulation Agent persona file""" import os import csv - # 构建人设文件路径 + # Build persona file path sim_dir = os.path.join( os.path.dirname(__file__), f'../../uploads/simulations/{simulation_id}' @@ -1520,7 +1520,7 @@ class ZepToolsService: profiles = [] - # 优先尝试读取Reddit JSON格式 + # Prefer to read Reddit JSON format reddit_profile_path = os.path.join(sim_dir, "reddit_profiles.json") if os.path.exists(reddit_profile_path): try: @@ -1531,20 +1531,20 @@ class ZepToolsService: except Exception as e: logger.warning(t("console.readRedditProfilesFailed", error=e)) - # 尝试读取Twitter CSV格式 + # Try to read Twitter CSV format twitter_profile_path = os.path.join(sim_dir, "twitter_profiles.csv") if os.path.exists(twitter_profile_path): try: with open(twitter_profile_path, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: - # CSV格式转换为统一格式 + # Convert CSV format to unified format profiles.append({ "realname": row.get("name", ""), "username": row.get("username", ""), "bio": row.get("description", ""), "persona": row.get("user_char", ""), - "profession": "未知" + "profession": "unknown" }) logger.info(t("console.loadedTwitterProfiles", count=len(profiles))) return profiles @@ -1561,51 +1561,51 @@ class ZepToolsService: max_agents: int ) -> tuple: """ - 使用LLM选择要采访的Agent + Use LLM to select Agents to interview Returns: tuple: (selected_agents, selected_indices, reasoning) - - selected_agents: 选中Agent的完整信息列表 - - selected_indices: 选中Agent的索引列表(用于API调用) - - reasoning: 选择理由 + - selected_agents: complete information list of selected Agents + - selected_indices: index list of selected Agents (used for API calls) + - reasoning: Selection reasoning """ - # 构建Agent摘要列表 + # Build Agent summary list agent_summaries = [] for i, profile in enumerate(profiles): summary = { "index": i, "name": profile.get("realname", profile.get("username", f"Agent_{i}")), - "profession": profile.get("profession", "未知"), + "profession": profile.get("profession", "unknown"), "bio": profile.get("bio", "")[:200], "interested_topics": profile.get("interested_topics", []) } agent_summaries.append(summary) - system_prompt = """你是一个专业的采访策划专家。你的任务是根据采访需求,从模拟Agent列表中选择最适合采访的对象。 + system_prompt = """You are a professional interview planning expert.. Your task is to select the most suitable interview subjects, from the simulation Agent list based on interview requirements. -选择标准: -1. Agent的身份/职业与采访主题相关 -2. Agent可能持有独特或有价值的观点 -3. 选择多样化的视角(如:支持方、反对方、中立方、专业人士等) -4. 优先选择与事件直接相关的角色 +Selection criteria: +1. Agent identity/profession is related to the interview topic +2. Agent may hold unique or valuable viewpoints +3. Select diverse perspectives (e.g., supporters, opponents, neutrals, professionals, etc.) +4. Prefer roles directly related to the event -返回JSON格式: +Return in JSON format: { - "selected_indices": [选中Agent的索引列表], - "reasoning": "选择理由说明" + "selected_indices": [index list of selected Agents], + "reasoning": "Selection reasoning explanation" }""" - user_prompt = f"""采访需求: + user_prompt = f"""Interview requirement: {interview_requirement} -模拟背景: -{simulation_requirement if simulation_requirement else "未提供"} +Simulation context: +{simulation_requirement if simulation_requirement else "not provided"} -可选择的Agent列表(共{len(agent_summaries)}个): +Selectable Agent list (total{len(agent_summaries)}): {json.dumps(agent_summaries, ensure_ascii=False, indent=2)} -请选择最多{max_agents}个最适合采访的Agent,并说明选择理由。""" +Please select at most{max_agents}most suitable Agents to interview, and explain the selection reasoning. """ try: response = self.llm.chat_json( @@ -1617,9 +1617,9 @@ class ZepToolsService: ) selected_indices = response.get("selected_indices", [])[:max_agents] - reasoning = response.get("reasoning", "基于相关性自动选择") + reasoning = response.get("reasoning", "Automatically selected based on relevance") - # 获取选中的Agent完整信息 + # Get complete information of selected Agents selected_agents = [] valid_indices = [] for idx in selected_indices: @@ -1631,10 +1631,10 @@ class ZepToolsService: except Exception as e: logger.warning(t("console.llmSelectAgentFailed", error=e)) - # 降级:选择前N个 + # Fallback: select the first N selected = profiles[:max_agents] indices = list(range(min(max_agents, len(profiles)))) - return selected, indices, "使用默认选择策略" + return selected, indices, "Use default selection strategy" def _generate_interview_questions( self, @@ -1642,29 +1642,29 @@ class ZepToolsService: simulation_requirement: str, selected_agents: List[Dict[str, Any]] ) -> List[str]: - """使用LLM生成采访问题""" + """Use LLM to generate interview questions""" - agent_roles = [a.get("profession", "未知") for a in selected_agents] + agent_roles = [a.get("profession", "unknown") for a in selected_agents] - system_prompt = """你是一个专业的记者/采访者。根据采访需求,生成3-5个深度采访问题。 + system_prompt = """You are a professional journalist/interviewer.. Based on interview requirements,, generate 3-5 in-depth interview questions. -问题要求: -1. 开放性问题,鼓励详细回答 -2. 针对不同角色可能有不同答案 -3. 涵盖事实、观点、感受等多个维度 -4. 语言自然,像真实采访一样 -5. 每个问题控制在50字以内,简洁明了 -6. 直接提问,不要包含背景说明或前缀 +Question requirements: +1. open-ended questions,, encourage detailed answers +2. different roles may have different answers +3. cover multiple dimensions such as facts, opinions, feelings +4. Natural language,, like a real interview +5. each question within 50 words,, concise and clear +6. ask directly,, do not include background explanation or prefix -返回JSON格式:{"questions": ["问题1", "问题2", ...]}""" +Return in JSON format: {"questions": ["question 1", "question 2", ...]}""" - user_prompt = f"""采访需求:{interview_requirement} + user_prompt = f"""Interview requirement: {interview_requirement} -模拟背景:{simulation_requirement if simulation_requirement else "未提供"} +Simulation context: {simulation_requirement if simulation_requirement else "not provided"} -采访对象角色:{', '.join(agent_roles)} +Interviewee roles: {', '.join(agent_roles)} -请生成3-5个采访问题。""" +Please generate 3-5 interview questions. """ try: response = self.llm.chat_json( @@ -1675,14 +1675,14 @@ class ZepToolsService: temperature=0.5 ) - return response.get("questions", [f"关于{interview_requirement},您有什么看法?"]) + return response.get("questions", [f"Regarding{interview_requirement}, what are your views?"]) except Exception as e: logger.warning(t("console.generateInterviewQuestionsFailed", error=e)) return [ - f"关于{interview_requirement},您的观点是什么?", - "这件事对您或您所代表的群体有什么影响?", - "您认为应该如何解决或改进这个问题?" + f"Regarding{interview_requirement}, what is your opinion?", + "What impact does this matter have on you or the group you represent?", + "What do you think should be done to resolve or improve this issue?" ] def _generate_interview_summary( @@ -1690,39 +1690,39 @@ class ZepToolsService: interviews: List[AgentInterview], interview_requirement: str ) -> str: - """生成采访摘要""" + """Generate interview summary""" if not interviews: - return "未完成任何采访" + return "No interviews completed" - # 收集所有采访内容 + # Collect all interview content interview_texts = [] for interview in interviews: - interview_texts.append(f"【{interview.agent_name}({interview.agent_role})】\n{interview.response[:500]}") + interview_texts.append(f"[{interview.agent_name} ({interview.agent_role})]\n{interview.response[:500]}") - quote_instruction = "引用受访者原话时使用中文引号「」" if get_locale() == 'zh' else 'Use quotation marks "" when quoting interviewees' - system_prompt = f"""你是一个专业的新闻编辑。请根据多位受访者的回答,生成一份采访摘要。 + quote_instruction = "use Chinese quotation marks "" when quoting interviewees" if get_locale() == 'zh' else 'Use quotation marks "" when quoting interviewees' + system_prompt = f"""You are a professional news editor.. Please generate an interview summary, based on the answers from multiple interviewees. -摘要要求: -1. 提炼各方主要观点 -2. 指出观点的共识和分歧 -3. 突出有价值的引言 -4. 客观中立,不偏袒任何一方 -5. 控制在1000字内 +Summary requirements: +1. Extract the main viewpoints of each party +2. Point out consensus and divergence of viewpoints +3. Highlight valuable quotes +4. Objective and neutral,, do not favor any party +5. limit to 1000 words -格式约束(必须遵守): -- 使用纯文本段落,用空行分隔不同部分 -- 不要使用Markdown标题(如#、##、###) -- 不要使用分割线(如---、***) +Format constraints ((must follow)): +- use plain text paragraphs,, separate different sections with blank lines +- do not use Markdown headings (e.g. #, ##, ###) +- do not use dividers (e.g. ---, ***) - {quote_instruction} -- 可以使用**加粗**标记关键词,但不要使用其他Markdown语法""" +- may use**bold**to mark keywords,, but do not use other Markdown syntax""" - user_prompt = f"""采访主题:{interview_requirement} + user_prompt = f"""Interview topic: {interview_requirement} -采访内容: +Interview content: {"".join(interview_texts)} -请生成采访摘要。""" +Please generate interview summary. """ try: summary = self.llm.chat( @@ -1737,5 +1737,5 @@ class ZepToolsService: except Exception as e: logger.warning(t("console.generateInterviewSummaryFailed", error=e)) - # 降级:简单拼接 - return f"共采访了{len(interviews)}位受访者,包括:" + "、".join([i.agent_name for i in interviews]) + # Fallback: simple concatenation + return f"Interviewed a total of{len(interviews)}interviewees,, including: " + ", ".join([i.agent_name for i in interviews]) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index e70161ac..e5ca8556 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -1,5 +1,5 @@ """ -工具模块 +Utility module. """ from .file_parser import FileParser @@ -7,4 +7,3 @@ from .llm_client import LLMClient from .locale import t, get_locale, set_locale, get_language_instruction __all__ = ['FileParser', 'LLMClient', 't', 'get_locale', 'set_locale', 'get_language_instruction'] - diff --git a/backend/app/utils/file_parser.py b/backend/app/utils/file_parser.py index 5e540434..8507e9e9 100644 --- a/backend/app/utils/file_parser.py +++ b/backend/app/utils/file_parser.py @@ -1,6 +1,6 @@ """ -文件解析工具 -支持PDF、Markdown、TXT文件的文本提取 +File parser utilities +Supports text extraction from PDF, Markdown, and TXT files """ import os @@ -10,29 +10,29 @@ from typing import List, Optional def _read_text_with_fallback(file_path: str) -> str: """ - 读取文本文件,UTF-8失败时自动探测编码。 + Read a text file with UTF-8; auto-detect encoding on failure. - 采用多级回退策略: - 1. 首先尝试 UTF-8 解码 - 2. 使用 charset_normalizer 检测编码 - 3. 回退到 chardet 检测编码 - 4. 最终使用 UTF-8 + errors='replace' 兜底 + Uses a multi-level fallback strategy: + 1. 1. First try UTF-8 decoding + 2. 2. Use charset_normalizer to detect encoding + 3. 3. Fall back to chardet + 4. 4. Final fallback: UTF-8 with errors="replace" Args: - file_path: 文件路径 + file_path: file path Returns: - 解码后的文本内容 + decoded text content """ data = Path(file_path).read_bytes() - # 首先尝试 UTF-8 + # First try UTF-8 try: return data.decode('utf-8') except UnicodeDecodeError: pass - # 尝试使用 charset_normalizer 检测编码 + # 2. Use charset_normalizer to detect encoding encoding = None try: from charset_normalizer import from_bytes @@ -42,7 +42,7 @@ def _read_text_with_fallback(file_path: str) -> str: except Exception: pass - # 回退到 chardet + # Fall back to chardet if not encoding: try: import chardet @@ -51,7 +51,7 @@ def _read_text_with_fallback(file_path: str) -> str: except Exception: pass - # 最终兜底:使用 UTF-8 + replace + # Final fallback: UTF-8 with replace if not encoding: encoding = 'utf-8' @@ -59,20 +59,20 @@ def _read_text_with_fallback(file_path: str) -> str: class FileParser: - """文件解析器""" + """File parser""" SUPPORTED_EXTENSIONS = {'.pdf', '.md', '.markdown', '.txt'} @classmethod def is_supported(cls, file_path: str) -> bool: """ - 检查文件是否为支持的格式 + Check whether a file is in a supported format Args: - file_path: 文件路径 + file_path: file path Returns: - 如果文件格式受支持则返回 True + Return True if the file format is supported """ suffix = Path(file_path).suffix.lower() return suffix in cls.SUPPORTED_EXTENSIONS @@ -80,23 +80,23 @@ class FileParser: @classmethod def extract_text(cls, file_path: str) -> str: """ - 从文件中提取文本 + Extract text from a file Args: - file_path: 文件路径 + file_path: file path Returns: - 提取的文本内容 + extracted text content """ path = Path(file_path) if not path.exists(): - raise FileNotFoundError(f"文件不存在: {file_path}") + raise FileNotFoundError(f"File does not exist: {file_path}") suffix = path.suffix.lower() if suffix not in cls.SUPPORTED_EXTENSIONS: - raise ValueError(f"不支持的文件格式: {suffix}") + raise ValueError(f"Unsupported file format: {suffix}") if suffix == '.pdf': return cls._extract_from_pdf(file_path) @@ -105,15 +105,15 @@ class FileParser: elif suffix == '.txt': return cls._extract_from_txt(file_path) - raise ValueError(f"无法处理的文件格式: {suffix}") + raise ValueError(f"Cannot process file format: {suffix}") @staticmethod def _extract_from_pdf(file_path: str) -> str: - """从PDF提取文本""" + """Extract text from PDF""" try: import fitz # PyMuPDF except ImportError: - raise ImportError("需要安装PyMuPDF: pip install PyMuPDF") + raise ImportError("PyMuPDF is required: pip install PyMuPDF") text_parts = [] with fitz.open(file_path) as doc: @@ -126,24 +126,24 @@ class FileParser: @staticmethod def _extract_from_md(file_path: str) -> str: - """从Markdown提取文本,支持自动编码检测""" + """Extract text from Markdown with auto-encoding detection""" return _read_text_with_fallback(file_path) @staticmethod def _extract_from_txt(file_path: str) -> str: - """从TXT提取文本,支持自动编码检测""" + """Extract text from TXT with auto-encoding detection""" return _read_text_with_fallback(file_path) @classmethod def extract_from_multiple(cls, file_paths: List[str]) -> str: """ - 从多个文件提取文本并合并 + Extract and merge text from multiple files Args: - file_paths: 文件路径列表 + file_paths: list of file paths Returns: - 合并后的文本 + merged text """ all_texts = [] @@ -151,9 +151,9 @@ class FileParser: try: text = cls.extract_text(file_path) filename = Path(file_path).name - all_texts.append(f"=== 文档 {i}: {filename} ===\n{text}") + all_texts.append(f"=== Document {i}: {filename} ===\n{text}") except Exception as e: - all_texts.append(f"=== 文档 {i}: {file_path} (提取失败: {str(e)}) ===") + all_texts.append(f"=== Document {i}: {file_path} (extraction failed: {str(e)}) ===") return "\n\n".join(all_texts) @@ -164,15 +164,15 @@ def split_text_into_chunks( overlap: int = 50 ) -> List[str]: """ - 将文本分割成小块 + Split text into chunks Args: - text: 原始文本 - chunk_size: 每块的字符数 - overlap: 重叠字符数 + text: raw text + chunk_size: characters per chunk + overlap: overlap character count Returns: - 文本块列表 + list of text chunks """ if len(text) <= chunk_size: return [text] if text.strip() else [] @@ -183,9 +183,9 @@ def split_text_into_chunks( while start < len(text): end = start + chunk_size - # 尝试在句子边界处分割 + # Try to split on sentence boundaries if end < len(text): - # 查找最近的句子结束符 + # Find the nearest sentence-ending punctuation for sep in ['。', '!', '?', '.\n', '!\n', '?\n', '\n\n', '. ', '! ', '? ']: last_sep = text[start:end].rfind(sep) if last_sep != -1 and last_sep > chunk_size * 0.3: @@ -196,7 +196,7 @@ def split_text_into_chunks( if chunk: chunks.append(chunk) - # 下一个块从重叠位置开始 + # The next chunk starts at the overlap position start = end - overlap if end < len(text) else len(text) return chunks diff --git a/backend/app/utils/llm_client.py b/backend/app/utils/llm_client.py index 2587fc31..0d3be9d2 100644 --- a/backend/app/utils/llm_client.py +++ b/backend/app/utils/llm_client.py @@ -1,6 +1,6 @@ """ -LLM客户端封装 -统一使用OpenAI格式调用 +LLM client wrapper +Unified OpenAI-format invocation """ import json @@ -12,7 +12,7 @@ from ..config import Config class LLMClient: - """LLM客户端""" + """LLM client""" def __init__( self, @@ -25,7 +25,7 @@ class LLMClient: self.model = model or Config.LLM_MODEL_NAME if not self.api_key: - raise ValueError("LLM_API_KEY 未配置") + raise ValueError("LLM_API_KEY is not configured") self.client = OpenAI( api_key=self.api_key, @@ -40,16 +40,16 @@ class LLMClient: response_format: Optional[Dict] = None ) -> str: """ - 发送聊天请求 + Send a chat completion request Args: - messages: 消息列表 - temperature: 温度参数 - max_tokens: 最大token数 - response_format: 响应格式(如JSON模式) + messages: message list + temperature: sampling temperature + max_tokens: max tokens + response_format: response format(e.g. JSON mode) Returns: - 模型响应文本 + model response text """ kwargs = { "model": self.model, @@ -63,7 +63,7 @@ class LLMClient: response = self.client.chat.completions.create(**kwargs) content = response.choices[0].message.content - # 部分模型(如MiniMax M2.5)会在content中包含思考内容,需要移除 + # Some models (e.g. MiniMax M2.5) embed ... reasoning in content — strip it content = re.sub(r'[\s\S]*?', '', content).strip() return content @@ -74,16 +74,16 @@ class LLMClient: max_tokens: int = 16384 ) -> Dict[str, Any]: """ - 发送聊天请求并返回JSON + Send a chat completion requestand parse the response as JSON Args: - messages: 消息列表 - temperature: 温度参数 - max_tokens: 最大token数 (默认 16384 — MiniMax M3 在本体/配置文件 - 这类长 JSON 模式下,4096 tokens 经常会把 JSON 截断在中间) + messages: message list + temperature: sampling temperature + max_tokens: max tokens (default 16384 — MiniMax M3 in ontology / config + long-JSON mode — 4096 tokens frequently truncates the JSON in the middle) Returns: - 解析后的JSON对象 + parsed JSON object """ response = self.chat( messages=messages, @@ -91,7 +91,7 @@ class LLMClient: max_tokens=max_tokens, response_format={"type": "json_object"} ) - # 清理markdown代码块标记 + # Strip markdown code-block fences # MiniMax M3 ignores response_format=json_object and wraps JSON in # markdown fences. Be aggressive about stripping them, including the # ```json\n and trailing ``` even when surrounded by other content. @@ -111,5 +111,5 @@ class LLMClient: try: return json.loads(cleaned_response) except json.JSONDecodeError: - raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response[:200]}") + raise ValueError(f"LLM returned invalid JSON: {cleaned_response[:200]}") diff --git a/backend/app/utils/locale.py b/backend/app/utils/locale.py index 23d04aa9..1201d562 100644 --- a/backend/app/utils/locale.py +++ b/backend/app/utils/locale.py @@ -1,3 +1,9 @@ +""" +Locale / i18n helpers used by the backend. Loads translation files from +``/opt/data/work/MiroFish/locales`` and exposes ``t(key)`` lookups plus a +per-thread locale setting. +""" + import json import os import threading @@ -64,6 +70,10 @@ def t(key: str, **kwargs) -> str: def get_language_instruction() -> str: + """ + Return the LLM language-injection string for the active locale. + Falls back to a Chinese instruction if no configuration is found. + """ locale = get_locale() lang_config = _languages.get(locale, _languages.get('zh', {})) - return lang_config.get('llmInstruction', '请使用中文回答。') + return lang_config.get('llmInstruction', 'Please reply in English.') diff --git a/backend/app/utils/logger.py b/backend/app/utils/logger.py index 93422afa..f1dc6c44 100644 --- a/backend/app/utils/logger.py +++ b/backend/app/utils/logger.py @@ -1,6 +1,6 @@ """ -日志配置模块 -提供统一的日志管理,同时输出到控制台和文件 +Logging configuration module +Provides unified log management, output to both console and file """ import os @@ -12,47 +12,47 @@ from logging.handlers import RotatingFileHandler def _ensure_utf8_stdout(): """ - 确保 stdout/stderr 使用 UTF-8 编码 - 解决 Windows 控制台中文乱码问题 + Ensure stdout/stderr use UTF-8 + Work around Windows console garbled Chinese """ if sys.platform == 'win32': - # Windows 下重新配置标准输出为 UTF-8 + # Reconfigure stdout to UTF-8 on Windows if hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(encoding='utf-8', errors='replace') if hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(encoding='utf-8', errors='replace') -# 日志目录 +# Log directory LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs') def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.Logger: """ - 设置日志器 + Set up a logger Args: - name: 日志器名称 - level: 日志级别 + name: logger name + level: log level Returns: - 配置好的日志器 + configured logger """ - # 确保日志目录存在 + # Ensure log directory exists os.makedirs(LOG_DIR, exist_ok=True) - # 创建日志器 + # Create logger logger = logging.getLogger(name) logger.setLevel(level) - # 阻止日志向上传播到根 logger,避免重复输出 + # Prevent logs from propagating to the root logger (avoid double output) logger.propagate = False - # 如果已经有处理器,不重复添加 + # If a handler is already attached, do not add another if logger.handlers: return logger - # 日志格式 + # Log format detailed_formatter = logging.Formatter( '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s', datefmt='%Y-%m-%d %H:%M:%S' @@ -63,7 +63,7 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging. datefmt='%H:%M:%S' ) - # 1. 文件处理器 - 详细日志(按日期命名,带轮转) + # 1. File handler — verbose log (date-named, with rotation) log_filename = datetime.now().strftime('%Y-%m-%d') + '.log' file_handler = RotatingFileHandler( os.path.join(LOG_DIR, log_filename), @@ -74,14 +74,14 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging. file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(detailed_formatter) - # 2. 控制台处理器 - 简洁日志(INFO及以上) - # 确保 Windows 下使用 UTF-8 编码,避免中文乱码 + # 2. Console handler — concise log (INFO and above) + # Use UTF-8 on Windows to avoid garbled output _ensure_utf8_stdout() console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(logging.INFO) console_handler.setFormatter(simple_formatter) - # 添加处理器 + # Add handler logger.addHandler(file_handler) logger.addHandler(console_handler) @@ -90,13 +90,13 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging. def get_logger(name: str = 'mirofish') -> logging.Logger: """ - 获取日志器(如果不存在则创建) + Get a logger (creating it if it does not exist) Args: - name: 日志器名称 + name: logger name Returns: - 日志器实例 + logger instance """ logger = logging.getLogger(name) if not logger.handlers: @@ -104,11 +104,11 @@ def get_logger(name: str = 'mirofish') -> logging.Logger: return logger -# 创建默认日志器 +# Create the default logger logger = setup_logger() -# 便捷方法 +# Convenience methods def debug(msg: str, *args, **kwargs) -> None: logger.debug(msg, *args, **kwargs) diff --git a/backend/app/utils/retry.py b/backend/app/utils/retry.py index 819b1cfc..46d96ae2 100644 --- a/backend/app/utils/retry.py +++ b/backend/app/utils/retry.py @@ -1,6 +1,6 @@ """ -API调用重试机制 -用于处理LLM等外部API调用的重试逻辑 +API-call retry mechanism +Handles retry logic for external API calls (e.g. LLM) """ import time @@ -22,16 +22,16 @@ def retry_with_backoff( on_retry: Optional[Callable[[Exception, int], None]] = None ): """ - 带指数退避的重试装饰器 + Retry decorator with exponential backoff Args: - max_retries: 最大重试次数 - initial_delay: 初始延迟(秒) - max_delay: 最大延迟(秒) - backoff_factor: 退避因子 - jitter: 是否添加随机抖动 - exceptions: 需要重试的异常类型 - on_retry: 重试时的回调函数 (exception, retry_count) + max_retries: maximum retry count + initial_delay: initial delay (seconds) + max_delay: maximum delay (seconds) + backoff_factor: backoff factor + jitter: whether to add random jitter + exceptions: exception types to retry on + on_retry: callback invoked on each retry (exception, retry_count) Usage: @retry_with_backoff(max_retries=3) @@ -52,17 +52,17 @@ def retry_with_backoff( last_exception = e if attempt == max_retries: - logger.error(f"函数 {func.__name__} 在 {max_retries} 次重试后仍失败: {str(e)}") + logger.error(f"Function {func.__name__} still failed after {max_retries} retries: {str(e)}") raise - # 计算延迟 + # Calculate delay current_delay = min(delay, max_delay) if jitter: current_delay = current_delay * (0.5 + random.random()) logger.warning( - f"函数 {func.__name__} 第 {attempt + 1} 次尝试失败: {str(e)}, " - f"{current_delay:.1f}秒后重试..." + f"function {func.__name__} attempt {attempt + 1} failed: {str(e)}, " + f"{current_delay:.1f}s, retrying..." ) if on_retry: @@ -87,7 +87,7 @@ def retry_with_backoff_async( on_retry: Optional[Callable[[Exception, int], None]] = None ): """ - 异步版本的重试装饰器 + Async version of the retry decorator """ import asyncio @@ -105,7 +105,7 @@ def retry_with_backoff_async( last_exception = e if attempt == max_retries: - logger.error(f"异步函数 {func.__name__} 在 {max_retries} 次重试后仍失败: {str(e)}") + logger.error(f"async function {func.__name__} still failed after {max_retries} retries: {str(e)}") raise current_delay = min(delay, max_delay) @@ -113,8 +113,8 @@ def retry_with_backoff_async( current_delay = current_delay * (0.5 + random.random()) logger.warning( - f"异步函数 {func.__name__} 第 {attempt + 1} 次尝试失败: {str(e)}, " - f"{current_delay:.1f}秒后重试..." + f"async function {func.__name__} attempt {attempt + 1} failed: {str(e)}, " + f"{current_delay:.1f}s, retrying..." ) if on_retry: @@ -131,7 +131,7 @@ def retry_with_backoff_async( class RetryableAPIClient: """ - 可重试的API客户端封装 + Retry-capable API client wrapper """ def __init__( @@ -154,16 +154,16 @@ class RetryableAPIClient: **kwargs ) -> Any: """ - 执行函数调用并在失败时重试 + Execute a function with retry on failure Args: - func: 要调用的函数 - *args: 函数参数 - exceptions: 需要重试的异常类型 - **kwargs: 函数关键字参数 + func: function to call + *args: function positional args + exceptions: exception types to retry on + **kwargs: function keyword args Returns: - 函数返回值 + function return value """ last_exception = None delay = self.initial_delay @@ -176,15 +176,15 @@ class RetryableAPIClient: last_exception = e if attempt == self.max_retries: - logger.error(f"API调用在 {self.max_retries} 次重试后仍失败: {str(e)}") + logger.error(f"API call still failed after {self.max_retries} retries: {str(e)}") raise current_delay = min(delay, self.max_delay) current_delay = current_delay * (0.5 + random.random()) logger.warning( - f"API调用第 {attempt + 1} 次尝试失败: {str(e)}, " - f"{current_delay:.1f}秒后重试..." + f"API callattempt {attempt + 1} failed: {str(e)}, " + f"{current_delay:.1f}s, retrying..." ) time.sleep(current_delay) @@ -200,16 +200,16 @@ class RetryableAPIClient: continue_on_failure: bool = True ) -> Tuple[list, list]: """ - 批量调用并对每个失败项单独重试 + Batch call with per-item retry on failure Args: - items: 要处理的项目列表 - process_func: 处理函数,接收单个item作为参数 - exceptions: 需要重试的异常类型 - continue_on_failure: 单项失败后是否继续处理其他项 + items: list of items to process + process_func: processing function, takes a single item + exceptions: exception types to retry on + continue_on_failure: whether to keep going after an individual item fails Returns: - (成功结果列表, 失败项列表) + (list of successful results, list of failed items) """ results = [] failures = [] @@ -224,7 +224,7 @@ class RetryableAPIClient: results.append(result) except Exception as e: - logger.error(f"处理第 {idx + 1} 项失败: {str(e)}") + logger.error(f"Processing item {idx + 1} failed: {str(e)}") failures.append({ "index": idx, "item": item, diff --git a/backend/run.py b/backend/run.py index 4e3b04fa..b9081321 100644 --- a/backend/run.py +++ b/backend/run.py @@ -1,21 +1,21 @@ """ -MiroFish Backend 启动入口 +MiroFish backend entry point """ import os import sys -# 解决 Windows 控制台中文乱码问题:在所有导入之前设置 UTF-8 编码 +# Work around Windows console garbled Chinese output:Set UTF-8 encoding before any imports if sys.platform == 'win32': - # 设置环境变量确保 Python 使用 UTF-8 + # Set environment variable to ensure Python uses UTF-8 os.environ.setdefault('PYTHONIOENCODING', 'utf-8') - # 重新配置标准输出流为 UTF-8 + # Reconfigure stdout to UTF-8 if hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(encoding='utf-8', errors='replace') if hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(encoding='utf-8', errors='replace') -# 添加项目根目录到路径 +# Add the project root to sys.path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from app import create_app @@ -23,25 +23,25 @@ from app.config import Config def main(): - """主函数""" - # 验证配置 + """Main entry point""" + # Validate configuration errors = Config.validate() if errors: - print("配置错误:") + print("Configuration error:") for err in errors: print(f" - {err}") - print("\n请检查 .env 文件中的配置") + print("\nPlease check the configuration in .env") sys.exit(1) - # 创建应用 + # Create app app = create_app() - # 获取运行配置 + # Read runtime config host = os.environ.get('FLASK_HOST', '0.0.0.0') port = int(os.environ.get('FLASK_PORT', 5001)) debug = Config.DEBUG - # 启动服务 + # Start the server app.run(host=host, port=port, debug=debug, threaded=True) diff --git a/backend/scripts/action_logger.py b/backend/scripts/action_logger.py index 38d025a6..33e30d39 100644 --- a/backend/scripts/action_logger.py +++ b/backend/scripts/action_logger.py @@ -1,15 +1,15 @@ """ -动作日志记录器 -用于记录OASIS模拟中每个Agent的动作,供后端监控使用 +Action logger +Records every Agent's actions in the OASIS simulation for backend monitoring. -日志结构: +Log structure: sim_xxx/ ├── twitter/ - │ └── actions.jsonl # Twitter 平台动作日志 + │ └── actions.jsonl # Twitter platform action log ├── reddit/ - │ └── actions.jsonl # Reddit 平台动作日志 - ├── simulation.log # 主模拟进程日志 - └── run_state.json # 运行状态(API 查询用) + │ └── actions.jsonl # Reddit platform action log + ├── simulation.log # Main simulation process log + └── run_state.json # Run state (used for API queries) """ import json @@ -20,26 +20,26 @@ from typing import Dict, Any, Optional class PlatformActionLogger: - """单平台动作日志记录器""" - + """Single-platform action logger""" + def __init__(self, platform: str, base_dir: str): """ - 初始化日志记录器 - + Initialize the logger. + Args: - platform: 平台名称 (twitter/reddit) - base_dir: 模拟目录的基础路径 + platform: Platform name (twitter/reddit) + base_dir: Base path of the simulation directory """ self.platform = platform self.base_dir = base_dir self.log_dir = os.path.join(base_dir, platform) self.log_path = os.path.join(self.log_dir, "actions.jsonl") self._ensure_dir() - + def _ensure_dir(self): - """确保目录存在""" + """Ensure the directory exists""" os.makedirs(self.log_dir, exist_ok=True) - + def log_action( self, round_num: int, @@ -50,7 +50,7 @@ class PlatformActionLogger: result: Optional[str] = None, success: bool = True ): - """记录一个动作""" + """Record a single action""" entry = { "round": round_num, "timestamp": datetime.now().isoformat(), @@ -66,31 +66,31 @@ class PlatformActionLogger: f.write(json.dumps(entry, ensure_ascii=False) + '\n') def log_round_start(self, round_num: int, simulated_hour: int): - """记录轮次开始""" + """Record round start""" entry = { "round": round_num, "timestamp": datetime.now().isoformat(), "event_type": "round_start", "simulated_hour": simulated_hour, } - + with open(self.log_path, 'a', encoding='utf-8') as f: f.write(json.dumps(entry, ensure_ascii=False) + '\n') - + def log_round_end(self, round_num: int, actions_count: int): - """记录轮次结束""" + """Record round end""" entry = { "round": round_num, "timestamp": datetime.now().isoformat(), "event_type": "round_end", "actions_count": actions_count, } - + with open(self.log_path, 'a', encoding='utf-8') as f: f.write(json.dumps(entry, ensure_ascii=False) + '\n') - + def log_simulation_start(self, config: Dict[str, Any]): - """记录模拟开始""" + """Record simulation start""" entry = { "timestamp": datetime.now().isoformat(), "event_type": "simulation_start", @@ -98,12 +98,12 @@ class PlatformActionLogger: "total_rounds": config.get("time_config", {}).get("total_simulation_hours", 72) * 2, "agents_count": len(config.get("agent_configs", [])), } - + with open(self.log_path, 'a', encoding='utf-8') as f: f.write(json.dumps(entry, ensure_ascii=False) + '\n') - + def log_simulation_end(self, total_rounds: int, total_actions: int): - """记录模拟结束""" + """Record simulation end""" entry = { "timestamp": datetime.now().isoformat(), "event_type": "simulation_end", @@ -118,35 +118,35 @@ class PlatformActionLogger: class SimulationLogManager: """ - 模拟日志管理器 - 统一管理所有日志文件,按平台分离 + Simulation log manager + Unifies management of all log files, separated by platform. """ - + def __init__(self, simulation_dir: str): """ - 初始化日志管理器 - + Initialize the log manager. + Args: - simulation_dir: 模拟目录路径 + simulation_dir: Path of the simulation directory """ self.simulation_dir = simulation_dir self.twitter_logger: Optional[PlatformActionLogger] = None self.reddit_logger: Optional[PlatformActionLogger] = None self._main_logger: Optional[logging.Logger] = None - - # 设置主日志 + + # Set up the main logger self._setup_main_logger() - + def _setup_main_logger(self): - """设置主模拟日志""" + """Set up the main simulation logger""" log_path = os.path.join(self.simulation_dir, "simulation.log") - - # 创建 logger + + # Create the logger self._main_logger = logging.getLogger(f"simulation.{os.path.basename(self.simulation_dir)}") self._main_logger.setLevel(logging.INFO) self._main_logger.handlers.clear() - - # 文件处理器 + + # File handler file_handler = logging.FileHandler(log_path, encoding='utf-8', mode='w') file_handler.setLevel(logging.INFO) file_handler.setFormatter(logging.Formatter( @@ -154,8 +154,8 @@ class SimulationLogManager: datefmt='%Y-%m-%d %H:%M:%S' )) self._main_logger.addHandler(file_handler) - - # 控制台处理器 + + # Console handler console_handler = logging.StreamHandler() console_handler.setLevel(logging.INFO) console_handler.setFormatter(logging.Formatter( @@ -163,23 +163,23 @@ class SimulationLogManager: datefmt='%H:%M:%S' )) self._main_logger.addHandler(console_handler) - + self._main_logger.propagate = False - + def get_twitter_logger(self) -> PlatformActionLogger: - """获取 Twitter 平台日志记录器""" + """Get the Twitter platform logger""" if self.twitter_logger is None: self.twitter_logger = PlatformActionLogger("twitter", self.simulation_dir) return self.twitter_logger - + def get_reddit_logger(self) -> PlatformActionLogger: - """获取 Reddit 平台日志记录器""" + """Get the Reddit platform logger""" if self.reddit_logger is None: self.reddit_logger = PlatformActionLogger("reddit", self.simulation_dir) return self.reddit_logger - + def log(self, message: str, level: str = "info"): - """记录主日志""" + """Record into the main log""" if self._main_logger: getattr(self._main_logger, level.lower(), self._main_logger.info)(message) @@ -196,12 +196,12 @@ class SimulationLogManager: self.log(message, "debug") -# ============ 兼容旧接口 ============ +# ============ Backward-compat interface ============ class ActionLogger: """ - 动作日志记录器(兼容旧接口) - 建议使用 SimulationLogManager 代替 + Action logger (backward-compat interface) + Prefer using SimulationLogManager instead. """ def __init__(self, log_path: str): @@ -288,12 +288,12 @@ class ActionLogger: f.write(json.dumps(entry, ensure_ascii=False) + '\n') -# 全局日志实例(兼容旧接口) +# Global log instance (backward-compat interface) _global_logger: Optional[ActionLogger] = None def get_logger(log_path: Optional[str] = None) -> ActionLogger: - """获取全局日志实例(兼容旧接口)""" + """Get the global log instance (backward-compat interface)""" global _global_logger if log_path: diff --git a/backend/scripts/run_parallel_simulation.py b/backend/scripts/run_parallel_simulation.py index 2a627ffd..3b342794 100644 --- a/backend/scripts/run_parallel_simulation.py +++ b/backend/scripts/run_parallel_simulation.py @@ -1,62 +1,62 @@ """ -OASIS 双平台并行模拟预设脚本 -同时运行Twitter和Reddit模拟,读取相同的配置文件 +OASIS dual-platform parallel simulation preset script +Run Twitter and Reddit simulations concurrently, reading the same config file -功能特性: -- 双平台(Twitter + Reddit)并行模拟 -- 完成模拟后不立即关闭环境,进入等待命令模式 -- 支持通过IPC接收Interview命令 -- 支持单个Agent采访和批量采访 -- 支持远程关闭环境命令 +Features: +- Dual-platform (Twitter + Reddit) parallel simulation +- After completing the simulation, do not close the environment immediately; enter wait-for-commands mode +- Support receiving Interview commands via IPC +- Support single-Agent and batch interviews +- Support remote environment-close commands -使用方式: +Usage: python run_parallel_simulation.py --config simulation_config.json - python run_parallel_simulation.py --config simulation_config.json --no-wait # 完成后立即关闭 + python run_parallel_simulation.py --config simulation_config.json --no-wait # close immediately after completion python run_parallel_simulation.py --config simulation_config.json --twitter-only python run_parallel_simulation.py --config simulation_config.json --reddit-only -日志结构: +Log structure: sim_xxx/ ├── twitter/ - │ └── actions.jsonl # Twitter 平台动作日志 + │ └── actions.jsonl # Twitter platform action log ├── reddit/ - │ └── actions.jsonl # Reddit 平台动作日志 - ├── simulation.log # 主模拟进程日志 - └── run_state.json # 运行状态(API 查询用) + │ └── actions.jsonl # Reddit platform action log + ├── simulation.log # Main simulation process log + └── run_state.json # Run state (for API queries) """ # ============================================================ -# 解决 Windows 编码问题:在所有 import 之前设置 UTF-8 编码 -# 这是为了修复 OASIS 第三方库读取文件时未指定编码的问题 +# Resolve Windows encoding issue: set UTF-8 encoding before any import +# This fixes the issue where OASIS third-party libraries read files without specifying encoding # ============================================================ import sys import os if sys.platform == 'win32': - # 设置 Python 默认 I/O 编码为 UTF-8 - # 这会影响所有未指定编码的 open() 调用 + # Set Python's default I/O encoding to UTF-8 + # This affects all open() calls that do not specify encoding os.environ.setdefault('PYTHONUTF8', '1') os.environ.setdefault('PYTHONIOENCODING', 'utf-8') - # 重新配置标准输出流为 UTF-8(解决控制台中文乱码) + # Reconfigure stdout streams to UTF-8 (fixes Chinese mojibake in the console) if hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(encoding='utf-8', errors='replace') if hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(encoding='utf-8', errors='replace') - # 强制设置默认编码(影响 open() 函数的默认编码) - # 注意:这需要在 Python 启动时就设置,运行时设置可能不生效 - # 所以我们还需要 monkey-patch 内置的 open 函数 + # Force the default encoding (affects open() default encoding) + # Note: this must be set at Python startup; setting at runtime may not take effect + # So we also need to monkey-patch the built-in open() function import builtins _original_open = builtins.open def _utf8_open(file, mode='r', buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=None): """ - 包装 open() 函数,对于文本模式默认使用 UTF-8 编码 - 这可以修复第三方库(如 OASIS)读取文件时未指定编码的问题 + Wrap the open() function to default to UTF-8 for text mode + This fixes issues where third-party libs (e.g. OASIS) read files without specifying encoding """ - # 只对文本模式(非二进制)且未指定编码的情况设置默认编码 + # Only set the default encoding for text mode (not binary) and when no encoding is specified if encoding is None and 'b' not in mode: encoding = 'utf-8' return _original_open(file, mode, buffering, encoding, errors, @@ -77,52 +77,52 @@ from datetime import datetime from typing import Dict, Any, List, Optional, Tuple -# 全局变量:用于信号处理 +# Global variables: for signal handling _shutdown_event = None _cleanup_done = False -# 添加 backend 目录到路径 -# 脚本固定位于 backend/scripts/ 目录 +# Add backend directory to path +# The script is fixed under the backend/scripts/ directory _scripts_dir = os.path.dirname(os.path.abspath(__file__)) _backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..')) _project_root = os.path.abspath(os.path.join(_backend_dir, '..')) sys.path.insert(0, _scripts_dir) sys.path.insert(0, _backend_dir) -# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置) +# Load project-root .env file (contains LLM_API_KEY etc.) from dotenv import load_dotenv _env_file = os.path.join(_project_root, '.env') if os.path.exists(_env_file): load_dotenv(_env_file) - print(f"已加载环境配置: {_env_file}") + print(f"Loaded environment config: {_env_file}") else: - # 尝试加载 backend/.env + # Try to load backend/.env _backend_env = os.path.join(_backend_dir, '.env') if os.path.exists(_backend_env): load_dotenv(_backend_env) - print(f"已加载环境配置: {_backend_env}") + print(f"Loaded environment config: {_backend_env}") class MaxTokensWarningFilter(logging.Filter): - """过滤掉 camel-ai 关于 max_tokens 的警告(我们故意不设置 max_tokens,让模型自行决定)""" + """Filter out camel-ai warnings about max_tokens (we intentionally do not set max_tokens, letting the model decide)""" def filter(self, record): - # 过滤掉包含 max_tokens 警告的日志 + # Filter out log records containing the max_tokens warning if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage(): return False return True -# 在模块加载时立即添加过滤器,确保在 camel 代码执行前生效 +# Add the filter at module load time, ensuring it takes effect before camel code runs logging.getLogger().addFilter(MaxTokensWarningFilter()) def disable_oasis_logging(): """ - 禁用 OASIS 库的详细日志输出 - OASIS 的日志太冗余(记录每个 agent 的观察和动作),我们使用自己的 action_logger + Disable verbose logging from the OASIS library + OASIS logs are too verbose (records every agent's observations and actions); we use our own action_logger """ - # 禁用 OASIS 的所有日志器 + # Disable all OASIS loggers oasis_loggers = [ "social.agent", "social.twitter", @@ -133,22 +133,22 @@ def disable_oasis_logging(): for logger_name in oasis_loggers: logger = logging.getLogger(logger_name) - logger.setLevel(logging.CRITICAL) # 只记录严重错误 + logger.setLevel(logging.CRITICAL) # Only log critical errors logger.handlers.clear() logger.propagate = False def init_logging_for_simulation(simulation_dir: str): """ - 初始化模拟的日志配置 + Initialize simulation log configuration Args: - simulation_dir: 模拟目录路径 + simulation_dir: Simulation directory path """ - # 禁用 OASIS 的详细日志 + # Disable OASIS verbose logging disable_oasis_logging() - # 清理旧的 log 目录(如果存在) + # Clean up the old log directory (if it exists) old_log_dir = os.path.join(simulation_dir, "log") if os.path.exists(old_log_dir): import shutil @@ -169,12 +169,12 @@ try: generate_reddit_agent_graph ) except ImportError as e: - print(f"错误: 缺少依赖 {e}") - print("请先安装: pip install oasis-ai camel-ai") + print(f"Error: missing dependency {e}") + print("Please install first: pip install oasis-ai camel-ai") sys.exit(1) -# Twitter可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发) +# Twitter available actions (does not include INTERVIEW; INTERVIEW can only be triggered manually via ManualAction) TWITTER_ACTIONS = [ ActionType.CREATE_POST, ActionType.LIKE_POST, @@ -184,7 +184,7 @@ TWITTER_ACTIONS = [ ActionType.QUOTE_POST, ] -# Reddit可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发) +# Reddit available actions (does not include INTERVIEW; INTERVIEW can only be triggered manually via ManualAction) REDDIT_ACTIONS = [ ActionType.LIKE_POST, ActionType.DISLIKE_POST, @@ -202,13 +202,13 @@ REDDIT_ACTIONS = [ ] -# IPC相关常量 +# IPC-related constants IPC_COMMANDS_DIR = "ipc_commands" IPC_RESPONSES_DIR = "ipc_responses" ENV_STATUS_FILE = "env_status.json" class CommandType: - """命令类型常量""" + """Command type constants""" INTERVIEW = "interview" BATCH_INTERVIEW = "batch_interview" CLOSE_ENV = "close_env" @@ -216,9 +216,9 @@ class CommandType: class ParallelIPCHandler: """ - 双平台IPC命令处理器 + Dual-platform IPC command handler - 管理两个平台的环境,处理Interview命令 + Manage environments of both platforms and handle Interview commands """ def __init__( @@ -239,12 +239,12 @@ class ParallelIPCHandler: self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR) self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE) - # 确保目录存在 + # Ensure the directory exists os.makedirs(self.commands_dir, exist_ok=True) os.makedirs(self.responses_dir, exist_ok=True) def update_status(self, status: str): - """更新环境状态""" + """Update environment status""" with open(self.status_file, 'w', encoding='utf-8') as f: json.dump({ "status": status, @@ -254,11 +254,11 @@ class ParallelIPCHandler: }, f, ensure_ascii=False, indent=2) def poll_command(self) -> Optional[Dict[str, Any]]: - """轮询获取待处理命令""" + """Poll for pending commands""" if not os.path.exists(self.commands_dir): return None - # 获取命令文件(按时间排序) + # Get command files (sorted by time) command_files = [] for filename in os.listdir(self.commands_dir): if filename.endswith('.json'): @@ -277,7 +277,7 @@ class ParallelIPCHandler: return None def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None): - """发送响应""" + """Send response""" response = { "command_id": command_id, "status": status, @@ -290,7 +290,7 @@ class ParallelIPCHandler: with open(response_file, 'w', encoding='utf-8') as f: json.dump(response, f, ensure_ascii=False, indent=2) - # 删除命令文件 + # Delete command file command_file = os.path.join(self.commands_dir, f"{command_id}.json") try: os.remove(command_file) @@ -299,13 +299,13 @@ class ParallelIPCHandler: def _get_env_and_graph(self, platform: str): """ - 获取指定平台的环境和agent_graph + Get the environment and agent_graph for the specified platform Args: - platform: 平台名称 ("twitter" 或 "reddit") + platform: Platform name ("twitter" or "reddit") Returns: - (env, agent_graph, platform_name) 或 (None, None, None) + (env, agent_graph, platform_name) or (None, None, None) """ if platform == "twitter" and self.twitter_env: return self.twitter_env, self.twitter_agent_graph, "twitter" @@ -316,15 +316,15 @@ class ParallelIPCHandler: async def _interview_single_platform(self, agent_id: int, prompt: str, platform: str) -> Dict[str, Any]: """ - 在单个平台上执行Interview + Execute an Interview on a single platform Returns: - 包含结果的字典,或包含error的字典 + Dictionary with results, or a dictionary containing an error """ env, agent_graph, actual_platform = self._get_env_and_graph(platform) if not env or not agent_graph: - return {"platform": platform, "error": f"{platform}平台不可用"} + return {"platform": platform, "error": f"{platform} platform unavailable"} try: agent = agent_graph.get_agent(agent_id) @@ -344,36 +344,36 @@ class ParallelIPCHandler: async def handle_interview(self, command_id: str, agent_id: int, prompt: str, platform: str = None) -> bool: """ - 处理单个Agent采访命令 + Handle single-Agent interview command Args: - command_id: 命令ID + command_id: Command ID agent_id: Agent ID - prompt: 采访问题 - platform: 指定平台(可选) - - "twitter": 只采访Twitter平台 - - "reddit": 只采访Reddit平台 - - None/不指定: 同时采访两个平台,返回整合结果 + prompt: Interview question + platform: Specified platform (optional) + - "twitter": only interview the Twitter platform + - "reddit": only interview the Reddit platform + - None/not specified: interview both platforms at once and return merged results Returns: - True 表示成功,False 表示失败 + True on success, False on failure """ - # 如果指定了平台,只采访该平台 + # If a platform is specified, only interview that platform if platform in ("twitter", "reddit"): result = await self._interview_single_platform(agent_id, prompt, platform) if "error" in result: self.send_response(command_id, "failed", error=result["error"]) - print(f" Interview失败: agent_id={agent_id}, platform={platform}, error={result['error']}") + print(f" Interview failed: agent_id={agent_id}, platform={platform}, error={result['error']}") return False else: self.send_response(command_id, "completed", result=result) - print(f" Interview完成: agent_id={agent_id}, platform={platform}") + print(f" Interview completed: agent_id={agent_id}, platform={platform}") return True - # 未指定平台:同时采访两个平台 + # No platform specified: interview both platforms at once if not self.twitter_env and not self.reddit_env: - self.send_response(command_id, "failed", error="没有可用的模拟环境") + self.send_response(command_id, "failed", error="No available simulation environment") return False results = { @@ -383,7 +383,7 @@ class ParallelIPCHandler: } success_count = 0 - # 并行采访两个平台 + # Interview both platforms in parallel tasks = [] platforms_to_interview = [] @@ -395,7 +395,7 @@ class ParallelIPCHandler: tasks.append(self._interview_single_platform(agent_id, prompt, "reddit")) platforms_to_interview.append("reddit") - # 并行执行 + # Execute in parallel platform_results = await asyncio.gather(*tasks) for platform_name, platform_result in zip(platforms_to_interview, platform_results): @@ -405,30 +405,30 @@ class ParallelIPCHandler: if success_count > 0: self.send_response(command_id, "completed", result=results) - print(f" Interview完成: agent_id={agent_id}, 成功平台数={success_count}/{len(platforms_to_interview)}") + print(f" Interview completed: agent_id={agent_id}, successful platforms={success_count}/{len(platforms_to_interview)}") return True else: - errors = [f"{p}: {r.get('error', '未知错误')}" for p, r in results["platforms"].items()] + errors = [f"{p}: {r.get('error', 'Unknown error')}" for p, r in results["platforms"].items()] self.send_response(command_id, "failed", error="; ".join(errors)) - print(f" Interview失败: agent_id={agent_id}, 所有平台都失败") + print(f" Interview failed: agent_id={agent_id}, all platforms failed") return False async def handle_batch_interview(self, command_id: str, interviews: List[Dict], platform: str = None) -> bool: """ - 处理批量采访命令 + Handle batch interview command Args: - command_id: 命令ID + command_id: Command ID interviews: [{"agent_id": int, "prompt": str, "platform": str(optional)}, ...] - platform: 默认平台(可被每个interview项覆盖) - - "twitter": 只采访Twitter平台 - - "reddit": 只采访Reddit平台 - - None/不指定: 每个Agent同时采访两个平台 + platform: Default platform (can be overridden per interview item) + - "twitter": only interview the Twitter platform + - "reddit": only interview the Reddit platform + - None/not specified: each Agent is interviewed on both platforms simultaneously """ - # 按平台分组 + # Group by platform twitter_interviews = [] reddit_interviews = [] - both_platforms_interviews = [] # 需要同时采访两个平台的 + both_platforms_interviews = [] # Those that need to interview both platforms for interview in interviews: item_platform = interview.get("platform", platform) @@ -437,10 +437,10 @@ class ParallelIPCHandler: elif item_platform == "reddit": reddit_interviews.append(interview) else: - # 未指定平台:两个平台都采访 + # Platform not specified: interview on both platforms both_platforms_interviews.append(interview) - # 把 both_platforms_interviews 拆分到两个平台 + # Split both_platforms_interviews into the two platforms if both_platforms_interviews: if self.twitter_env: twitter_interviews.extend(both_platforms_interviews) @@ -449,7 +449,7 @@ class ParallelIPCHandler: results = {} - # 处理Twitter平台的采访 + # Handle Twitter platform interviews if twitter_interviews and self.twitter_env: try: twitter_actions = {} @@ -463,7 +463,7 @@ class ParallelIPCHandler: action_args={"prompt": prompt} ) except Exception as e: - print(f" 警告: 无法获取Twitter Agent {agent_id}: {e}") + print(f" Warning: unable to fetch Twitter Agent {agent_id}: {e}") if twitter_actions: await self.twitter_env.step(twitter_actions) @@ -474,9 +474,9 @@ class ParallelIPCHandler: result["platform"] = "twitter" results[f"twitter_{agent_id}"] = result except Exception as e: - print(f" Twitter批量Interview失败: {e}") + print(f" Twitter batch Interview failed: {e}") - # 处理Reddit平台的采访 + # Handle Reddit platform interviews if reddit_interviews and self.reddit_env: try: reddit_actions = {} @@ -490,7 +490,7 @@ class ParallelIPCHandler: action_args={"prompt": prompt} ) except Exception as e: - print(f" 警告: 无法获取Reddit Agent {agent_id}: {e}") + print(f" Warning: unable to fetch Reddit Agent {agent_id}: {e}") if reddit_actions: await self.reddit_env.step(reddit_actions) @@ -501,21 +501,21 @@ class ParallelIPCHandler: result["platform"] = "reddit" results[f"reddit_{agent_id}"] = result except Exception as e: - print(f" Reddit批量Interview失败: {e}") + print(f" Reddit batch Interview failed: {e}") if results: self.send_response(command_id, "completed", result={ "interviews_count": len(results), "results": results }) - print(f" 批量Interview完成: {len(results)} 个Agent") + print(f" Batch Interview completed: {len(results)} agents") return True else: - self.send_response(command_id, "failed", error="没有成功的采访") + self.send_response(command_id, "failed", error="No successful interviews") return False def _get_interview_result(self, agent_id: int, platform: str) -> Dict[str, Any]: - """从数据库获取最新的Interview结果""" + """Get the latest Interview results from the database""" db_path = os.path.join(self.simulation_dir, f"{platform}_simulation.db") result = { @@ -531,7 +531,7 @@ class ParallelIPCHandler: conn = sqlite3.connect(db_path) cursor = conn.cursor() - # 查询最新的Interview记录 + # Query the latest Interview record cursor.execute(""" SELECT user_id, info, created_at FROM trace @@ -553,16 +553,16 @@ class ParallelIPCHandler: conn.close() except Exception as e: - print(f" 读取Interview结果失败: {e}") + print(f" Failed to read Interview results: {e}") return result async def process_commands(self) -> bool: """ - 处理所有待处理命令 + Handle all pending commands Returns: - True 表示继续运行,False 表示应该退出 + True to continue running, False to exit """ command = self.poll_command() if not command: @@ -572,7 +572,7 @@ class ParallelIPCHandler: command_type = command.get("command_type") args = command.get("args", {}) - print(f"\n收到IPC命令: {command_type}, id={command_id}") + print(f"\nReceived IPC command: {command_type}, id={command_id}") if command_type == CommandType.INTERVIEW: await self.handle_interview( @@ -592,25 +592,25 @@ class ParallelIPCHandler: return True elif command_type == CommandType.CLOSE_ENV: - print("收到关闭环境命令") - self.send_response(command_id, "completed", result={"message": "环境即将关闭"}) + print("Received close-environment command") + self.send_response(command_id, "completed", result={"message": "Environment is about to close"}) return False else: - self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}") + self.send_response(command_id, "failed", error=f"Unknown command type: {command_type}") return True def load_config(config_path: str) -> Dict[str, Any]: - """加载配置文件""" + """Load configuration file""" with open(config_path, 'r', encoding='utf-8') as f: return json.load(f) -# 需要过滤掉的非核心动作类型(这些动作对分析价值较低) +# Non-core action types to filter out (low analytical value) FILTERED_ACTIONS = {'refresh', 'sign_up'} -# 动作类型映射表(数据库中的名称 -> 标准名称) +# Action type mapping table (DB name -> standard name) ACTION_TYPE_MAP = { 'create_post': 'CREATE_POST', 'like_post': 'LIKE_POST', @@ -632,15 +632,15 @@ ACTION_TYPE_MAP = { def get_agent_names_from_config(config: Dict[str, Any]) -> Dict[int, str]: """ - 从 simulation_config 中获取 agent_id -> entity_name 的映射 + Get the agent_id -> entity_name mapping from simulation_config - 这样可以在 actions.jsonl 中显示真实的实体名称,而不是 "Agent_0" 这样的代号 + This lets actions.jsonl show real entity names rather than placeholders like "Agent_0" Args: - config: simulation_config.json 的内容 + config: Contents of simulation_config.json Returns: - agent_id -> entity_name 的映射字典 + Dictionary mapping agent_id -> entity_name """ agent_names = {} agent_configs = config.get("agent_configs", []) @@ -660,17 +660,17 @@ def fetch_new_actions_from_db( agent_names: Dict[int, str] ) -> Tuple[List[Dict[str, Any]], int]: """ - 从数据库中获取新的动作记录,并补充完整的上下文信息 + Fetch new action records from the database and augment them with full context Args: - db_path: 数据库文件路径 - last_rowid: 上次读取的最大 rowid 值(使用 rowid 而不是 created_at,因为不同平台的 created_at 格式不同) - agent_names: agent_id -> agent_name 映射 + db_path: Database file path + last_rowid: Last max rowid read (use rowid instead of created_at, because different platforms have different created_at formats) + agent_names: agent_id -> agent_name mapping Returns: (actions_list, new_last_rowid) - - actions_list: 动作列表,每个元素包含 agent_id, agent_name, action_type, action_args(含上下文信息) - - new_last_rowid: 新的最大 rowid 值 + - actions_list: List of actions; each element contains agent_id, agent_name, action_type, action_args (with context info) + - new_last_rowid: New max rowid value """ actions = [] new_last_rowid = last_rowid @@ -682,8 +682,8 @@ def fetch_new_actions_from_db( conn = sqlite3.connect(db_path) cursor = conn.cursor() - # 使用 rowid 来追踪已处理的记录(rowid 是 SQLite 的内置自增字段) - # 这样可以避免 created_at 格式差异问题(Twitter 用整数,Reddit 用日期时间字符串) + # Use rowid to track processed records (rowid is SQLite's built-in auto-increment field) + # This avoids created_at format differences (Twitter uses an integer, Reddit uses a datetime string) cursor.execute(""" SELECT rowid, user_id, action, info FROM trace @@ -692,20 +692,20 @@ def fetch_new_actions_from_db( """, (last_rowid,)) for rowid, user_id, action, info_json in cursor.fetchall(): - # 更新最大 rowid + # Update max rowid new_last_rowid = rowid - # 过滤非核心动作 + # Filter non-core actions if action in FILTERED_ACTIONS: continue - # 解析动作参数 + # Parse action arguments try: action_args = json.loads(info_json) if info_json else {} except json.JSONDecodeError: action_args = {} - # 精简 action_args,只保留关键字段(保留完整内容,不截断) + # Trim action_args to keep only key fields (keep full content, do not truncate) simplified_args = {} if 'content' in action_args: simplified_args['content'] = action_args['content'] @@ -726,10 +726,10 @@ def fetch_new_actions_from_db( if 'dislike_id' in action_args: simplified_args['dislike_id'] = action_args['dislike_id'] - # 转换动作类型名称 + # Convert action type name action_type = ACTION_TYPE_MAP.get(action, action.upper()) - # 补充上下文信息(帖子内容、用户名等) + # Augment with context info (post content, username, etc.) _enrich_action_context(cursor, action_type, simplified_args, agent_names) actions.append({ @@ -741,7 +741,7 @@ def fetch_new_actions_from_db( conn.close() except Exception as e: - print(f"读取数据库动作失败: {e}") + print(f"Failed to read database actions: {e}") return actions, new_last_rowid @@ -753,16 +753,16 @@ def _enrich_action_context( agent_names: Dict[int, str] ) -> None: """ - 为动作补充上下文信息(帖子内容、用户名等) + Augment actions with context info (post content, username, etc.) Args: - cursor: 数据库游标 - action_type: 动作类型 - action_args: 动作参数(会被修改) - agent_names: agent_id -> agent_name 映射 + cursor: Database cursor + action_type: Action type + action_args: Action arguments (will be modified) + agent_names: agent_id -> agent_name mapping """ try: - # 点赞/踩帖子:补充帖子内容和作者 + # Like/dislike post: augment with post content and author if action_type in ('LIKE_POST', 'DISLIKE_POST'): post_id = action_args.get('post_id') if post_id: @@ -771,11 +771,11 @@ def _enrich_action_context( action_args['post_content'] = post_info.get('content', '') action_args['post_author_name'] = post_info.get('author_name', '') - # 转发帖子:补充原帖内容和作者 + # Repost: augment with original post content and author elif action_type == 'REPOST': new_post_id = action_args.get('new_post_id') if new_post_id: - # 转发帖子的 original_post_id 指向原帖 + # For reposts, original_post_id points to the original post cursor.execute(""" SELECT original_post_id FROM post WHERE post_id = ? """, (new_post_id,)) @@ -787,7 +787,7 @@ def _enrich_action_context( action_args['original_content'] = original_info.get('content', '') action_args['original_author_name'] = original_info.get('author_name', '') - # 引用帖子:补充原帖内容、作者和引用评论 + # Quote post: augment with original post content, author, and quoted comment elif action_type == 'QUOTE_POST': quoted_id = action_args.get('quoted_id') new_post_id = action_args.get('new_post_id') @@ -798,7 +798,7 @@ def _enrich_action_context( action_args['original_content'] = original_info.get('content', '') action_args['original_author_name'] = original_info.get('author_name', '') - # 获取引用帖子的评论内容(quote_content) + # Get the quoted post's comment content (quote_content) if new_post_id: cursor.execute(""" SELECT quote_content FROM post WHERE post_id = ? @@ -807,11 +807,11 @@ def _enrich_action_context( if row and row[0]: action_args['quote_content'] = row[0] - # 关注用户:补充被关注用户的名称 + # Follow user: augment with followed user's name elif action_type == 'FOLLOW': follow_id = action_args.get('follow_id') if follow_id: - # 从 follow 表获取 followee_id + # Get followee_id from the follow table cursor.execute(""" SELECT followee_id FROM follow WHERE follow_id = ? """, (follow_id,)) @@ -822,16 +822,16 @@ def _enrich_action_context( if target_name: action_args['target_user_name'] = target_name - # 屏蔽用户:补充被屏蔽用户的名称 + # Block user: augment with blocked user's name elif action_type == 'MUTE': - # 从 action_args 中获取 user_id 或 target_id + # Get user_id or target_id from action_args target_id = action_args.get('user_id') or action_args.get('target_id') if target_id: target_name = _get_user_name(cursor, target_id, agent_names) if target_name: action_args['target_user_name'] = target_name - # 点赞/踩评论:补充评论内容和作者 + # Like/dislike comment: augment with comment content and author elif action_type in ('LIKE_COMMENT', 'DISLIKE_COMMENT'): comment_id = action_args.get('comment_id') if comment_id: @@ -840,7 +840,7 @@ def _enrich_action_context( action_args['comment_content'] = comment_info.get('content', '') action_args['comment_author_name'] = comment_info.get('author_name', '') - # 发表评论:补充所评论的帖子信息 + # Comment: augment with the commented post info elif action_type == 'CREATE_COMMENT': post_id = action_args.get('post_id') if post_id: @@ -850,8 +850,8 @@ def _enrich_action_context( action_args['post_author_name'] = post_info.get('author_name', '') except Exception as e: - # 补充上下文失败不影响主流程 - print(f"补充动作上下文失败: {e}") + # Context-augmentation failures do not affect the main flow + print(f"Failed to augment action context: {e}") def _get_post_info( @@ -860,15 +860,15 @@ def _get_post_info( agent_names: Dict[int, str] ) -> Optional[Dict[str, str]]: """ - 获取帖子信息 + Get post info Args: - cursor: 数据库游标 - post_id: 帖子ID - agent_names: agent_id -> agent_name 映射 + cursor: Database cursor + post_id: Post ID + agent_names: agent_id -> agent_name mapping Returns: - 包含 content 和 author_name 的字典,或 None + Dictionary containing content and author_name, or None """ try: cursor.execute(""" @@ -883,12 +883,12 @@ def _get_post_info( user_id = row[1] agent_id = row[2] - # 优先使用 agent_names 中的名称 + # Prefer the name from agent_names author_name = '' if agent_id is not None and agent_id in agent_names: author_name = agent_names[agent_id] elif user_id: - # 从 user 表获取名称 + # Get name from the user table cursor.execute("SELECT name, user_name FROM user WHERE user_id = ?", (user_id,)) user_row = cursor.fetchone() if user_row: @@ -906,15 +906,15 @@ def _get_user_name( agent_names: Dict[int, str] ) -> Optional[str]: """ - 获取用户名称 + Get user name Args: - cursor: 数据库游标 - user_id: 用户ID - agent_names: agent_id -> agent_name 映射 + cursor: Database cursor + user_id: User ID + agent_names: agent_id -> agent_name mapping Returns: - 用户名称,或 None + User name, or None """ try: cursor.execute(""" @@ -926,7 +926,7 @@ def _get_user_name( name = row[1] user_name = row[2] - # 优先使用 agent_names 中的名称 + # Prefer the name from agent_names if agent_id is not None and agent_id in agent_names: return agent_names[agent_id] return name or user_name or '' @@ -941,15 +941,15 @@ def _get_comment_info( agent_names: Dict[int, str] ) -> Optional[Dict[str, str]]: """ - 获取评论信息 + Get comment info Args: - cursor: 数据库游标 - comment_id: 评论ID - agent_names: agent_id -> agent_name 映射 + cursor: Database cursor + comment_id: Comment ID + agent_names: agent_id -> agent_name mapping Returns: - 包含 content 和 author_name 的字典,或 None + Dictionary containing content and author_name, or None """ try: cursor.execute(""" @@ -964,12 +964,12 @@ def _get_comment_info( user_id = row[1] agent_id = row[2] - # 优先使用 agent_names 中的名称 + # Prefer the name from agent_names author_name = '' if agent_id is not None and agent_id in agent_names: author_name = agent_names[agent_id] elif user_id: - # 从 user 表获取名称 + # Get name from the user table cursor.execute("SELECT name, user_name FROM user WHERE user_id = ?", (user_id,)) user_row = cursor.fetchone() if user_row: @@ -983,53 +983,53 @@ def _get_comment_info( def create_model(config: Dict[str, Any], use_boost: bool = False): """ - 创建LLM模型 + Create the LLM model - 支持双 LLM 配置,用于并行模拟时提速: - - 通用配置:LLM_API_KEY, LLM_BASE_URL, LLM_MODEL_NAME - - 加速配置(可选):LLM_BOOST_API_KEY, LLM_BOOST_BASE_URL, LLM_BOOST_MODEL_NAME + Supports dual LLM configuration for faster parallel simulation: + - Common config: LLM_API_KEY, LLM_BASE_URL, LLM_MODEL_NAME + - Boost config (optional): LLM_BOOST_API_KEY, LLM_BOOST_BASE_URL, LLM_BOOST_MODEL_NAME - 如果配置了加速 LLM,并行模拟时可以让不同平台使用不同的 API 服务商,提高并发能力。 + If a boost LLM is configured, parallel simulation can use different API providers for different platforms to improve concurrency. Args: - config: 模拟配置字典 - use_boost: 是否使用加速 LLM 配置(如果可用) + config: Simulation config dict + use_boost: Whether to use the boost LLM config (if available) """ - # 检查是否有加速配置 + # Check if boost config is available boost_api_key = os.environ.get("LLM_BOOST_API_KEY", "") boost_base_url = os.environ.get("LLM_BOOST_BASE_URL", "") boost_model = os.environ.get("LLM_BOOST_MODEL_NAME", "") has_boost_config = bool(boost_api_key) - # 根据参数和配置情况选择使用哪个 LLM + # Choose which LLM to use based on parameters and config if use_boost and has_boost_config: - # 使用加速配置 + # Use boost config llm_api_key = boost_api_key llm_base_url = boost_base_url llm_model = boost_model or os.environ.get("LLM_MODEL_NAME", "") - config_label = "[加速LLM]" + config_label = "[Boost LLM]" else: - # 使用通用配置 + # Use common config llm_api_key = os.environ.get("LLM_API_KEY", "") llm_base_url = os.environ.get("LLM_BASE_URL", "") llm_model = os.environ.get("LLM_MODEL_NAME", "") - config_label = "[通用LLM]" + config_label = "[Common LLM]" - # 如果 .env 中没有模型名,则使用 config 作为备用 + # If .env has no model name, fall back to config if not llm_model: llm_model = config.get("llm_model", "gpt-4o-mini") - # 设置 camel-ai 所需的环境变量 + # Set environment variables required by camel-ai if llm_api_key: os.environ["OPENAI_API_KEY"] = llm_api_key if not os.environ.get("OPENAI_API_KEY"): - raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY") + raise ValueError("Missing API Key config, please set LLM_API_KEY in the project root .env file") if llm_base_url: os.environ["OPENAI_API_BASE_URL"] = llm_base_url - print(f"{config_label} model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...") + print(f"{config_label} model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else 'default'}...") return ModelFactory.create( model_platform=ModelPlatformType.OPENAI, @@ -1043,7 +1043,7 @@ def get_active_agents_for_round( current_hour: int, round_num: int ) -> List: - """根据时间和配置决定本轮激活哪些Agent""" + """Determine which Agents to activate this round based on time and config""" time_config = config.get("time_config", {}) agent_configs = config.get("agent_configs", []) @@ -1091,7 +1091,7 @@ def get_active_agents_for_round( class PlatformSimulation: - """平台模拟结果容器""" + """Container for a single platform's simulation result""" def __init__(self): self.env = None self.agent_graph = None @@ -1105,17 +1105,17 @@ async def run_twitter_simulation( main_logger: Optional[SimulationLogManager] = None, max_rounds: Optional[int] = None ) -> PlatformSimulation: - """运行Twitter模拟 + """Run Twitter simulation Args: - config: 模拟配置 - simulation_dir: 模拟目录 - action_logger: 动作日志记录器 - main_logger: 主日志管理器 - max_rounds: 最大模拟轮数(可选,用于截断过长的模拟) + config: Simulation config + simulation_dir: Simulation directory + action_logger: Action logger + main_logger: Main logger + max_rounds: Maximum simulation rounds (optional, used to truncate overlong simulations) Returns: - PlatformSimulation: 包含env和agent_graph的结果对象 + PlatformSimulation: Result object containing env and agent_graph """ result = PlatformSimulation() @@ -1124,15 +1124,15 @@ async def run_twitter_simulation( main_logger.info(f"[Twitter] {msg}") print(f"[Twitter] {msg}") - log_info("初始化...") + log_info("Initializing...") - # Twitter 使用通用 LLM 配置 + # Twitter uses the common LLM config model = create_model(config, use_boost=False) - # OASIS Twitter使用CSV格式 + # OASIS Twitter uses CSV format profile_path = os.path.join(simulation_dir, "twitter_profiles.csv") if not os.path.exists(profile_path): - log_info(f"错误: Profile文件不存在: {profile_path}") + log_info(f"Error: Profile file does not exist: {profile_path}") return result result.agent_graph = await generate_twitter_agent_graph( @@ -1141,9 +1141,9 @@ async def run_twitter_simulation( available_actions=TWITTER_ACTIONS, ) - # 从配置文件获取 Agent 真实名称映射(使用 entity_name 而非默认的 Agent_X) + # Get the real Agent name mapping from the config file (use entity_name instead of the default Agent_X) agent_names = get_agent_names_from_config(config) - # 如果配置中没有某个 agent,则使用 OASIS 的默认名称 + # If an agent is missing from the config, fall back to OASIS's default name for agent_id, agent in result.agent_graph.get_agents(): if agent_id not in agent_names: agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}') @@ -1156,23 +1156,23 @@ async def run_twitter_simulation( agent_graph=result.agent_graph, platform=oasis.DefaultPlatformType.TWITTER, database_path=db_path, - semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载 + semaphore=30, # Limit max concurrent LLM requests to prevent API overload ) await result.env.reset() - log_info("环境已启动") + log_info("Environment started") if action_logger: action_logger.log_simulation_start(config) total_actions = 0 - last_rowid = 0 # 跟踪数据库中最后处理的行号(使用 rowid 避免 created_at 格式差异) + last_rowid = 0 # Track last processed rowid in the database (use rowid to avoid created_at format differences) - # 执行初始事件 + # Execute initial events event_config = config.get("event_config", {}) initial_posts = event_config.get("initial_posts", []) - # 记录 round 0 开始(初始事件阶段) + # Record round 0 start (initial events phase) if action_logger: action_logger.log_round_start(0, 0) # round 0, simulated_hour 0 @@ -1204,32 +1204,32 @@ async def run_twitter_simulation( if initial_actions: await result.env.step(initial_actions) - log_info(f"已发布 {len(initial_actions)} 条初始帖子") + log_info(f"Published {len(initial_actions)} initial posts") - # 记录 round 0 结束 + # Record round 0 end if action_logger: action_logger.log_round_end(0, initial_action_count) - # 主模拟循环 + # Main simulation loop time_config = config.get("time_config", {}) total_hours = time_config.get("total_simulation_hours", 72) minutes_per_round = time_config.get("minutes_per_round", 30) total_rounds = (total_hours * 60) // minutes_per_round - # 如果指定了最大轮数,则截断 + # If max_rounds is specified, truncate if max_rounds is not None and max_rounds > 0: original_rounds = total_rounds total_rounds = min(total_rounds, max_rounds) if total_rounds < original_rounds: - log_info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") + log_info(f"Rounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") start_time = datetime.now() for round_num in range(total_rounds): - # 检查是否收到退出信号 + # Check if an exit signal was received if _shutdown_event and _shutdown_event.is_set(): if main_logger: - main_logger.info(f"收到退出信号,在第 {round_num + 1} 轮停止模拟") + main_logger.info(f"Received exit signal, stopping simulation at round {round_num + 1}") break simulated_minutes = round_num * minutes_per_round @@ -1240,12 +1240,12 @@ async def run_twitter_simulation( result.env, config, simulated_hour, round_num ) - # 无论是否有活跃agent,都记录round开始 + # Record round start regardless of whether there are active agents if action_logger: action_logger.log_round_start(round_num + 1, simulated_hour) if not active_agents: - # 没有活跃agent时也记录round结束(actions_count=0) + # Also record round end when there are no active agents (actions_count=0) if action_logger: action_logger.log_round_end(round_num + 1, 0) continue @@ -1253,7 +1253,7 @@ async def run_twitter_simulation( actions = {agent: LLMAction() for _, agent in active_agents} await result.env.step(actions) - # 从数据库获取实际执行的动作并记录 + # Fetch actually-executed actions from the database and record them actual_actions, last_rowid = fetch_new_actions_from_db( db_path, last_rowid, agent_names ) @@ -1278,14 +1278,14 @@ async def run_twitter_simulation( progress = (round_num + 1) / total_rounds * 100 log_info(f"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)") - # 注意:不关闭环境,保留给Interview使用 + # Note: do not close the environment, keep it for Interview use if action_logger: action_logger.log_simulation_end(total_rounds, total_actions) result.total_actions = total_actions elapsed = (datetime.now() - start_time).total_seconds() - log_info(f"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}") + log_info(f"Simulation loop completed! Elapsed: {elapsed:.1f}s, total actions: {total_actions}") return result @@ -1297,17 +1297,17 @@ async def run_reddit_simulation( main_logger: Optional[SimulationLogManager] = None, max_rounds: Optional[int] = None ) -> PlatformSimulation: - """运行Reddit模拟 + """Run Reddit simulation Args: - config: 模拟配置 - simulation_dir: 模拟目录 - action_logger: 动作日志记录器 - main_logger: 主日志管理器 - max_rounds: 最大模拟轮数(可选,用于截断过长的模拟) + config: Simulation config + simulation_dir: Simulation directory + action_logger: Action logger + main_logger: Main logger + max_rounds: Maximum simulation rounds (optional, used to truncate overlong simulations) Returns: - PlatformSimulation: 包含env和agent_graph的结果对象 + PlatformSimulation: Result object containing env and agent_graph """ result = PlatformSimulation() @@ -1316,14 +1316,14 @@ async def run_reddit_simulation( main_logger.info(f"[Reddit] {msg}") print(f"[Reddit] {msg}") - log_info("初始化...") + log_info("Initializing...") - # Reddit 使用加速 LLM 配置(如果有的话,否则回退到通用配置) + # Reddit uses the boost LLM config (if available, otherwise fall back to common config) model = create_model(config, use_boost=True) profile_path = os.path.join(simulation_dir, "reddit_profiles.json") if not os.path.exists(profile_path): - log_info(f"错误: Profile文件不存在: {profile_path}") + log_info(f"Error: Profile file does not exist: {profile_path}") return result result.agent_graph = await generate_reddit_agent_graph( @@ -1332,9 +1332,9 @@ async def run_reddit_simulation( available_actions=REDDIT_ACTIONS, ) - # 从配置文件获取 Agent 真实名称映射(使用 entity_name 而非默认的 Agent_X) + # Get the real Agent name mapping from the config file (use entity_name instead of the default Agent_X) agent_names = get_agent_names_from_config(config) - # 如果配置中没有某个 agent,则使用 OASIS 的默认名称 + # If an agent is missing from the config, fall back to OASIS's default name for agent_id, agent in result.agent_graph.get_agents(): if agent_id not in agent_names: agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}') @@ -1347,23 +1347,23 @@ async def run_reddit_simulation( agent_graph=result.agent_graph, platform=oasis.DefaultPlatformType.REDDIT, database_path=db_path, - semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载 + semaphore=30, # Limit max concurrent LLM requests to prevent API overload ) await result.env.reset() - log_info("环境已启动") + log_info("Environment started") if action_logger: action_logger.log_simulation_start(config) total_actions = 0 - last_rowid = 0 # 跟踪数据库中最后处理的行号(使用 rowid 避免 created_at 格式差异) + last_rowid = 0 # Track last processed rowid in the database (use rowid to avoid created_at format differences) - # 执行初始事件 + # Execute initial events event_config = config.get("event_config", {}) initial_posts = event_config.get("initial_posts", []) - # 记录 round 0 开始(初始事件阶段) + # Record round 0 start (initial events phase) if action_logger: action_logger.log_round_start(0, 0) # round 0, simulated_hour 0 @@ -1403,32 +1403,32 @@ async def run_reddit_simulation( if initial_actions: await result.env.step(initial_actions) - log_info(f"已发布 {len(initial_actions)} 条初始帖子") + log_info(f"Published {len(initial_actions)} initial posts") - # 记录 round 0 结束 + # Record round 0 end if action_logger: action_logger.log_round_end(0, initial_action_count) - # 主模拟循环 + # Main simulation loop time_config = config.get("time_config", {}) total_hours = time_config.get("total_simulation_hours", 72) minutes_per_round = time_config.get("minutes_per_round", 30) total_rounds = (total_hours * 60) // minutes_per_round - # 如果指定了最大轮数,则截断 + # If max_rounds is specified, truncate if max_rounds is not None and max_rounds > 0: original_rounds = total_rounds total_rounds = min(total_rounds, max_rounds) if total_rounds < original_rounds: - log_info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") + log_info(f"Rounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") start_time = datetime.now() for round_num in range(total_rounds): - # 检查是否收到退出信号 + # Check if an exit signal was received if _shutdown_event and _shutdown_event.is_set(): if main_logger: - main_logger.info(f"收到退出信号,在第 {round_num + 1} 轮停止模拟") + main_logger.info(f"Received exit signal, stopping simulation at round {round_num + 1}") break simulated_minutes = round_num * minutes_per_round @@ -1439,12 +1439,12 @@ async def run_reddit_simulation( result.env, config, simulated_hour, round_num ) - # 无论是否有活跃agent,都记录round开始 + # Record round start regardless of whether there are active agents if action_logger: action_logger.log_round_start(round_num + 1, simulated_hour) if not active_agents: - # 没有活跃agent时也记录round结束(actions_count=0) + # Also record round end when there are no active agents (actions_count=0) if action_logger: action_logger.log_round_end(round_num + 1, 0) continue @@ -1452,7 +1452,7 @@ async def run_reddit_simulation( actions = {agent: LLMAction() for _, agent in active_agents} await result.env.step(actions) - # 从数据库获取实际执行的动作并记录 + # Fetch actually-executed actions from the database and record them actual_actions, last_rowid = fetch_new_actions_from_db( db_path, last_rowid, agent_names ) @@ -1477,76 +1477,76 @@ async def run_reddit_simulation( progress = (round_num + 1) / total_rounds * 100 log_info(f"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)") - # 注意:不关闭环境,保留给Interview使用 + # Note: do not close the environment, keep it for Interview use if action_logger: action_logger.log_simulation_end(total_rounds, total_actions) result.total_actions = total_actions elapsed = (datetime.now() - start_time).total_seconds() - log_info(f"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}") + log_info(f"Simulation loop completed! Elapsed: {elapsed:.1f}s, total actions: {total_actions}") return result async def main(): - parser = argparse.ArgumentParser(description='OASIS双平台并行模拟') + parser = argparse.ArgumentParser(description='OASIS dual-platform parallel simulation') parser.add_argument( '--config', type=str, required=True, - help='配置文件路径 (simulation_config.json)' + help='Configuration file path (simulation_config.json)' ) parser.add_argument( '--twitter-only', action='store_true', - help='只运行Twitter模拟' + help='Run only the Twitter simulation' ) parser.add_argument( '--reddit-only', action='store_true', - help='只运行Reddit模拟' + help='Run only the Reddit simulation' ) parser.add_argument( '--max-rounds', type=int, default=None, - help='最大模拟轮数(可选,用于截断过长的模拟)' + help='Maximum simulation rounds (optional, used to truncate overlong simulations)' ) parser.add_argument( '--no-wait', action='store_true', default=False, - help='模拟完成后立即关闭环境,不进入等待命令模式' + help='Close environment immediately after simulation completes, do not enter wait-for-commands mode' ) args = parser.parse_args() - # 在 main 函数开始时创建 shutdown 事件,确保整个程序都能响应退出信号 + # Create a shutdown event at the start of main() so the entire program can respond to exit signals global _shutdown_event _shutdown_event = asyncio.Event() if not os.path.exists(args.config): - print(f"错误: 配置文件不存在: {args.config}") + print(f"Error: configuration file does not exist: {args.config}") sys.exit(1) config = load_config(args.config) simulation_dir = os.path.dirname(args.config) or "." wait_for_commands = not args.no_wait - # 初始化日志配置(禁用 OASIS 日志,清理旧文件) + # Initialize log config (disable OASIS logs, clean up old files) init_logging_for_simulation(simulation_dir) - # 创建日志管理器 + # Create log manager log_manager = SimulationLogManager(simulation_dir) twitter_logger = log_manager.get_twitter_logger() reddit_logger = log_manager.get_reddit_logger() log_manager.info("=" * 60) - log_manager.info("OASIS 双平台并行模拟") - log_manager.info(f"配置文件: {args.config}") - log_manager.info(f"模拟ID: {config.get('simulation_id', 'unknown')}") - log_manager.info(f"等待命令模式: {'启用' if wait_for_commands else '禁用'}") + log_manager.info("OASIS Dual-Platform Parallel Simulation") + log_manager.info(f"Config file: {args.config}") + log_manager.info(f"Simulation ID: {config.get('simulation_id', 'unknown')}") + log_manager.info(f"Wait-for-commands mode: {'enabled' if wait_for_commands else 'disabled'}") log_manager.info("=" * 60) time_config = config.get("time_config", {}) @@ -1554,25 +1554,25 @@ async def main(): minutes_per_round = time_config.get('minutes_per_round', 30) config_total_rounds = (total_hours * 60) // minutes_per_round - log_manager.info(f"模拟参数:") - log_manager.info(f" - 总模拟时长: {total_hours}小时") - log_manager.info(f" - 每轮时间: {minutes_per_round}分钟") - log_manager.info(f" - 配置总轮数: {config_total_rounds}") + log_manager.info(f"Simulation parameters:") + log_manager.info(f" - Total simulation duration: {total_hours} hours") + log_manager.info(f" - Time per round: {minutes_per_round} minutes") + log_manager.info(f" - Configured total rounds: {config_total_rounds}") if args.max_rounds: - log_manager.info(f" - 最大轮数限制: {args.max_rounds}") + log_manager.info(f" - Max rounds limit: {args.max_rounds}") if args.max_rounds < config_total_rounds: - log_manager.info(f" - 实际执行轮数: {args.max_rounds} (已截断)") - log_manager.info(f" - Agent数量: {len(config.get('agent_configs', []))}") + log_manager.info(f" - Actual executed rounds: {args.max_rounds} (truncated)") + log_manager.info(f" - Number of Agents: {len(config.get('agent_configs', []))}") - log_manager.info("日志结构:") - log_manager.info(f" - 主日志: simulation.log") - log_manager.info(f" - Twitter动作: twitter/actions.jsonl") - log_manager.info(f" - Reddit动作: reddit/actions.jsonl") + log_manager.info("Log structure:") + log_manager.info(f" - Main log: simulation.log") + log_manager.info(f" - Twitter actions: twitter/actions.jsonl") + log_manager.info(f" - Reddit actions: reddit/actions.jsonl") log_manager.info("=" * 60) start_time = datetime.now() - # 存储两个平台的模拟结果 + # Store the simulation results of both platforms twitter_result: Optional[PlatformSimulation] = None reddit_result: Optional[PlatformSimulation] = None @@ -1581,7 +1581,7 @@ async def main(): elif args.reddit_only: reddit_result = await run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds) else: - # 并行运行(每个平台使用独立的日志记录器) + # Run in parallel (each platform uses an independent logger) results = await asyncio.gather( run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds), run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds), @@ -1590,17 +1590,17 @@ async def main(): total_elapsed = (datetime.now() - start_time).total_seconds() log_manager.info("=" * 60) - log_manager.info(f"模拟循环完成! 总耗时: {total_elapsed:.1f}秒") + log_manager.info(f"Simulation loop completed! Total elapsed: {total_elapsed:.1f} seconds") - # 是否进入等待命令模式 + # Whether to enter wait-for-commands mode if wait_for_commands: log_manager.info("") log_manager.info("=" * 60) - log_manager.info("进入等待命令模式 - 环境保持运行") - log_manager.info("支持的命令: interview, batch_interview, close_env") + log_manager.info("Entering wait-for-commands mode - environment keeps running") + log_manager.info("Supported commands: interview, batch_interview, close_env") log_manager.info("=" * 60) - # 创建IPC处理器 + # Create IPC handler ipc_handler = ParallelIPCHandler( simulation_dir=simulation_dir, twitter_env=twitter_result.env if twitter_result else None, @@ -1610,40 +1610,40 @@ async def main(): ) ipc_handler.update_status("alive") - # 等待命令循环(使用全局 _shutdown_event) + # Wait-for-commands loop (using global _shutdown_event) try: while not _shutdown_event.is_set(): should_continue = await ipc_handler.process_commands() if not should_continue: break - # 使用 wait_for 替代 sleep,这样可以响应 shutdown_event + # Use wait_for instead of sleep, so it can respond to shutdown_event try: await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5) - break # 收到退出信号 + break # Received exit signal except asyncio.TimeoutError: - pass # 超时继续循环 + pass # Timeout, continue loop except KeyboardInterrupt: - print("\n收到中断信号") + print("\nReceived interrupt signal") except asyncio.CancelledError: - print("\n任务被取消") + print("\nTask cancelled") except Exception as e: - print(f"\n命令处理出错: {e}") + print(f"\nCommand processing error: {e}") - log_manager.info("\n关闭环境...") + log_manager.info("\nClosing environment...") ipc_handler.update_status("stopped") - # 关闭环境 + # Close the environment if twitter_result and twitter_result.env: await twitter_result.env.close() - log_manager.info("[Twitter] 环境已关闭") + log_manager.info("[Twitter] Environment closed") if reddit_result and reddit_result.env: await reddit_result.env.close() - log_manager.info("[Reddit] 环境已关闭") + log_manager.info("[Reddit] Environment closed") log_manager.info("=" * 60) - log_manager.info(f"全部完成!") - log_manager.info(f"日志文件:") + log_manager.info(f"All done!") + log_manager.info(f"Log files:") log_manager.info(f" - {os.path.join(simulation_dir, 'simulation.log')}") log_manager.info(f" - {os.path.join(simulation_dir, 'twitter', 'actions.jsonl')}") log_manager.info(f" - {os.path.join(simulation_dir, 'reddit', 'actions.jsonl')}") @@ -1652,29 +1652,29 @@ async def main(): def setup_signal_handlers(loop=None): """ - 设置信号处理器,确保收到 SIGTERM/SIGINT 时能够正确退出 + Set up signal handlers to ensure clean exit on SIGTERM/SIGINT - 持久化模拟场景:模拟完成后不退出,等待 interview 命令 - 当收到终止信号时,需要: - 1. 通知 asyncio 循环退出等待 - 2. 让程序有机会正常清理资源(关闭数据库、环境等) - 3. 然后才退出 + Persistent simulation scenario: do not exit after the simulation completes, wait for interview commands + When a termination signal is received, we must: + 1. Notify the asyncio loop to exit the wait + 2. Give the program a chance to clean up resources (close DB, env, etc.) + 3. Then exit """ def signal_handler(signum, frame): global _cleanup_done sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT" - print(f"\n收到 {sig_name} 信号,正在退出...") + print(f"\nReceived {sig_name} signal, exiting...") if not _cleanup_done: _cleanup_done = True - # 设置事件通知 asyncio 循环退出(让循环有机会清理资源) + # Set the event to notify the asyncio loop to exit (let it clean up resources) if _shutdown_event: _shutdown_event.set() - # 不要直接 sys.exit(),让 asyncio 循环正常退出并清理资源 - # 如果是重复收到信号,才强制退出 + # Do not call sys.exit() directly; let the asyncio loop exit normally and clean up + # Only force exit on repeated signal else: - print("强制退出...") + print("Forced exit...") sys.exit(1) signal.signal(signal.SIGTERM, signal_handler) @@ -1686,14 +1686,14 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - print("\n程序被中断") + print("\nProgram interrupted") except SystemExit: pass finally: - # 清理 multiprocessing 资源跟踪器(防止退出时的警告) + # Clean up the multiprocessing resource tracker (prevents warnings on exit) try: from multiprocessing import resource_tracker resource_tracker._resource_tracker._stop() except Exception: pass - print("模拟进程已退出") + print("Simulation process exited") diff --git a/backend/scripts/run_reddit_simulation.py b/backend/scripts/run_reddit_simulation.py index 14907cbd..149eebbe 100644 --- a/backend/scripts/run_reddit_simulation.py +++ b/backend/scripts/run_reddit_simulation.py @@ -1,16 +1,16 @@ """ -OASIS Reddit模拟预设脚本 -此脚本读取配置文件中的参数来执行模拟,实现全程自动化 +OASIS Reddit simulation preset script +This script reads parameters from a config file to run simulations, fully automated end-to-end -功能特性: -- 完成模拟后不立即关闭环境,进入等待命令模式 -- 支持通过IPC接收Interview命令 -- 支持单个Agent采访和批量采访 -- 支持远程关闭环境命令 +Features: +- After completing the simulation, do not close the environment immediately; enter wait-for-commands mode +- Support receiving Interview commands via IPC +- Support single-Agent and batch interviews +- Support remote environment-close commands -使用方式: +Usage: python run_reddit_simulation.py --config /path/to/simulation_config.json - python run_reddit_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭 + python run_reddit_simulation.py --config /path/to/simulation_config.json --no-wait # close immediately after completion """ import argparse @@ -25,18 +25,18 @@ import sqlite3 from datetime import datetime from typing import Dict, Any, List, Optional -# 全局变量:用于信号处理 +# Global variables: for signal handling _shutdown_event = None _cleanup_done = False -# 添加项目路径 +# Add project paths _scripts_dir = os.path.dirname(os.path.abspath(__file__)) _backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..')) _project_root = os.path.abspath(os.path.join(_backend_dir, '..')) sys.path.insert(0, _scripts_dir) sys.path.insert(0, _backend_dir) -# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置) +# Load project-root .env file (contains LLM_API_KEY etc.) from dotenv import load_dotenv _env_file = os.path.join(_project_root, '.env') if os.path.exists(_env_file): @@ -51,7 +51,7 @@ import re class UnicodeFormatter(logging.Formatter): - """自定义格式化器,将 Unicode 转义序列转换为可读字符""" + """Custom formatter that converts Unicode escape sequences into readable characters""" UNICODE_ESCAPE_PATTERN = re.compile(r'\\u([0-9a-fA-F]{4})') @@ -68,24 +68,24 @@ class UnicodeFormatter(logging.Formatter): class MaxTokensWarningFilter(logging.Filter): - """过滤掉 camel-ai 关于 max_tokens 的警告(我们故意不设置 max_tokens,让模型自行决定)""" + """Filter out camel-ai warnings about max_tokens (we intentionally do not set max_tokens, letting the model decide)""" def filter(self, record): - # 过滤掉包含 max_tokens 警告的日志 + # Filter out log records containing the max_tokens warning if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage(): return False return True -# 在模块加载时立即添加过滤器,确保在 camel 代码执行前生效 +# Add the filter at module load time, ensuring it takes effect before camel code runs logging.getLogger().addFilter(MaxTokensWarningFilter()) def setup_oasis_logging(log_dir: str): - """配置 OASIS 的日志,使用固定名称的日志文件""" + """Configure OASIS logging using fixed-name log files""" os.makedirs(log_dir, exist_ok=True) - # 清理旧的日志文件 + # Clean up old log files for f in os.listdir(log_dir): old_log = os.path.join(log_dir, f) if os.path.isfile(old_log) and f.endswith('.log'): @@ -126,25 +126,25 @@ try: generate_reddit_agent_graph ) except ImportError as e: - print(f"错误: 缺少依赖 {e}") - print("请先安装: pip install oasis-ai camel-ai") + print(f"Error: missing dependency {e}") + print("Please install first: pip install oasis-ai camel-ai") sys.exit(1) -# IPC相关常量 +# IPC-related constants IPC_COMMANDS_DIR = "ipc_commands" IPC_RESPONSES_DIR = "ipc_responses" ENV_STATUS_FILE = "env_status.json" class CommandType: - """命令类型常量""" + """Command type constants""" INTERVIEW = "interview" BATCH_INTERVIEW = "batch_interview" CLOSE_ENV = "close_env" class IPCHandler: - """IPC命令处理器""" + """IPC command handler""" def __init__(self, simulation_dir: str, env, agent_graph): self.simulation_dir = simulation_dir @@ -155,12 +155,12 @@ class IPCHandler: self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE) self._running = True - # 确保目录存在 + # Ensure the directory exists os.makedirs(self.commands_dir, exist_ok=True) os.makedirs(self.responses_dir, exist_ok=True) def update_status(self, status: str): - """更新环境状态""" + """Update environment status""" with open(self.status_file, 'w', encoding='utf-8') as f: json.dump({ "status": status, @@ -168,11 +168,11 @@ class IPCHandler: }, f, ensure_ascii=False, indent=2) def poll_command(self) -> Optional[Dict[str, Any]]: - """轮询获取待处理命令""" + """Poll for pending commands""" if not os.path.exists(self.commands_dir): return None - # 获取命令文件(按时间排序) + # Get command files (sorted by time) command_files = [] for filename in os.listdir(self.commands_dir): if filename.endswith('.json'): @@ -191,7 +191,7 @@ class IPCHandler: return None def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None): - """发送响应""" + """Send response""" response = { "command_id": command_id, "status": status, @@ -204,7 +204,7 @@ class IPCHandler: with open(response_file, 'w', encoding='utf-8') as f: json.dump(response, f, ensure_ascii=False, indent=2) - # 删除命令文件 + # Delete command file command_file = os.path.join(self.commands_dir, f"{command_id}.json") try: os.remove(command_file) @@ -213,49 +213,49 @@ class IPCHandler: async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool: """ - 处理单个Agent采访命令 + Handle single-Agent interview command Returns: - True 表示成功,False 表示失败 + True on success, False on failure """ try: - # 获取Agent + # Get Agent agent = self.agent_graph.get_agent(agent_id) - # 创建Interview动作 + # Create Interview action interview_action = ManualAction( action_type=ActionType.INTERVIEW, action_args={"prompt": prompt} ) - # 执行Interview + # Execute Interview actions = {agent: interview_action} await self.env.step(actions) - # 从数据库获取结果 + # Get result from database result = self._get_interview_result(agent_id) self.send_response(command_id, "completed", result=result) - print(f" Interview完成: agent_id={agent_id}") + print(f" Interview completed: agent_id={agent_id}") return True except Exception as e: error_msg = str(e) - print(f" Interview失败: agent_id={agent_id}, error={error_msg}") + print(f" Interview failed: agent_id={agent_id}, error={error_msg}") self.send_response(command_id, "failed", error=error_msg) return False async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool: """ - 处理批量采访命令 + Handle batch interview command Args: interviews: [{"agent_id": int, "prompt": str}, ...] """ try: - # 构建动作字典 + # Build action dict actions = {} - agent_prompts = {} # 记录每个agent的prompt + agent_prompts = {} # Record each agent's prompt for interview in interviews: agent_id = interview.get("agent_id") @@ -269,16 +269,16 @@ class IPCHandler: ) agent_prompts[agent_id] = prompt except Exception as e: - print(f" 警告: 无法获取Agent {agent_id}: {e}") + print(f" Warning: unable to fetch Agent {agent_id}: {e}") if not actions: - self.send_response(command_id, "failed", error="没有有效的Agent") + self.send_response(command_id, "failed", error="No valid agents") return False - # 执行批量Interview + # Execute batch Interview await self.env.step(actions) - # 获取所有结果 + # Get all results results = {} for agent_id in agent_prompts.keys(): result = self._get_interview_result(agent_id) @@ -288,17 +288,17 @@ class IPCHandler: "interviews_count": len(results), "results": results }) - print(f" 批量Interview完成: {len(results)} 个Agent") + print(f" Batch Interview completed: {len(results)} agents") return True except Exception as e: error_msg = str(e) - print(f" 批量Interview失败: {error_msg}") + print(f" Batch Interview failed: {error_msg}") self.send_response(command_id, "failed", error=error_msg) return False def _get_interview_result(self, agent_id: int) -> Dict[str, Any]: - """从数据库获取最新的Interview结果""" + """Get the latest Interview results from the database""" db_path = os.path.join(self.simulation_dir, "reddit_simulation.db") result = { @@ -314,7 +314,7 @@ class IPCHandler: conn = sqlite3.connect(db_path) cursor = conn.cursor() - # 查询最新的Interview记录 + # Query the latest Interview record cursor.execute(""" SELECT user_id, info, created_at FROM trace @@ -336,16 +336,16 @@ class IPCHandler: conn.close() except Exception as e: - print(f" 读取Interview结果失败: {e}") + print(f" Failed to read Interview results: {e}") return result async def process_commands(self) -> bool: """ - 处理所有待处理命令 + Handle all pending commands Returns: - True 表示继续运行,False 表示应该退出 + True to continue running, False to exit """ command = self.poll_command() if not command: @@ -355,7 +355,7 @@ class IPCHandler: command_type = command.get("command_type") args = command.get("args", {}) - print(f"\n收到IPC命令: {command_type}, id={command_id}") + print(f"\nReceived IPC command: {command_type}, id={command_id}") if command_type == CommandType.INTERVIEW: await self.handle_interview( @@ -373,19 +373,19 @@ class IPCHandler: return True elif command_type == CommandType.CLOSE_ENV: - print("收到关闭环境命令") - self.send_response(command_id, "completed", result={"message": "环境即将关闭"}) + print("Received close-environment command") + self.send_response(command_id, "completed", result={"message": "Environment is about to close"}) return False else: - self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}") + self.send_response(command_id, "failed", error=f"Unknown command type: {command_type}") return True class RedditSimulationRunner: - """Reddit模拟运行器""" + """Reddit simulation runner""" - # Reddit可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发) + # Reddit available actions (does not include INTERVIEW; INTERVIEW can only be triggered manually via ManualAction) AVAILABLE_ACTIONS = [ ActionType.LIKE_POST, ActionType.DISLIKE_POST, @@ -404,11 +404,11 @@ class RedditSimulationRunner: def __init__(self, config_path: str, wait_for_commands: bool = True): """ - 初始化模拟运行器 + Initialize the simulation runner Args: - config_path: 配置文件路径 (simulation_config.json) - wait_for_commands: 模拟完成后是否等待命令(默认True) + config_path: Configuration file path (simulation_config.json) + wait_for_commands: Whether to wait for commands after the simulation completes (default True) """ self.config_path = config_path self.config = self._load_config() @@ -419,47 +419,47 @@ class RedditSimulationRunner: self.ipc_handler = None def _load_config(self) -> Dict[str, Any]: - """加载配置文件""" + """Load configuration file""" with open(self.config_path, 'r', encoding='utf-8') as f: return json.load(f) def _get_profile_path(self) -> str: - """获取Profile文件路径""" + """Get Profile file path""" return os.path.join(self.simulation_dir, "reddit_profiles.json") def _get_db_path(self) -> str: - """获取数据库路径""" + """Get database path""" return os.path.join(self.simulation_dir, "reddit_simulation.db") def _create_model(self): """ - 创建LLM模型 + Create the LLM model - 统一使用项目根目录 .env 文件中的配置(优先级最高): - - LLM_API_KEY: API密钥 - - LLM_BASE_URL: API基础URL - - LLM_MODEL_NAME: 模型名称 + Use config from project-root .env file (highest priority): + - LLM_API_KEY: API key + - LLM_BASE_URL: API base URL + - LLM_MODEL_NAME: Model name """ - # 优先从 .env 读取配置 + # Prefer reading config from .env llm_api_key = os.environ.get("LLM_API_KEY", "") llm_base_url = os.environ.get("LLM_BASE_URL", "") llm_model = os.environ.get("LLM_MODEL_NAME", "") - # 如果 .env 中没有,则使用 config 作为备用 + # If .env has no value, fall back to config if not llm_model: llm_model = self.config.get("llm_model", "gpt-4o-mini") - # 设置 camel-ai 所需的环境变量 + # Set environment variables required by camel-ai if llm_api_key: os.environ["OPENAI_API_KEY"] = llm_api_key if not os.environ.get("OPENAI_API_KEY"): - raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY") + raise ValueError("Missing API Key config, please set LLM_API_KEY in the project root .env file") if llm_base_url: os.environ["OPENAI_API_BASE_URL"] = llm_base_url - print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...") + print(f"LLM config: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else 'default'}...") return ModelFactory.create( model_platform=ModelPlatformType.OPENAI, @@ -473,7 +473,7 @@ class RedditSimulationRunner: round_num: int ) -> List: """ - 根据时间和配置决定本轮激活哪些Agent + Determine which Agents to activate this round based on time and config """ time_config = self.config.get("time_config", {}) agent_configs = self.config.get("agent_configs", []) @@ -521,16 +521,16 @@ class RedditSimulationRunner: return active_agents async def run(self, max_rounds: int = None): - """运行Reddit模拟 + """Run Reddit simulation Args: - max_rounds: 最大模拟轮数(可选,用于截断过长的模拟) + max_rounds: Maximum simulation rounds (optional, used to truncate overlong simulations) """ print("=" * 60) - print("OASIS Reddit模拟") - print(f"配置文件: {self.config_path}") - print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}") - print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}") + print("OASIS Reddit Simulation") + print(f"Config file: {self.config_path}") + print(f"Simulation ID: {self.config.get('simulation_id', 'unknown')}") + print(f"Wait-for-commands mode: {'enabled' if self.wait_for_commands else 'disabled'}") print("=" * 60) time_config = self.config.get("time_config", {}) @@ -538,28 +538,28 @@ class RedditSimulationRunner: minutes_per_round = time_config.get("minutes_per_round", 30) total_rounds = (total_hours * 60) // minutes_per_round - # 如果指定了最大轮数,则截断 + # If a max-rounds is specified, truncate if max_rounds is not None and max_rounds > 0: original_rounds = total_rounds total_rounds = min(total_rounds, max_rounds) if total_rounds < original_rounds: - print(f"\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") + print(f"\nRounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") - print(f"\n模拟参数:") - print(f" - 总模拟时长: {total_hours}小时") - print(f" - 每轮时间: {minutes_per_round}分钟") - print(f" - 总轮数: {total_rounds}") + print(f"\nSimulation parameters:") + print(f" - Total simulation duration: {total_hours} hours") + print(f" - Time per round: {minutes_per_round} minutes") + print(f" - Total rounds: {total_rounds}") if max_rounds: - print(f" - 最大轮数限制: {max_rounds}") - print(f" - Agent数量: {len(self.config.get('agent_configs', []))}") + print(f" - Max rounds limit: {max_rounds}") + print(f" - Number of Agents: {len(self.config.get('agent_configs', []))}") - print("\n初始化LLM模型...") + print("\nInitializing LLM model...") model = self._create_model() - print("加载Agent Profile...") + print("Loading Agent Profile...") profile_path = self._get_profile_path() if not os.path.exists(profile_path): - print(f"错误: Profile文件不存在: {profile_path}") + print(f"Error: Profile file does not exist: {profile_path}") return self.agent_graph = await generate_reddit_agent_graph( @@ -571,29 +571,29 @@ class RedditSimulationRunner: db_path = self._get_db_path() if os.path.exists(db_path): os.remove(db_path) - print(f"已删除旧数据库: {db_path}") + print(f"Deleted old database: {db_path}") - print("创建OASIS环境...") + print("Creating OASIS environment...") self.env = oasis.make( agent_graph=self.agent_graph, platform=oasis.DefaultPlatformType.REDDIT, database_path=db_path, - semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载 + semaphore=30, # Limit max concurrent LLM requests to prevent API overload ) await self.env.reset() - print("环境初始化完成\n") + print("Environment initialization complete\n") - # 初始化IPC处理器 + # Initialize IPC handler self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph) self.ipc_handler.update_status("running") - # 执行初始事件 + # Execute initial events event_config = self.config.get("event_config", {}) initial_posts = event_config.get("initial_posts", []) if initial_posts: - print(f"执行初始事件 ({len(initial_posts)}条初始帖子)...") + print(f"Executing initial events ({len(initial_posts)} initial posts)...") initial_actions = {} for post in initial_posts: agent_id = post.get("poster_agent_id", 0) @@ -613,14 +613,14 @@ class RedditSimulationRunner: action_args={"content": content} ) except Exception as e: - print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}") + print(f" Warning: unable to create initial post for Agent {agent_id}: {e}") if initial_actions: await self.env.step(initial_actions) - print(f" 已发布 {len(initial_actions)} 条初始帖子") + print(f" Published {len(initial_actions)} initial posts") - # 主模拟循环 - print("\n开始模拟循环...") + # Main simulation loop + print("\nStarting simulation loop...") start_time = datetime.now() for round_num in range(total_rounds): @@ -651,20 +651,20 @@ class RedditSimulationRunner: f"- elapsed: {elapsed:.1f}s") total_elapsed = (datetime.now() - start_time).total_seconds() - print(f"\n模拟循环完成!") - print(f" - 总耗时: {total_elapsed:.1f}秒") - print(f" - 数据库: {db_path}") + print(f"\nSimulation loop completed!") + print(f" - Total elapsed: {total_elapsed:.1f} seconds") + print(f" - Database: {db_path}") - # 是否进入等待命令模式 + # Whether to enter wait-for-commands mode if self.wait_for_commands: print("\n" + "=" * 60) - print("进入等待命令模式 - 环境保持运行") - print("支持的命令: interview, batch_interview, close_env") + print("Entering wait-for-commands mode - environment keeps running") + print("Supported commands: interview, batch_interview, close_env") print("=" * 60) self.ipc_handler.update_status("alive") - # 等待命令循环(使用全局 _shutdown_event) + # Wait-for-commands loop (using global _shutdown_event) try: while not _shutdown_event.is_set(): should_continue = await self.ipc_handler.process_commands() @@ -672,58 +672,58 @@ class RedditSimulationRunner: break try: await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5) - break # 收到退出信号 + break # Received exit signal except asyncio.TimeoutError: pass except KeyboardInterrupt: - print("\n收到中断信号") + print("\nReceived interrupt signal") except asyncio.CancelledError: - print("\n任务被取消") + print("\nTask cancelled") except Exception as e: - print(f"\n命令处理出错: {e}") + print(f"\nCommand processing error: {e}") - print("\n关闭环境...") + print("\nClosing environment...") - # 关闭环境 + # Close the environment self.ipc_handler.update_status("stopped") await self.env.close() - print("环境已关闭") + print("Environment closed") print("=" * 60) async def main(): - parser = argparse.ArgumentParser(description='OASIS Reddit模拟') + parser = argparse.ArgumentParser(description='OASIS Reddit Simulation') parser.add_argument( '--config', type=str, required=True, - help='配置文件路径 (simulation_config.json)' + help='Configuration file path (simulation_config.json)' ) parser.add_argument( '--max-rounds', type=int, default=None, - help='最大模拟轮数(可选,用于截断过长的模拟)' + help='Maximum simulation rounds (optional, used to truncate overlong simulations)' ) parser.add_argument( '--no-wait', action='store_true', default=False, - help='模拟完成后立即关闭环境,不进入等待命令模式' + help='Close environment immediately after simulation completes, do not enter wait-for-commands mode' ) args = parser.parse_args() - # 在 main 函数开始时创建 shutdown 事件 + # Create shutdown event at the start of main() global _shutdown_event _shutdown_event = asyncio.Event() if not os.path.exists(args.config): - print(f"错误: 配置文件不存在: {args.config}") + print(f"Error: configuration file does not exist: {args.config}") sys.exit(1) - # 初始化日志配置(使用固定文件名,清理旧日志) + # Initialize log config (use fixed log file names, clean up old logs) simulation_dir = os.path.dirname(args.config) or "." setup_oasis_logging(os.path.join(simulation_dir, "log")) @@ -736,20 +736,20 @@ async def main(): def setup_signal_handlers(): """ - 设置信号处理器,确保收到 SIGTERM/SIGINT 时能够正确退出 - 让程序有机会正常清理资源(关闭数据库、环境等) + Set up signal handlers to ensure clean exit on SIGTERM/SIGINT + Give the program a chance to clean up resources (close DB, env, etc.) """ def signal_handler(signum, frame): global _cleanup_done sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT" - print(f"\n收到 {sig_name} 信号,正在退出...") + print(f"\nReceived {sig_name} signal, exiting...") if not _cleanup_done: _cleanup_done = True if _shutdown_event: _shutdown_event.set() else: - # 重复收到信号才强制退出 - print("强制退出...") + # Only force exit if the signal is received again + print("Forced exit...") sys.exit(1) signal.signal(signal.SIGTERM, signal_handler) @@ -761,9 +761,9 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - print("\n程序被中断") + print("\nProgram interrupted") except SystemExit: pass finally: - print("模拟进程已退出") + print("Simulation process exited") diff --git a/backend/scripts/run_twitter_simulation.py b/backend/scripts/run_twitter_simulation.py index caab9e9d..9999b2ce 100644 --- a/backend/scripts/run_twitter_simulation.py +++ b/backend/scripts/run_twitter_simulation.py @@ -1,16 +1,16 @@ """ -OASIS Twitter模拟预设脚本 -此脚本读取配置文件中的参数来执行模拟,实现全程自动化 +OASIS Twitter simulation preset script +This script reads parameters from a config file to run simulations, fully automated end-to-end -功能特性: -- 完成模拟后不立即关闭环境,进入等待命令模式 -- 支持通过IPC接收Interview命令 -- 支持单个Agent采访和批量采访 -- 支持远程关闭环境命令 +Features: +- After completing the simulation, do not close the environment immediately; enter wait-for-commands mode +- Support receiving Interview commands via IPC +- Support single-Agent and batch interviews +- Support remote environment-close commands -使用方式: +Usage: python run_twitter_simulation.py --config /path/to/simulation_config.json - python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭 + python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait # close immediately after completion """ import argparse @@ -25,18 +25,18 @@ import sqlite3 from datetime import datetime from typing import Dict, Any, List, Optional -# 全局变量:用于信号处理 +# Global variables: for signal handling _shutdown_event = None _cleanup_done = False -# 添加项目路径 +# Add project paths _scripts_dir = os.path.dirname(os.path.abspath(__file__)) _backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..')) _project_root = os.path.abspath(os.path.join(_backend_dir, '..')) sys.path.insert(0, _scripts_dir) sys.path.insert(0, _backend_dir) -# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置) +# Load project-root .env file (contains LLM_API_KEY etc.) from dotenv import load_dotenv _env_file = os.path.join(_project_root, '.env') if os.path.exists(_env_file): @@ -51,7 +51,7 @@ import re class UnicodeFormatter(logging.Formatter): - """自定义格式化器,将 Unicode 转义序列转换为可读字符""" + """Custom formatter that converts Unicode escape sequences into readable characters""" UNICODE_ESCAPE_PATTERN = re.compile(r'\\u([0-9a-fA-F]{4})') @@ -68,24 +68,24 @@ class UnicodeFormatter(logging.Formatter): class MaxTokensWarningFilter(logging.Filter): - """过滤掉 camel-ai 关于 max_tokens 的警告(我们故意不设置 max_tokens,让模型自行决定)""" + """Filter out camel-ai warnings about max_tokens (we intentionally do not set max_tokens, letting the model decide)""" def filter(self, record): - # 过滤掉包含 max_tokens 警告的日志 + # Filter out log records containing the max_tokens warning if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage(): return False return True -# 在模块加载时立即添加过滤器,确保在 camel 代码执行前生效 +# Add the filter at module load time, ensuring it takes effect before camel code runs logging.getLogger().addFilter(MaxTokensWarningFilter()) def setup_oasis_logging(log_dir: str): - """配置 OASIS 的日志,使用固定名称的日志文件""" + """Configure OASIS logging using fixed-name log files""" os.makedirs(log_dir, exist_ok=True) - # 清理旧的日志文件 + # Clean up old log files for f in os.listdir(log_dir): old_log = os.path.join(log_dir, f) if os.path.isfile(old_log) and f.endswith('.log'): @@ -126,25 +126,25 @@ try: generate_twitter_agent_graph ) except ImportError as e: - print(f"错误: 缺少依赖 {e}") - print("请先安装: pip install oasis-ai camel-ai") + print(f"Error: missing dependency {e}") + print("Please install first: pip install oasis-ai camel-ai") sys.exit(1) -# IPC相关常量 +# IPC-related constants IPC_COMMANDS_DIR = "ipc_commands" IPC_RESPONSES_DIR = "ipc_responses" ENV_STATUS_FILE = "env_status.json" class CommandType: - """命令类型常量""" + """Command type constants""" INTERVIEW = "interview" BATCH_INTERVIEW = "batch_interview" CLOSE_ENV = "close_env" class IPCHandler: - """IPC命令处理器""" + """IPC command handler""" def __init__(self, simulation_dir: str, env, agent_graph): self.simulation_dir = simulation_dir @@ -155,12 +155,12 @@ class IPCHandler: self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE) self._running = True - # 确保目录存在 + # Ensure the directory exists os.makedirs(self.commands_dir, exist_ok=True) os.makedirs(self.responses_dir, exist_ok=True) def update_status(self, status: str): - """更新环境状态""" + """Update environment status""" with open(self.status_file, 'w', encoding='utf-8') as f: json.dump({ "status": status, @@ -168,11 +168,11 @@ class IPCHandler: }, f, ensure_ascii=False, indent=2) def poll_command(self) -> Optional[Dict[str, Any]]: - """轮询获取待处理命令""" + """Poll for pending commands""" if not os.path.exists(self.commands_dir): return None - # 获取命令文件(按时间排序) + # Get command files (sorted by time) command_files = [] for filename in os.listdir(self.commands_dir): if filename.endswith('.json'): @@ -191,7 +191,7 @@ class IPCHandler: return None def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None): - """发送响应""" + """Send response""" response = { "command_id": command_id, "status": status, @@ -204,7 +204,7 @@ class IPCHandler: with open(response_file, 'w', encoding='utf-8') as f: json.dump(response, f, ensure_ascii=False, indent=2) - # 删除命令文件 + # Delete command file command_file = os.path.join(self.commands_dir, f"{command_id}.json") try: os.remove(command_file) @@ -213,49 +213,49 @@ class IPCHandler: async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool: """ - 处理单个Agent采访命令 + Handle single-Agent interview command Returns: - True 表示成功,False 表示失败 + True on success, False on failure """ try: - # 获取Agent + # Get Agent agent = self.agent_graph.get_agent(agent_id) - # 创建Interview动作 + # Create Interview action interview_action = ManualAction( action_type=ActionType.INTERVIEW, action_args={"prompt": prompt} ) - # 执行Interview + # Execute Interview actions = {agent: interview_action} await self.env.step(actions) - # 从数据库获取结果 + # Get result from database result = self._get_interview_result(agent_id) self.send_response(command_id, "completed", result=result) - print(f" Interview完成: agent_id={agent_id}") + print(f" Interview completed: agent_id={agent_id}") return True except Exception as e: error_msg = str(e) - print(f" Interview失败: agent_id={agent_id}, error={error_msg}") + print(f" Interview failed: agent_id={agent_id}, error={error_msg}") self.send_response(command_id, "failed", error=error_msg) return False async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool: """ - 处理批量采访命令 + Handle batch interview command Args: interviews: [{"agent_id": int, "prompt": str}, ...] """ try: - # 构建动作字典 + # Build action dict actions = {} - agent_prompts = {} # 记录每个agent的prompt + agent_prompts = {} # Record each agent's prompt for interview in interviews: agent_id = interview.get("agent_id") @@ -269,16 +269,16 @@ class IPCHandler: ) agent_prompts[agent_id] = prompt except Exception as e: - print(f" 警告: 无法获取Agent {agent_id}: {e}") + print(f" Warning: unable to fetch Agent {agent_id}: {e}") if not actions: - self.send_response(command_id, "failed", error="没有有效的Agent") + self.send_response(command_id, "failed", error="No valid agents") return False - # 执行批量Interview + # Execute batch Interview await self.env.step(actions) - # 获取所有结果 + # Get all results results = {} for agent_id in agent_prompts.keys(): result = self._get_interview_result(agent_id) @@ -288,17 +288,17 @@ class IPCHandler: "interviews_count": len(results), "results": results }) - print(f" 批量Interview完成: {len(results)} 个Agent") + print(f" Batch Interview completed: {len(results)} agents") return True except Exception as e: error_msg = str(e) - print(f" 批量Interview失败: {error_msg}") + print(f" Batch Interview failed: {error_msg}") self.send_response(command_id, "failed", error=error_msg) return False def _get_interview_result(self, agent_id: int) -> Dict[str, Any]: - """从数据库获取最新的Interview结果""" + """Get the latest Interview results from the database""" db_path = os.path.join(self.simulation_dir, "twitter_simulation.db") result = { @@ -314,7 +314,7 @@ class IPCHandler: conn = sqlite3.connect(db_path) cursor = conn.cursor() - # 查询最新的Interview记录 + # Query the latest Interview record cursor.execute(""" SELECT user_id, info, created_at FROM trace @@ -336,16 +336,16 @@ class IPCHandler: conn.close() except Exception as e: - print(f" 读取Interview结果失败: {e}") + print(f" Failed to read Interview results: {e}") return result async def process_commands(self) -> bool: """ - 处理所有待处理命令 + Handle all pending commands Returns: - True 表示继续运行,False 表示应该退出 + True to continue running, False to exit """ command = self.poll_command() if not command: @@ -355,7 +355,7 @@ class IPCHandler: command_type = command.get("command_type") args = command.get("args", {}) - print(f"\n收到IPC命令: {command_type}, id={command_id}") + print(f"\nReceived IPC command: {command_type}, id={command_id}") if command_type == CommandType.INTERVIEW: await self.handle_interview( @@ -373,19 +373,19 @@ class IPCHandler: return True elif command_type == CommandType.CLOSE_ENV: - print("收到关闭环境命令") - self.send_response(command_id, "completed", result={"message": "环境即将关闭"}) + print("Received close-environment command") + self.send_response(command_id, "completed", result={"message": "Environment is about to close"}) return False else: - self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}") + self.send_response(command_id, "failed", error=f"Unknown command type: {command_type}") return True class TwitterSimulationRunner: - """Twitter模拟运行器""" + """Twitter simulation runner""" - # Twitter可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发) + # Twitter available actions (does not include INTERVIEW; INTERVIEW can only be triggered manually via ManualAction) AVAILABLE_ACTIONS = [ ActionType.CREATE_POST, ActionType.LIKE_POST, @@ -397,11 +397,11 @@ class TwitterSimulationRunner: def __init__(self, config_path: str, wait_for_commands: bool = True): """ - 初始化模拟运行器 + Initialize the simulation runner Args: - config_path: 配置文件路径 (simulation_config.json) - wait_for_commands: 模拟完成后是否等待命令(默认True) + config_path: Configuration file path (simulation_config.json) + wait_for_commands: Whether to wait for commands after the simulation completes (default True) """ self.config_path = config_path self.config = self._load_config() @@ -412,47 +412,47 @@ class TwitterSimulationRunner: self.ipc_handler = None def _load_config(self) -> Dict[str, Any]: - """加载配置文件""" + """Load configuration file""" with open(self.config_path, 'r', encoding='utf-8') as f: return json.load(f) def _get_profile_path(self) -> str: - """获取Profile文件路径(OASIS Twitter使用CSV格式)""" + """Get Profile file path (OASIS Twitter uses CSV format)""" return os.path.join(self.simulation_dir, "twitter_profiles.csv") def _get_db_path(self) -> str: - """获取数据库路径""" + """Get database path""" return os.path.join(self.simulation_dir, "twitter_simulation.db") def _create_model(self): """ - 创建LLM模型 + Create the LLM model - 统一使用项目根目录 .env 文件中的配置(优先级最高): - - LLM_API_KEY: API密钥 - - LLM_BASE_URL: API基础URL - - LLM_MODEL_NAME: 模型名称 + Use config from project-root .env file (highest priority): + - LLM_API_KEY: API key + - LLM_BASE_URL: API base URL + - LLM_MODEL_NAME: Model name """ - # 优先从 .env 读取配置 + # Prefer reading config from .env llm_api_key = os.environ.get("LLM_API_KEY", "") llm_base_url = os.environ.get("LLM_BASE_URL", "") llm_model = os.environ.get("LLM_MODEL_NAME", "") - # 如果 .env 中没有,则使用 config 作为备用 + # If .env has no value, fall back to config if not llm_model: llm_model = self.config.get("llm_model", "gpt-4o-mini") - # 设置 camel-ai 所需的环境变量 + # Set environment variables required by camel-ai if llm_api_key: os.environ["OPENAI_API_KEY"] = llm_api_key if not os.environ.get("OPENAI_API_KEY"): - raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY") + raise ValueError("Missing API Key config, please set LLM_API_KEY in the project root .env file") if llm_base_url: os.environ["OPENAI_API_BASE_URL"] = llm_base_url - print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...") + print(f"LLM config: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else 'default'}...") return ModelFactory.create( model_platform=ModelPlatformType.OPENAI, @@ -466,24 +466,24 @@ class TwitterSimulationRunner: round_num: int ) -> List: """ - 根据时间和配置决定本轮激活哪些Agent + Determine which Agents to activate this round based on time and config Args: - env: OASIS环境 - current_hour: 当前模拟小时(0-23) - round_num: 当前轮数 + env: OASIS environment + current_hour: Current simulation hour (0-23) + round_num: Current round number Returns: - 激活的Agent列表 + List of activated Agents """ time_config = self.config.get("time_config", {}) agent_configs = self.config.get("agent_configs", []) - # 基础激活数量 + # Base activation count base_min = time_config.get("agents_per_hour_min", 5) base_max = time_config.get("agents_per_hour_max", 20) - # 根据时段调整 + # Adjust by time period peak_hours = time_config.get("peak_hours", [9, 10, 11, 14, 15, 20, 21, 22]) off_peak_hours = time_config.get("off_peak_hours", [0, 1, 2, 3, 4, 5]) @@ -496,28 +496,28 @@ class TwitterSimulationRunner: target_count = int(random.uniform(base_min, base_max) * multiplier) - # 根据每个Agent的配置计算激活概率 + # Compute activation probability from each Agent's config candidates = [] for cfg in agent_configs: agent_id = cfg.get("agent_id", 0) active_hours = cfg.get("active_hours", list(range(8, 23))) activity_level = cfg.get("activity_level", 0.5) - # 检查是否在活跃时间 + # Check if within active hours if current_hour not in active_hours: continue - # 根据活跃度计算概率 + # Compute probability from activity level if random.random() < activity_level: candidates.append(agent_id) - # 随机选择 + # Random selection selected_ids = random.sample( candidates, min(target_count, len(candidates)) ) if candidates else [] - # 转换为Agent对象 + # Convert to Agent objects active_agents = [] for agent_id in selected_ids: try: @@ -529,50 +529,50 @@ class TwitterSimulationRunner: return active_agents async def run(self, max_rounds: int = None): - """运行Twitter模拟 + """Run Twitter simulation Args: - max_rounds: 最大模拟轮数(可选,用于截断过长的模拟) + max_rounds: Maximum simulation rounds (optional, used to truncate overlong simulations) """ print("=" * 60) - print("OASIS Twitter模拟") - print(f"配置文件: {self.config_path}") - print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}") - print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}") + print("OASIS Twitter Simulation") + print(f"Config file: {self.config_path}") + print(f"Simulation ID: {self.config.get('simulation_id', 'unknown')}") + print(f"Wait-for-commands mode: {'enabled' if self.wait_for_commands else 'disabled'}") print("=" * 60) - # 加载时间配置 + # Load time config time_config = self.config.get("time_config", {}) total_hours = time_config.get("total_simulation_hours", 72) minutes_per_round = time_config.get("minutes_per_round", 30) - # 计算总轮数 + # Compute total rounds total_rounds = (total_hours * 60) // minutes_per_round - # 如果指定了最大轮数,则截断 + # If a max-rounds is specified, truncate if max_rounds is not None and max_rounds > 0: original_rounds = total_rounds total_rounds = min(total_rounds, max_rounds) if total_rounds < original_rounds: - print(f"\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") + print(f"\nRounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") - print(f"\n模拟参数:") - print(f" - 总模拟时长: {total_hours}小时") - print(f" - 每轮时间: {minutes_per_round}分钟") - print(f" - 总轮数: {total_rounds}") + print(f"\nSimulation parameters:") + print(f" - Total simulation duration: {total_hours} hours") + print(f" - Time per round: {minutes_per_round} minutes") + print(f" - Total rounds: {total_rounds}") if max_rounds: - print(f" - 最大轮数限制: {max_rounds}") - print(f" - Agent数量: {len(self.config.get('agent_configs', []))}") + print(f" - Max rounds limit: {max_rounds}") + print(f" - Number of Agents: {len(self.config.get('agent_configs', []))}") - # 创建模型 - print("\n初始化LLM模型...") + # Create model + print("\nInitializing LLM model...") model = self._create_model() - # 加载Agent图 - print("加载Agent Profile...") + # Load Agent graph + print("Loading Agent Profile...") profile_path = self._get_profile_path() if not os.path.exists(profile_path): - print(f"错误: Profile文件不存在: {profile_path}") + print(f"Error: Profile file does not exist: {profile_path}") return self.agent_graph = await generate_twitter_agent_graph( @@ -581,34 +581,34 @@ class TwitterSimulationRunner: available_actions=self.AVAILABLE_ACTIONS, ) - # 数据库路径 + # Database path db_path = self._get_db_path() if os.path.exists(db_path): os.remove(db_path) - print(f"已删除旧数据库: {db_path}") + print(f"Deleted old database: {db_path}") - # 创建环境 - print("创建OASIS环境...") + # Create environment + print("Creating OASIS environment...") self.env = oasis.make( agent_graph=self.agent_graph, platform=oasis.DefaultPlatformType.TWITTER, database_path=db_path, - semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载 + semaphore=30, # Limit max concurrent LLM requests to prevent API overload ) await self.env.reset() - print("环境初始化完成\n") + print("Environment initialization complete\n") - # 初始化IPC处理器 + # Initialize IPC handler self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph) self.ipc_handler.update_status("running") - # 执行初始事件 + # Execute initial events event_config = self.config.get("event_config", {}) initial_posts = event_config.get("initial_posts", []) if initial_posts: - print(f"执行初始事件 ({len(initial_posts)}条初始帖子)...") + print(f"Executing initial events ({len(initial_posts)} initial posts)...") initial_actions = {} for post in initial_posts: agent_id = post.get("poster_agent_id", 0) @@ -620,23 +620,23 @@ class TwitterSimulationRunner: action_args={"content": content} ) except Exception as e: - print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}") + print(f" Warning: unable to create initial post for Agent {agent_id}: {e}") if initial_actions: await self.env.step(initial_actions) - print(f" 已发布 {len(initial_actions)} 条初始帖子") + print(f" Published {len(initial_actions)} initial posts") - # 主模拟循环 - print("\n开始模拟循环...") + # Main simulation loop + print("\nStarting simulation loop...") start_time = datetime.now() for round_num in range(total_rounds): - # 计算当前模拟时间 + # Compute current simulation time simulated_minutes = round_num * minutes_per_round simulated_hour = (simulated_minutes // 60) % 24 simulated_day = simulated_minutes // (60 * 24) + 1 - # 获取本轮激活的Agent + # Get activated Agents for this round active_agents = self._get_active_agents_for_round( self.env, simulated_hour, round_num ) @@ -644,16 +644,16 @@ class TwitterSimulationRunner: if not active_agents: continue - # 构建动作 + # Build actions actions = { agent: LLMAction() for _, agent in active_agents } - # 执行动作 + # Execute actions await self.env.step(actions) - # 打印进度 + # Print progress if (round_num + 1) % 10 == 0 or round_num == 0: elapsed = (datetime.now() - start_time).total_seconds() progress = (round_num + 1) / total_rounds * 100 @@ -663,20 +663,20 @@ class TwitterSimulationRunner: f"- elapsed: {elapsed:.1f}s") total_elapsed = (datetime.now() - start_time).total_seconds() - print(f"\n模拟循环完成!") - print(f" - 总耗时: {total_elapsed:.1f}秒") - print(f" - 数据库: {db_path}") + print(f"\nSimulation loop completed!") + print(f" - Total elapsed: {total_elapsed:.1f} seconds") + print(f" - Database: {db_path}") - # 是否进入等待命令模式 + # Whether to enter wait-for-commands mode if self.wait_for_commands: print("\n" + "=" * 60) - print("进入等待命令模式 - 环境保持运行") - print("支持的命令: interview, batch_interview, close_env") + print("Entering wait-for-commands mode - environment keeps running") + print("Supported commands: interview, batch_interview, close_env") print("=" * 60) self.ipc_handler.update_status("alive") - # 等待命令循环(使用全局 _shutdown_event) + # Wait-for-commands loop (using global _shutdown_event) try: while not _shutdown_event.is_set(): should_continue = await self.ipc_handler.process_commands() @@ -684,58 +684,58 @@ class TwitterSimulationRunner: break try: await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5) - break # 收到退出信号 + break # Received exit signal except asyncio.TimeoutError: pass except KeyboardInterrupt: - print("\n收到中断信号") + print("\nReceived interrupt signal") except asyncio.CancelledError: - print("\n任务被取消") + print("\nTask cancelled") except Exception as e: - print(f"\n命令处理出错: {e}") + print(f"\nCommand processing error: {e}") - print("\n关闭环境...") + print("\nClosing environment...") - # 关闭环境 + # Close the environment self.ipc_handler.update_status("stopped") await self.env.close() - print("环境已关闭") + print("Environment closed") print("=" * 60) async def main(): - parser = argparse.ArgumentParser(description='OASIS Twitter模拟') + parser = argparse.ArgumentParser(description='OASIS Twitter Simulation') parser.add_argument( '--config', type=str, required=True, - help='配置文件路径 (simulation_config.json)' + help='Configuration file path (simulation_config.json)' ) parser.add_argument( '--max-rounds', type=int, default=None, - help='最大模拟轮数(可选,用于截断过长的模拟)' + help='Maximum simulation rounds (optional, used to truncate overlong simulations)' ) parser.add_argument( '--no-wait', action='store_true', default=False, - help='模拟完成后立即关闭环境,不进入等待命令模式' + help='Close environment immediately after simulation completes, do not enter wait-for-commands mode' ) args = parser.parse_args() - # 在 main 函数开始时创建 shutdown 事件 + # Create shutdown event at the start of main() global _shutdown_event _shutdown_event = asyncio.Event() if not os.path.exists(args.config): - print(f"错误: 配置文件不存在: {args.config}") + print(f"Error: configuration file does not exist: {args.config}") sys.exit(1) - # 初始化日志配置(使用固定文件名,清理旧日志) + # Initialize log config (use fixed log file names, clean up old logs) simulation_dir = os.path.dirname(args.config) or "." setup_oasis_logging(os.path.join(simulation_dir, "log")) @@ -748,20 +748,20 @@ async def main(): def setup_signal_handlers(): """ - 设置信号处理器,确保收到 SIGTERM/SIGINT 时能够正确退出 - 让程序有机会正常清理资源(关闭数据库、环境等) + Set up signal handlers to ensure clean exit on SIGTERM/SIGINT + Give the program a chance to clean up resources (close DB, env, etc.) """ def signal_handler(signum, frame): global _cleanup_done sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT" - print(f"\n收到 {sig_name} 信号,正在退出...") + print(f"\nReceived {sig_name} signal, exiting...") if not _cleanup_done: _cleanup_done = True if _shutdown_event: _shutdown_event.set() else: - # 重复收到信号才强制退出 - print("强制退出...") + # Only force exit if the signal is received again + print("Forced exit...") sys.exit(1) signal.signal(signal.SIGTERM, signal_handler) @@ -773,8 +773,8 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - print("\n程序被中断") + print("\nProgram interrupted") except SystemExit: pass finally: - print("模拟进程已退出") + print("Simulation process exited") diff --git a/backend/scripts/test_profile_format.py b/backend/scripts/test_profile_format.py index 354e8b5c..ba404f6f 100644 --- a/backend/scripts/test_profile_format.py +++ b/backend/scripts/test_profile_format.py @@ -1,8 +1,8 @@ """ -测试Profile格式生成是否符合OASIS要求 -验证: -1. Twitter Profile生成CSV格式 -2. Reddit Profile生成JSON详细格式 +Test that the generated Profile formats conform to OASIS requirements +Verifies: +1. Twitter Profile generated as CSV format +2. Reddit Profile generated as detailed JSON format """ import os @@ -11,19 +11,19 @@ import json import csv import tempfile -# 添加项目路径 +# Add project path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.services.oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile def test_profile_formats(): - """测试Profile格式""" + """Test Profile formats""" print("=" * 60) - print("OASIS Profile格式测试") + print("OASIS Profile format test") print("=" * 60) - - # 创建测试Profile数据 + + # Create test Profile data test_profiles = [ OasisAgentProfile( user_id=0, @@ -60,87 +60,87 @@ def test_profile_formats(): source_entity_type="University", ), ] - + generator = OasisProfileGenerator.__new__(OasisProfileGenerator) - - # 使用临时目录 + + # Use a temporary directory with tempfile.TemporaryDirectory() as temp_dir: twitter_path = os.path.join(temp_dir, "twitter_profiles.csv") reddit_path = os.path.join(temp_dir, "reddit_profiles.json") - - # 测试Twitter CSV格式 - print("\n1. 测试Twitter Profile (CSV格式)") + + # Test Twitter CSV format + print("\n1. Test Twitter Profile (CSV format)") print("-" * 40) generator._save_twitter_csv(test_profiles, twitter_path) - - # 读取并验证CSV + + # Read and verify the CSV with open(twitter_path, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) rows = list(reader) - - print(f" 文件: {twitter_path}") - print(f" 行数: {len(rows)}") - print(f" 表头: {list(rows[0].keys())}") - print(f"\n 示例数据 (第1行):") + + print(f" File: {twitter_path}") + print(f" Row count: {len(rows)}") + print(f" Header: {list(rows[0].keys())}") + print(f"\n Sample data (row 1):") for key, value in rows[0].items(): print(f" {key}: {value}") - - # 验证必需字段 - required_twitter_fields = ['user_id', 'user_name', 'name', 'bio', + + # Verify required fields + required_twitter_fields = ['user_id', 'user_name', 'name', 'bio', 'friend_count', 'follower_count', 'statuses_count', 'created_at'] missing = set(required_twitter_fields) - set(rows[0].keys()) if missing: - print(f"\n [错误] 缺少字段: {missing}") + print(f"\n [Error] Missing fields: {missing}") else: - print(f"\n [通过] 所有必需字段都存在") - - # 测试Reddit JSON格式 - print("\n2. 测试Reddit Profile (JSON详细格式)") + print(f"\n [Pass] All required fields are present") + + # Test Reddit JSON format + print("\n2. Test Reddit Profile (detailed JSON format)") print("-" * 40) generator._save_reddit_json(test_profiles, reddit_path) - - # 读取并验证JSON + + # Read and verify the JSON with open(reddit_path, 'r', encoding='utf-8') as f: reddit_data = json.load(f) - - print(f" 文件: {reddit_path}") - print(f" 条目数: {len(reddit_data)}") - print(f" 字段: {list(reddit_data[0].keys())}") - print(f"\n 示例数据 (第1条):") + + print(f" File: {reddit_path}") + print(f" Entry count: {len(reddit_data)}") + print(f" Fields: {list(reddit_data[0].keys())}") + print(f"\n Sample data (entry 1):") print(json.dumps(reddit_data[0], ensure_ascii=False, indent=4)) - - # 验证详细格式字段 + + # Verify detailed-format fields required_reddit_fields = ['realname', 'username', 'bio', 'persona'] optional_reddit_fields = ['age', 'gender', 'mbti', 'country', 'profession', 'interested_topics'] - + missing = set(required_reddit_fields) - set(reddit_data[0].keys()) if missing: - print(f"\n [错误] 缺少必需字段: {missing}") + print(f"\n [Error] Missing required fields: {missing}") else: - print(f"\n [通过] 所有必需字段都存在") - + print(f"\n [Pass] All required fields are present") + present_optional = set(optional_reddit_fields) & set(reddit_data[0].keys()) - print(f" [信息] 可选字段: {present_optional}") - + print(f" [Info] Optional fields: {present_optional}") + print("\n" + "=" * 60) - print("测试完成!") + print("Tests complete!") print("=" * 60) def show_expected_formats(): - """显示OASIS期望的格式""" + """Show the formats expected by OASIS""" print("\n" + "=" * 60) - print("OASIS 期望的Profile格式参考") + print("OASIS expected Profile format reference") print("=" * 60) - - print("\n1. Twitter Profile (CSV格式)") + + print("\n1. Twitter Profile (CSV format)") print("-" * 40) twitter_example = """user_id,user_name,name,bio,friend_count,follower_count,statuses_count,created_at 0,user0,User Zero,I am user zero with interests in technology.,100,150,500,2023-01-01 1,user1,User One,Tech enthusiast and coffee lover.,200,250,1000,2023-01-02""" print(twitter_example) - - print("\n2. Reddit Profile (JSON详细格式)") + + print("\n2. Reddit Profile (detailed JSON format)") print("-" * 40) reddit_example = [ { diff --git a/docker-compose.yml b/docker-compose.yml index 637f1dfa..7964a54e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: mirofish: image: ghcr.io/666ghj/mirofish:latest - # 加速镜像(如拉取缓慢可替换上方地址) + # Accelerated registry mirror (replace the upstream address above if pulling is slow) # image: ghcr.nju.edu.cn/666ghj/mirofish:latest container_name: mirofish env_file: diff --git a/frontend/index.html b/frontend/index.html index 0b80095c..7ae57a27 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,15 +1,15 @@ - + - + - - MiroFish - 预测万物 + + MiroFish - Predict Anything
diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b7cd71ca..4ef4895f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,11 +3,11 @@