Spaces:
Sleeping
Sleeping
Implement Interview feature for agent interactions in simulations
Browse files- Added a new Interview module to facilitate interactions with agents post-simulation, allowing for single and batch interviews.
- Introduced IPC communication mechanism for command and response handling between the Flask backend and simulation scripts.
- Updated README.md to include detailed instructions on the new Interview functionality, including API endpoints and usage examples.
- Enhanced simulation scripts to support waiting for commands after completion, improving user control over the simulation environment.
- Implemented error handling and logging for interview processes, ensuring robust operation and traceability.
- backend/README.md +464 -4
- backend/app/api/simulation.py +565 -0
- backend/app/services/__init__.py +14 -0
- backend/app/services/simulation_ipc.py +394 -0
- backend/app/services/simulation_runner.py +363 -1
- backend/scripts/run_parallel_simulation.py +523 -33
- backend/scripts/run_reddit_simulation.py +320 -15
- backend/scripts/run_twitter_simulation.py +320 -17
backend/README.md
CHANGED
|
@@ -73,6 +73,11 @@
|
|
| 73 |
启动模拟 → 运行OASIS脚本 → 实时监控 → 记录动作 → (可选)更新Zep图谱记忆 → 状态查询
|
| 74 |
```
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
---
|
| 77 |
|
| 78 |
## 技术栈
|
|
@@ -152,6 +157,7 @@ backend/
|
|
| 152 |
│ ├── simulation_config_generator.py # 配置生成
|
| 153 |
│ ├── simulation_manager.py # 模拟管理
|
| 154 |
│ ├── simulation_runner.py # 模拟运行
|
|
|
|
| 155 |
│ └── zep_graph_memory_updater.py # 图谱记忆动态更新
|
| 156 |
└── utils/ # 工具类
|
| 157 |
├── __init__.py
|
|
@@ -211,12 +217,43 @@ backend/
|
|
| 211 |
4. 解析动作日志(actions.jsonl)
|
| 212 |
5. (可选)将Agent活动实时更新到Zep图谱
|
| 213 |
6. 实时更新运行状态
|
| 214 |
-
7.
|
|
|
|
| 215 |
|
| 216 |
**核心服务**:
|
| 217 |
- `SimulationRunner`: 模拟运行器
|
| 218 |
- `ZepGraphMemoryUpdater`: 图谱记忆动态更新器
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
---
|
| 221 |
|
| 222 |
## API接口文档
|
|
@@ -630,6 +667,290 @@ backend/
|
|
| 630 |
|
| 631 |
---
|
| 632 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
#### 6. 获取运行状态
|
| 634 |
|
| 635 |
**接口**: `GET /api/simulation/{simulation_id}/run-status`
|
|
@@ -1395,6 +1716,92 @@ POST /api/simulation/start
|
|
| 1395 |
|
| 1396 |
---
|
| 1397 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1398 |
## 工具类
|
| 1399 |
|
| 1400 |
### 1. FileParser (文件解析器)
|
|
@@ -1661,7 +2068,35 @@ curl -X POST http://localhost:5001/api/simulation/start \
|
|
| 1661 |
# Step 8: 实时查询运行状态
|
| 1662 |
curl http://localhost:5001/api/simulation/{sim_xxx}/run-status
|
| 1663 |
|
| 1664 |
-
# Step 9:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1665 |
curl -X POST http://localhost:5001/api/simulation/stop \
|
| 1666 |
-H "Content-Type: application/json" \
|
| 1667 |
-d '{
|
|
@@ -1830,6 +2265,31 @@ MIT License
|
|
| 1830 |
|
| 1831 |
---
|
| 1832 |
|
| 1833 |
-
**最后更新**: 2025-12-
|
| 1834 |
-
**版本**: v1.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1835 |
|
|
|
|
| 73 |
启动模拟 → 运行OASIS脚本 → 实时监控 → 记录动作 → (可选)更新Zep图谱记忆 → 状态查询
|
| 74 |
```
|
| 75 |
|
| 76 |
+
4. **Interview采访流程**:
|
| 77 |
+
```
|
| 78 |
+
模拟完成 → 环境进入等待模式 → 发送Interview命令 → Agent回答 → 获取结果 → (可选)关闭环境
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
---
|
| 82 |
|
| 83 |
## 技术栈
|
|
|
|
| 157 |
│ ├── simulation_config_generator.py # 配置生成
|
| 158 |
│ ├── simulation_manager.py # 模拟管理
|
| 159 |
│ ├── simulation_runner.py # 模拟运行
|
| 160 |
+
│ ├── simulation_ipc.py # 模拟IPC通信(Interview功能)
|
| 161 |
│ └── zep_graph_memory_updater.py # 图谱记忆动态更新
|
| 162 |
└── utils/ # 工具类
|
| 163 |
├── __init__.py
|
|
|
|
| 217 |
4. 解析动作日志(actions.jsonl)
|
| 218 |
5. (可选)将Agent活动实时更新到Zep图谱
|
| 219 |
6. 实时更新运行状态
|
| 220 |
+
7. 模拟完成后进入等待命令模式
|
| 221 |
+
8. 支持停止/暂停/恢复
|
| 222 |
|
| 223 |
**核心服务**:
|
| 224 |
- `SimulationRunner`: 模拟运行器
|
| 225 |
- `ZepGraphMemoryUpdater`: 图谱记忆动态更新器
|
| 226 |
|
| 227 |
+
### 4. Agent采访(Interview)模块
|
| 228 |
+
|
| 229 |
+
**功能**: 在模拟完成后对Agent进行采访
|
| 230 |
+
|
| 231 |
+
**特点**:
|
| 232 |
+
- **模拟状态持久化**: 模拟完成后环境不立即关闭,进入等待命令模式
|
| 233 |
+
- **IPC通信机制**: 通过文件系统在Flask后端和模拟脚本之间通信
|
| 234 |
+
- **单个采访**: 对指定Agent提问并获取回答
|
| 235 |
+
- **批量采访**: 同时对多个Agent提不同问题
|
| 236 |
+
- **全局采访**: 使用相同问题采访所有Agent
|
| 237 |
+
- **采访历史**: 从数据库读取所有Interview记录
|
| 238 |
+
|
| 239 |
+
**核心服务**:
|
| 240 |
+
- `SimulationIPCClient`: IPC客户端(Flask端使用)
|
| 241 |
+
- `SimulationIPCServer`: IPC服务器(模拟脚本端使用)
|
| 242 |
+
|
| 243 |
+
**工作原理**:
|
| 244 |
+
```
|
| 245 |
+
Flask后端 模拟脚本
|
| 246 |
+
│ │
|
| 247 |
+
│ 写入命令文件 │
|
| 248 |
+
│ ─────────────────────────→│
|
| 249 |
+
│ │ 轮询命令目录
|
| 250 |
+
│ │ 执行Interview
|
| 251 |
+
│ │ 写入响应文件
|
| 252 |
+
│←───────────────────────── │
|
| 253 |
+
│ 读取响应文件 │
|
| 254 |
+
│ │
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
---
|
| 258 |
|
| 259 |
## API接口文档
|
|
|
|
| 667 |
|
| 668 |
---
|
| 669 |
|
| 670 |
+
### Interview 采访接口
|
| 671 |
+
|
| 672 |
+
> **注意**: 所有Interview接口的参数都通过请求体(JSON)传递,包括simulation_id。
|
| 673 |
+
>
|
| 674 |
+
> **双平台模式说明**: 当不指定`platform`参数时,双平台模拟会同时采访两个平台并返回整合结果。
|
| 675 |
+
|
| 676 |
+
#### 1. 采访单个Agent
|
| 677 |
+
|
| 678 |
+
**接口**: `POST /api/simulation/interview`
|
| 679 |
+
|
| 680 |
+
**请求参数**:
|
| 681 |
+
```json
|
| 682 |
+
{
|
| 683 |
+
"simulation_id": "sim_xxxx",
|
| 684 |
+
"agent_id": 0,
|
| 685 |
+
"prompt": "你对这件事有什么看法?",
|
| 686 |
+
"platform": "reddit",
|
| 687 |
+
"timeout": 60
|
| 688 |
+
}
|
| 689 |
+
```
|
| 690 |
+
|
| 691 |
+
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
| 692 |
+
|------|------|------|--------|------|
|
| 693 |
+
| simulation_id | String | 是 | - | 模拟ID |
|
| 694 |
+
| agent_id | Integer | 是 | - | Agent ID |
|
| 695 |
+
| prompt | String | 是 | - | 采访问题 |
|
| 696 |
+
| platform | String | 否 | null | 指定平台(twitter/reddit),不指定则双平台同时采访 |
|
| 697 |
+
| timeout | Integer | 否 | 60 | 超时时间(秒) |
|
| 698 |
+
|
| 699 |
+
**返回示例(指定单平台)**:
|
| 700 |
+
```json
|
| 701 |
+
{
|
| 702 |
+
"success": true,
|
| 703 |
+
"data": {
|
| 704 |
+
"success": true,
|
| 705 |
+
"agent_id": 0,
|
| 706 |
+
"prompt": "你对这件事有什么看法?",
|
| 707 |
+
"result": {
|
| 708 |
+
"agent_id": 0,
|
| 709 |
+
"response": "我认为这件事反映了...",
|
| 710 |
+
"platform": "reddit",
|
| 711 |
+
"timestamp": "2025-12-08T10:00:00"
|
| 712 |
+
},
|
| 713 |
+
"timestamp": "2025-12-08T10:00:01"
|
| 714 |
+
}
|
| 715 |
+
}
|
| 716 |
+
```
|
| 717 |
+
|
| 718 |
+
**返回示例(不指定platform,双平台模式)**:
|
| 719 |
+
```json
|
| 720 |
+
{
|
| 721 |
+
"success": true,
|
| 722 |
+
"data": {
|
| 723 |
+
"success": true,
|
| 724 |
+
"agent_id": 0,
|
| 725 |
+
"prompt": "你对这件事有什么看法?",
|
| 726 |
+
"result": {
|
| 727 |
+
"agent_id": 0,
|
| 728 |
+
"prompt": "你对这件事有什么看法?",
|
| 729 |
+
"platforms": {
|
| 730 |
+
"twitter": {
|
| 731 |
+
"agent_id": 0,
|
| 732 |
+
"response": "从Twitter视角来看...",
|
| 733 |
+
"platform": "twitter",
|
| 734 |
+
"timestamp": "2025-12-08T10:00:00"
|
| 735 |
+
},
|
| 736 |
+
"reddit": {
|
| 737 |
+
"agent_id": 0,
|
| 738 |
+
"response": "作为Reddit用户,我认为...",
|
| 739 |
+
"platform": "reddit",
|
| 740 |
+
"timestamp": "2025-12-08T10:00:00"
|
| 741 |
+
}
|
| 742 |
+
}
|
| 743 |
+
},
|
| 744 |
+
"timestamp": "2025-12-08T10:00:01"
|
| 745 |
+
}
|
| 746 |
+
}
|
| 747 |
+
```
|
| 748 |
+
|
| 749 |
+
**注意**: 此功能需要模拟环境处于运行状态(完成模拟循环后进入等待命令模式)
|
| 750 |
+
|
| 751 |
+
---
|
| 752 |
+
|
| 753 |
+
#### 2. 批量采访多个Agent
|
| 754 |
+
|
| 755 |
+
**接口**: `POST /api/simulation/interview/batch`
|
| 756 |
+
|
| 757 |
+
**请求参数**:
|
| 758 |
+
```json
|
| 759 |
+
{
|
| 760 |
+
"simulation_id": "sim_xxxx",
|
| 761 |
+
"interviews": [
|
| 762 |
+
{"agent_id": 0, "prompt": "你对A有什么看法?", "platform": "twitter"},
|
| 763 |
+
{"agent_id": 1, "prompt": "你对B有什么看法?", "platform": "reddit"},
|
| 764 |
+
{"agent_id": 2, "prompt": "你对C有什么看法?"}
|
| 765 |
+
],
|
| 766 |
+
"platform": "reddit",
|
| 767 |
+
"timeout": 120
|
| 768 |
+
}
|
| 769 |
+
```
|
| 770 |
+
|
| 771 |
+
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
| 772 |
+
|------|------|------|--------|------|
|
| 773 |
+
| simulation_id | String | 是 | - | 模拟ID |
|
| 774 |
+
| interviews | Array | 是 | - | 采访列表,每项包含agent_id、prompt和可选的platform |
|
| 775 |
+
| platform | String | 否 | null | 默认平台(被每项的platform覆盖),不指定则双平台同时采访 |
|
| 776 |
+
| timeout | Integer | 否 | 120 | 超时时间(秒) |
|
| 777 |
+
|
| 778 |
+
**返回示例**:
|
| 779 |
+
```json
|
| 780 |
+
{
|
| 781 |
+
"success": true,
|
| 782 |
+
"data": {
|
| 783 |
+
"success": true,
|
| 784 |
+
"interviews_count": 3,
|
| 785 |
+
"result": {
|
| 786 |
+
"interviews_count": 6,
|
| 787 |
+
"results": {
|
| 788 |
+
"twitter_0": {"agent_id": 0, "response": "...", "platform": "twitter"},
|
| 789 |
+
"reddit_1": {"agent_id": 1, "response": "...", "platform": "reddit"},
|
| 790 |
+
"twitter_2": {"agent_id": 2, "response": "...", "platform": "twitter"},
|
| 791 |
+
"reddit_2": {"agent_id": 2, "response": "...", "platform": "reddit"}
|
| 792 |
+
}
|
| 793 |
+
},
|
| 794 |
+
"timestamp": "2025-12-08T10:00:01"
|
| 795 |
+
}
|
| 796 |
+
}
|
| 797 |
+
```
|
| 798 |
+
|
| 799 |
+
---
|
| 800 |
+
|
| 801 |
+
#### 3. 全局采访(采访所有Agent)
|
| 802 |
+
|
| 803 |
+
**接口**: `POST /api/simulation/interview/all`
|
| 804 |
+
|
| 805 |
+
**请求参数**:
|
| 806 |
+
```json
|
| 807 |
+
{
|
| 808 |
+
"simulation_id": "sim_xxxx",
|
| 809 |
+
"prompt": "你对这件事整体有什么看法?",
|
| 810 |
+
"platform": "reddit",
|
| 811 |
+
"timeout": 180
|
| 812 |
+
}
|
| 813 |
+
```
|
| 814 |
+
|
| 815 |
+
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
| 816 |
+
|------|------|------|--------|------|
|
| 817 |
+
| simulation_id | String | 是 | - | 模拟ID |
|
| 818 |
+
| prompt | String | 是 | - | 采访问题(所有Agent使用相同问题) |
|
| 819 |
+
| platform | String | 否 | null | 指定平台(twitter/reddit),不指定则双平台同时采访 |
|
| 820 |
+
| timeout | Integer | 否 | 180 | 超时时间(秒) |
|
| 821 |
+
|
| 822 |
+
**返回示例**:
|
| 823 |
+
```json
|
| 824 |
+
{
|
| 825 |
+
"success": true,
|
| 826 |
+
"data": {
|
| 827 |
+
"success": true,
|
| 828 |
+
"interviews_count": 50,
|
| 829 |
+
"result": {
|
| 830 |
+
"interviews_count": 100,
|
| 831 |
+
"results": {
|
| 832 |
+
"twitter_0": {"agent_id": 0, "response": "...", "platform": "twitter"},
|
| 833 |
+
"reddit_0": {"agent_id": 0, "response": "...", "platform": "reddit"},
|
| 834 |
+
"twitter_1": {"agent_id": 1, "response": "...", "platform": "twitter"},
|
| 835 |
+
"reddit_1": {"agent_id": 1, "response": "...", "platform": "reddit"},
|
| 836 |
+
...
|
| 837 |
+
}
|
| 838 |
+
},
|
| 839 |
+
"timestamp": "2025-12-08T10:00:01"
|
| 840 |
+
}
|
| 841 |
+
}
|
| 842 |
+
```
|
| 843 |
+
|
| 844 |
+
---
|
| 845 |
+
|
| 846 |
+
#### 4. 获取Interview历史
|
| 847 |
+
|
| 848 |
+
**接口**: `POST /api/simulation/interview/history`
|
| 849 |
+
|
| 850 |
+
**请求参数**:
|
| 851 |
+
```json
|
| 852 |
+
{
|
| 853 |
+
"simulation_id": "sim_xxxx",
|
| 854 |
+
"platform": "reddit",
|
| 855 |
+
"agent_id": 0,
|
| 856 |
+
"limit": 100
|
| 857 |
+
}
|
| 858 |
+
```
|
| 859 |
+
|
| 860 |
+
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
| 861 |
+
|------|------|------|--------|------|
|
| 862 |
+
| simulation_id | String | 是 | - | 模拟ID |
|
| 863 |
+
| platform | String | 否 | reddit | 平台类型(reddit/twitter) |
|
| 864 |
+
| agent_id | Integer | 否 | - | 过滤Agent ID |
|
| 865 |
+
| limit | Integer | 否 | 100 | 返回数量限制 |
|
| 866 |
+
|
| 867 |
+
**返回示例**:
|
| 868 |
+
```json
|
| 869 |
+
{
|
| 870 |
+
"success": true,
|
| 871 |
+
"data": {
|
| 872 |
+
"count": 10,
|
| 873 |
+
"history": [
|
| 874 |
+
{
|
| 875 |
+
"agent_id": 0,
|
| 876 |
+
"response": "我认为...",
|
| 877 |
+
"prompt": "你对这件事有什么看法?",
|
| 878 |
+
"timestamp": "2025-12-08T10:00:00",
|
| 879 |
+
"platform": "reddit"
|
| 880 |
+
},
|
| 881 |
+
...
|
| 882 |
+
]
|
| 883 |
+
}
|
| 884 |
+
}
|
| 885 |
+
```
|
| 886 |
+
|
| 887 |
+
---
|
| 888 |
+
|
| 889 |
+
#### 5. 获取模拟环境状态
|
| 890 |
+
|
| 891 |
+
**接口**: `POST /api/simulation/env-status`
|
| 892 |
+
|
| 893 |
+
**请求参数**:
|
| 894 |
+
```json
|
| 895 |
+
{
|
| 896 |
+
"simulation_id": "sim_xxxx"
|
| 897 |
+
}
|
| 898 |
+
```
|
| 899 |
+
|
| 900 |
+
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
| 901 |
+
|------|------|------|--------|------|
|
| 902 |
+
| simulation_id | String | 是 | - | 模拟ID |
|
| 903 |
+
|
| 904 |
+
**返回示例**:
|
| 905 |
+
```json
|
| 906 |
+
{
|
| 907 |
+
"success": true,
|
| 908 |
+
"data": {
|
| 909 |
+
"simulation_id": "sim_xxxx",
|
| 910 |
+
"env_alive": true,
|
| 911 |
+
"twitter_available": true,
|
| 912 |
+
"reddit_available": true,
|
| 913 |
+
"message": "环境正在运行,可以接收Interview命令"
|
| 914 |
+
}
|
| 915 |
+
}
|
| 916 |
+
```
|
| 917 |
+
|
| 918 |
+
---
|
| 919 |
+
|
| 920 |
+
#### 6. 关闭模拟环境
|
| 921 |
+
|
| 922 |
+
**接口**: `POST /api/simulation/close-env`
|
| 923 |
+
|
| 924 |
+
**请求参数**:
|
| 925 |
+
```json
|
| 926 |
+
{
|
| 927 |
+
"simulation_id": "sim_10b494550540",
|
| 928 |
+
"timeout": 30
|
| 929 |
+
}
|
| 930 |
+
```
|
| 931 |
+
|
| 932 |
+
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
| 933 |
+
|------|------|------|--------|------|
|
| 934 |
+
| simulation_id | String | 是 | - | 模拟ID |
|
| 935 |
+
| timeout | Integer | 否 | 30 | 超时时间(秒) |
|
| 936 |
+
|
| 937 |
+
**返回示例**:
|
| 938 |
+
```json
|
| 939 |
+
{
|
| 940 |
+
"success": true,
|
| 941 |
+
"data": {
|
| 942 |
+
"success": true,
|
| 943 |
+
"message": "环境关闭命令已发送",
|
| 944 |
+
"result": {"message": "环境即将关闭"},
|
| 945 |
+
"timestamp": "2025-12-08T10:00:01"
|
| 946 |
+
}
|
| 947 |
+
}
|
| 948 |
+
```
|
| 949 |
+
|
| 950 |
+
**注意**: 此接口与 `/stop` 不同:
|
| 951 |
+
- `/stop`: 强制终止模拟进程
|
| 952 |
+
- `/close-env`: 优雅地关闭环境,让模拟进程正常退出
|
| 953 |
+
|
| 954 |
#### 6. 获取运行状态
|
| 955 |
|
| 956 |
**接口**: `GET /api/simulation/{simulation_id}/run-status`
|
|
|
|
| 1716 |
|
| 1717 |
---
|
| 1718 |
|
| 1719 |
+
### 9. SimulationIPCClient/Server (IPC通信模块)
|
| 1720 |
+
|
| 1721 |
+
**文件**: `app/services/simulation_ipc.py`
|
| 1722 |
+
|
| 1723 |
+
**功能**: 实现Flask后端与模拟脚本之间的进程间通信
|
| 1724 |
+
|
| 1725 |
+
**核心类**:
|
| 1726 |
+
|
| 1727 |
+
```python
|
| 1728 |
+
class SimulationIPCClient:
|
| 1729 |
+
"""IPC客户端(Flask端使用)"""
|
| 1730 |
+
|
| 1731 |
+
def send_interview(agent_id: int, prompt: str, timeout: float) -> IPCResponse:
|
| 1732 |
+
"""发送单个Agent采访命令"""
|
| 1733 |
+
|
| 1734 |
+
def send_batch_interview(interviews: List[Dict], timeout: float) -> IPCResponse:
|
| 1735 |
+
"""发送批量采访命令"""
|
| 1736 |
+
|
| 1737 |
+
def send_close_env(timeout: float) -> IPCResponse:
|
| 1738 |
+
"""发送关闭环境命令"""
|
| 1739 |
+
|
| 1740 |
+
def check_env_alive() -> bool:
|
| 1741 |
+
"""检查模拟环境是否存活"""
|
| 1742 |
+
```
|
| 1743 |
+
|
| 1744 |
+
```python
|
| 1745 |
+
class SimulationIPCServer:
|
| 1746 |
+
"""IPC服务器(模拟脚本端使用)"""
|
| 1747 |
+
|
| 1748 |
+
def poll_commands() -> Optional[IPCCommand]:
|
| 1749 |
+
"""轮询获取待处理命令"""
|
| 1750 |
+
|
| 1751 |
+
def send_response(response: IPCResponse):
|
| 1752 |
+
"""发送响应"""
|
| 1753 |
+
```
|
| 1754 |
+
|
| 1755 |
+
**命令类型**:
|
| 1756 |
+
|
| 1757 |
+
| 命令类型 | 说明 |
|
| 1758 |
+
|----------|------|
|
| 1759 |
+
| interview | 单个Agent采访 |
|
| 1760 |
+
| batch_interview | 批量采访 |
|
| 1761 |
+
| close_env | 关闭环境 |
|
| 1762 |
+
|
| 1763 |
+
**文件结构**:
|
| 1764 |
+
|
| 1765 |
+
```
|
| 1766 |
+
uploads/simulations/sim_xxx/
|
| 1767 |
+
├── ipc_commands/ # 命令文件目录
|
| 1768 |
+
│ └── {command_id}.json # 待处理命令
|
| 1769 |
+
├── ipc_responses/ # 响应文件目录
|
| 1770 |
+
│ └── {command_id}.json # 命令响应
|
| 1771 |
+
└── env_status.json # 环境状态文件
|
| 1772 |
+
```
|
| 1773 |
+
|
| 1774 |
+
**使用示例**:
|
| 1775 |
+
|
| 1776 |
+
```python
|
| 1777 |
+
# Flask端发送Interview命令
|
| 1778 |
+
from app.services import SimulationRunner
|
| 1779 |
+
|
| 1780 |
+
# 单个采访
|
| 1781 |
+
result = SimulationRunner.interview_agent(
|
| 1782 |
+
simulation_id="sim_xxx",
|
| 1783 |
+
agent_id=0,
|
| 1784 |
+
prompt="你对这件事有什么看法?"
|
| 1785 |
+
)
|
| 1786 |
+
|
| 1787 |
+
# 批量采访
|
| 1788 |
+
result = SimulationRunner.interview_agents_batch(
|
| 1789 |
+
simulation_id="sim_xxx",
|
| 1790 |
+
interviews=[
|
| 1791 |
+
{"agent_id": 0, "prompt": "问题A"},
|
| 1792 |
+
{"agent_id": 1, "prompt": "问题B"}
|
| 1793 |
+
]
|
| 1794 |
+
)
|
| 1795 |
+
|
| 1796 |
+
# 全局采访
|
| 1797 |
+
result = SimulationRunner.interview_all_agents(
|
| 1798 |
+
simulation_id="sim_xxx",
|
| 1799 |
+
prompt="你认为事件会如何发展?"
|
| 1800 |
+
)
|
| 1801 |
+
```
|
| 1802 |
+
|
| 1803 |
+
---
|
| 1804 |
+
|
| 1805 |
## 工具类
|
| 1806 |
|
| 1807 |
### 1. FileParser (文件解析器)
|
|
|
|
| 2068 |
# Step 8: 实时查询运行状态
|
| 2069 |
curl http://localhost:5001/api/simulation/{sim_xxx}/run-status
|
| 2070 |
|
| 2071 |
+
# Step 9: 检查环境状态(模拟完成后环境会进入等待命令模式)
|
| 2072 |
+
curl http://localhost:5001/api/simulation/{sim_xxx}/env-status
|
| 2073 |
+
|
| 2074 |
+
# Step 10: 采访单个Agent
|
| 2075 |
+
curl -X POST http://localhost:5001/api/simulation/{sim_xxx}/interview \
|
| 2076 |
+
-H "Content-Type: application/json" \
|
| 2077 |
+
-d '{
|
| 2078 |
+
"agent_id": 0,
|
| 2079 |
+
"prompt": "你对这件事有什么看法?"
|
| 2080 |
+
}'
|
| 2081 |
+
|
| 2082 |
+
# Step 11: 全局采访(采访所有Agent)
|
| 2083 |
+
curl -X POST http://localhost:5001/api/simulation/{sim_xxx}/interview/all \
|
| 2084 |
+
-H "Content-Type: application/json" \
|
| 2085 |
+
-d '{
|
| 2086 |
+
"prompt": "你认为事件的后续发展会如何?"
|
| 2087 |
+
}'
|
| 2088 |
+
|
| 2089 |
+
# Step 12: 获取Interview历史
|
| 2090 |
+
curl http://localhost:5001/api/simulation/{sim_xxx}/interview/history
|
| 2091 |
+
|
| 2092 |
+
# Step 13: 关闭模拟环境(优雅退出)
|
| 2093 |
+
curl -X POST http://localhost:5001/api/simulation/close-env \
|
| 2094 |
+
-H "Content-Type: application/json" \
|
| 2095 |
+
-d '{
|
| 2096 |
+
"simulation_id": "sim_xxx"
|
| 2097 |
+
}'
|
| 2098 |
+
|
| 2099 |
+
# 或者强制停止模拟
|
| 2100 |
curl -X POST http://localhost:5001/api/simulation/stop \
|
| 2101 |
-H "Content-Type: application/json" \
|
| 2102 |
-d '{
|
|
|
|
| 2265 |
|
| 2266 |
---
|
| 2267 |
|
| 2268 |
+
**最后更新**: 2025-12-08
|
| 2269 |
+
**版本**: v1.2.0
|
| 2270 |
+
|
| 2271 |
+
### 更新日志
|
| 2272 |
+
|
| 2273 |
+
**v1.2.0 (2025-12-08)**:
|
| 2274 |
+
- 新增 Interview 采访功能
|
| 2275 |
+
- 支持单个Agent采访
|
| 2276 |
+
- 支持批量采访多个Agent
|
| 2277 |
+
- 支持全局采访(所有Agent使用相同问题)
|
| 2278 |
+
- 支持获取Interview历史记录
|
| 2279 |
+
- 新增模拟状态持久化
|
| 2280 |
+
- 模拟完成后环境不立即关闭,进入等待命令模式
|
| 2281 |
+
- 支持优雅关闭环境命令
|
| 2282 |
+
- 新增 IPC 通信机制
|
| 2283 |
+
- Flask后端与模拟脚本之间的进程间通信
|
| 2284 |
+
- 基于文件系统的命令/响应模式
|
| 2285 |
+
|
| 2286 |
+
**v1.1.0 (2025-12-05)**:
|
| 2287 |
+
- 新增图谱记忆动态更新功能
|
| 2288 |
+
- 支持 max_rounds 参数限制模拟轮数
|
| 2289 |
+
|
| 2290 |
+
**v1.0.0**:
|
| 2291 |
+
- 初始版本发布
|
| 2292 |
+
- 支持知识图谱构建
|
| 2293 |
+
- 支持Agent人设生成
|
| 2294 |
+
- 支持双平台模拟
|
| 2295 |
|
backend/app/api/simulation.py
CHANGED
|
@@ -1723,3 +1723,568 @@ def get_simulation_comments(simulation_id: str):
|
|
| 1723 |
"error": str(e),
|
| 1724 |
"traceback": traceback.format_exc()
|
| 1725 |
}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1723 |
"error": str(e),
|
| 1724 |
"traceback": traceback.format_exc()
|
| 1725 |
}), 500
|
| 1726 |
+
|
| 1727 |
+
|
| 1728 |
+
# ============== Interview 采访接口 ==============
|
| 1729 |
+
|
| 1730 |
+
@simulation_bp.route('/interview', methods=['POST'])
|
| 1731 |
+
def interview_agent():
|
| 1732 |
+
"""
|
| 1733 |
+
采访单个Agent
|
| 1734 |
+
|
| 1735 |
+
注意:此功能需要模拟环境处于运行状态(完成模拟循环后进入等待命令模式)
|
| 1736 |
+
|
| 1737 |
+
请求(JSON):
|
| 1738 |
+
{
|
| 1739 |
+
"simulation_id": "sim_xxxx", // 必填,模拟ID
|
| 1740 |
+
"agent_id": 0, // 必填,Agent ID
|
| 1741 |
+
"prompt": "你对这件事有什么看法?", // 必填,采访问题
|
| 1742 |
+
"platform": "twitter", // 可选,指定平台(twitter/reddit)
|
| 1743 |
+
// 不指定时:双平台模拟同时采访两个平台
|
| 1744 |
+
"timeout": 60 // 可选,超时时间(秒),默认60
|
| 1745 |
+
}
|
| 1746 |
+
|
| 1747 |
+
返回(不指定platform,双平台模式):
|
| 1748 |
+
{
|
| 1749 |
+
"success": true,
|
| 1750 |
+
"data": {
|
| 1751 |
+
"agent_id": 0,
|
| 1752 |
+
"prompt": "你对这件事有什么看法?",
|
| 1753 |
+
"result": {
|
| 1754 |
+
"agent_id": 0,
|
| 1755 |
+
"prompt": "...",
|
| 1756 |
+
"platforms": {
|
| 1757 |
+
"twitter": {"agent_id": 0, "response": "...", "platform": "twitter"},
|
| 1758 |
+
"reddit": {"agent_id": 0, "response": "...", "platform": "reddit"}
|
| 1759 |
+
}
|
| 1760 |
+
},
|
| 1761 |
+
"timestamp": "2025-12-08T10:00:01"
|
| 1762 |
+
}
|
| 1763 |
+
}
|
| 1764 |
+
|
| 1765 |
+
返回(指定platform):
|
| 1766 |
+
{
|
| 1767 |
+
"success": true,
|
| 1768 |
+
"data": {
|
| 1769 |
+
"agent_id": 0,
|
| 1770 |
+
"prompt": "你对这件事有什么看法?",
|
| 1771 |
+
"result": {
|
| 1772 |
+
"agent_id": 0,
|
| 1773 |
+
"response": "我认为...",
|
| 1774 |
+
"platform": "twitter",
|
| 1775 |
+
"timestamp": "2025-12-08T10:00:00"
|
| 1776 |
+
},
|
| 1777 |
+
"timestamp": "2025-12-08T10:00:01"
|
| 1778 |
+
}
|
| 1779 |
+
}
|
| 1780 |
+
"""
|
| 1781 |
+
try:
|
| 1782 |
+
data = request.get_json() or {}
|
| 1783 |
+
|
| 1784 |
+
simulation_id = data.get('simulation_id')
|
| 1785 |
+
agent_id = data.get('agent_id')
|
| 1786 |
+
prompt = data.get('prompt')
|
| 1787 |
+
platform = data.get('platform') # 可选:twitter/reddit/None
|
| 1788 |
+
timeout = data.get('timeout', 60)
|
| 1789 |
+
|
| 1790 |
+
if not simulation_id:
|
| 1791 |
+
return jsonify({
|
| 1792 |
+
"success": False,
|
| 1793 |
+
"error": "请提供 simulation_id"
|
| 1794 |
+
}), 400
|
| 1795 |
+
|
| 1796 |
+
if agent_id is None:
|
| 1797 |
+
return jsonify({
|
| 1798 |
+
"success": False,
|
| 1799 |
+
"error": "请提供 agent_id"
|
| 1800 |
+
}), 400
|
| 1801 |
+
|
| 1802 |
+
if not prompt:
|
| 1803 |
+
return jsonify({
|
| 1804 |
+
"success": False,
|
| 1805 |
+
"error": "请提供 prompt(采访问题)"
|
| 1806 |
+
}), 400
|
| 1807 |
+
|
| 1808 |
+
# 验证platform参数
|
| 1809 |
+
if platform and platform not in ("twitter", "reddit"):
|
| 1810 |
+
return jsonify({
|
| 1811 |
+
"success": False,
|
| 1812 |
+
"error": "platform 参数只能是 'twitter' 或 'reddit'"
|
| 1813 |
+
}), 400
|
| 1814 |
+
|
| 1815 |
+
# 检查环境状态
|
| 1816 |
+
if not SimulationRunner.check_env_alive(simulation_id):
|
| 1817 |
+
return jsonify({
|
| 1818 |
+
"success": False,
|
| 1819 |
+
"error": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。"
|
| 1820 |
+
}), 400
|
| 1821 |
+
|
| 1822 |
+
result = SimulationRunner.interview_agent(
|
| 1823 |
+
simulation_id=simulation_id,
|
| 1824 |
+
agent_id=agent_id,
|
| 1825 |
+
prompt=prompt,
|
| 1826 |
+
platform=platform,
|
| 1827 |
+
timeout=timeout
|
| 1828 |
+
)
|
| 1829 |
+
|
| 1830 |
+
return jsonify({
|
| 1831 |
+
"success": result.get("success", False),
|
| 1832 |
+
"data": result
|
| 1833 |
+
})
|
| 1834 |
+
|
| 1835 |
+
except ValueError as e:
|
| 1836 |
+
return jsonify({
|
| 1837 |
+
"success": False,
|
| 1838 |
+
"error": str(e)
|
| 1839 |
+
}), 400
|
| 1840 |
+
|
| 1841 |
+
except TimeoutError as e:
|
| 1842 |
+
return jsonify({
|
| 1843 |
+
"success": False,
|
| 1844 |
+
"error": f"等待Interview响应超时: {str(e)}"
|
| 1845 |
+
}), 504
|
| 1846 |
+
|
| 1847 |
+
except Exception as e:
|
| 1848 |
+
logger.error(f"Interview失败: {str(e)}")
|
| 1849 |
+
return jsonify({
|
| 1850 |
+
"success": False,
|
| 1851 |
+
"error": str(e),
|
| 1852 |
+
"traceback": traceback.format_exc()
|
| 1853 |
+
}), 500
|
| 1854 |
+
|
| 1855 |
+
|
| 1856 |
+
@simulation_bp.route('/interview/batch', methods=['POST'])
|
| 1857 |
+
def interview_agents_batch():
|
| 1858 |
+
"""
|
| 1859 |
+
批量采访多个Agent
|
| 1860 |
+
|
| 1861 |
+
注意:此功能需要模拟环境处于运行状态
|
| 1862 |
+
|
| 1863 |
+
请求(JSON):
|
| 1864 |
+
{
|
| 1865 |
+
"simulation_id": "sim_xxxx", // 必填,模拟ID
|
| 1866 |
+
"interviews": [ // 必填,采访列表
|
| 1867 |
+
{
|
| 1868 |
+
"agent_id": 0,
|
| 1869 |
+
"prompt": "你对A有什么看法?",
|
| 1870 |
+
"platform": "twitter" // 可选,指定该Agent的采访平台
|
| 1871 |
+
},
|
| 1872 |
+
{
|
| 1873 |
+
"agent_id": 1,
|
| 1874 |
+
"prompt": "你对B有什么看法?" // 不指定platform则使用默认值
|
| 1875 |
+
}
|
| 1876 |
+
],
|
| 1877 |
+
"platform": "reddit", // 可选,默认平台(被每项的platform覆盖)
|
| 1878 |
+
// 不指定时:双平台模拟每个Agent同时采访两个平台
|
| 1879 |
+
"timeout": 120 // 可选,超时时间(秒),默认120
|
| 1880 |
+
}
|
| 1881 |
+
|
| 1882 |
+
返回:
|
| 1883 |
+
{
|
| 1884 |
+
"success": true,
|
| 1885 |
+
"data": {
|
| 1886 |
+
"interviews_count": 2,
|
| 1887 |
+
"result": {
|
| 1888 |
+
"interviews_count": 4,
|
| 1889 |
+
"results": {
|
| 1890 |
+
"twitter_0": {"agent_id": 0, "response": "...", "platform": "twitter"},
|
| 1891 |
+
"reddit_0": {"agent_id": 0, "response": "...", "platform": "reddit"},
|
| 1892 |
+
"twitter_1": {"agent_id": 1, "response": "...", "platform": "twitter"},
|
| 1893 |
+
"reddit_1": {"agent_id": 1, "response": "...", "platform": "reddit"}
|
| 1894 |
+
}
|
| 1895 |
+
},
|
| 1896 |
+
"timestamp": "2025-12-08T10:00:01"
|
| 1897 |
+
}
|
| 1898 |
+
}
|
| 1899 |
+
"""
|
| 1900 |
+
try:
|
| 1901 |
+
data = request.get_json() or {}
|
| 1902 |
+
|
| 1903 |
+
simulation_id = data.get('simulation_id')
|
| 1904 |
+
interviews = data.get('interviews')
|
| 1905 |
+
platform = data.get('platform') # 可选:twitter/reddit/None
|
| 1906 |
+
timeout = data.get('timeout', 120)
|
| 1907 |
+
|
| 1908 |
+
if not simulation_id:
|
| 1909 |
+
return jsonify({
|
| 1910 |
+
"success": False,
|
| 1911 |
+
"error": "请提供 simulation_id"
|
| 1912 |
+
}), 400
|
| 1913 |
+
|
| 1914 |
+
if not interviews or not isinstance(interviews, list):
|
| 1915 |
+
return jsonify({
|
| 1916 |
+
"success": False,
|
| 1917 |
+
"error": "请提供 interviews(采访列表)"
|
| 1918 |
+
}), 400
|
| 1919 |
+
|
| 1920 |
+
# 验证platform参数
|
| 1921 |
+
if platform and platform not in ("twitter", "reddit"):
|
| 1922 |
+
return jsonify({
|
| 1923 |
+
"success": False,
|
| 1924 |
+
"error": "platform 参数只能是 'twitter' 或 'reddit'"
|
| 1925 |
+
}), 400
|
| 1926 |
+
|
| 1927 |
+
# 验证每个采访项
|
| 1928 |
+
for i, interview in enumerate(interviews):
|
| 1929 |
+
if 'agent_id' not in interview:
|
| 1930 |
+
return jsonify({
|
| 1931 |
+
"success": False,
|
| 1932 |
+
"error": f"采访列表第{i+1}项缺少 agent_id"
|
| 1933 |
+
}), 400
|
| 1934 |
+
if 'prompt' not in interview:
|
| 1935 |
+
return jsonify({
|
| 1936 |
+
"success": False,
|
| 1937 |
+
"error": f"采访列表第{i+1}项缺少 prompt"
|
| 1938 |
+
}), 400
|
| 1939 |
+
# 验证每项的platform(如果有)
|
| 1940 |
+
item_platform = interview.get('platform')
|
| 1941 |
+
if item_platform and item_platform not in ("twitter", "reddit"):
|
| 1942 |
+
return jsonify({
|
| 1943 |
+
"success": False,
|
| 1944 |
+
"error": f"采访列表第{i+1}项的platform只能是 'twitter' 或 'reddit'"
|
| 1945 |
+
}), 400
|
| 1946 |
+
|
| 1947 |
+
# 检查环境状态
|
| 1948 |
+
if not SimulationRunner.check_env_alive(simulation_id):
|
| 1949 |
+
return jsonify({
|
| 1950 |
+
"success": False,
|
| 1951 |
+
"error": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。"
|
| 1952 |
+
}), 400
|
| 1953 |
+
|
| 1954 |
+
result = SimulationRunner.interview_agents_batch(
|
| 1955 |
+
simulation_id=simulation_id,
|
| 1956 |
+
interviews=interviews,
|
| 1957 |
+
platform=platform,
|
| 1958 |
+
timeout=timeout
|
| 1959 |
+
)
|
| 1960 |
+
|
| 1961 |
+
return jsonify({
|
| 1962 |
+
"success": result.get("success", False),
|
| 1963 |
+
"data": result
|
| 1964 |
+
})
|
| 1965 |
+
|
| 1966 |
+
except ValueError as e:
|
| 1967 |
+
return jsonify({
|
| 1968 |
+
"success": False,
|
| 1969 |
+
"error": str(e)
|
| 1970 |
+
}), 400
|
| 1971 |
+
|
| 1972 |
+
except TimeoutError as e:
|
| 1973 |
+
return jsonify({
|
| 1974 |
+
"success": False,
|
| 1975 |
+
"error": f"等待批量Interview响应超时: {str(e)}"
|
| 1976 |
+
}), 504
|
| 1977 |
+
|
| 1978 |
+
except Exception as e:
|
| 1979 |
+
logger.error(f"批量Interview失败: {str(e)}")
|
| 1980 |
+
return jsonify({
|
| 1981 |
+
"success": False,
|
| 1982 |
+
"error": str(e),
|
| 1983 |
+
"traceback": traceback.format_exc()
|
| 1984 |
+
}), 500
|
| 1985 |
+
|
| 1986 |
+
|
| 1987 |
+
@simulation_bp.route('/interview/all', methods=['POST'])
|
| 1988 |
+
def interview_all_agents():
|
| 1989 |
+
"""
|
| 1990 |
+
全局采访 - 使用相同问题采访所有Agent
|
| 1991 |
+
|
| 1992 |
+
注意:此功能需要模拟环境处于运行状态
|
| 1993 |
+
|
| 1994 |
+
请求(JSON):
|
| 1995 |
+
{
|
| 1996 |
+
"simulation_id": "sim_xxxx", // 必填,模拟ID
|
| 1997 |
+
"prompt": "你对这件事整体有什么看法?", // 必填,采访问题(所有Agent使用相同问题)
|
| 1998 |
+
"platform": "reddit", // 可选,指定平台(twitter/reddit)
|
| 1999 |
+
// 不指定时:双平台模拟每个Agent同时采访两个平台
|
| 2000 |
+
"timeout": 180 // 可选,超时时间(秒),默认180
|
| 2001 |
+
}
|
| 2002 |
+
|
| 2003 |
+
返回:
|
| 2004 |
+
{
|
| 2005 |
+
"success": true,
|
| 2006 |
+
"data": {
|
| 2007 |
+
"interviews_count": 50,
|
| 2008 |
+
"result": {
|
| 2009 |
+
"interviews_count": 100,
|
| 2010 |
+
"results": {
|
| 2011 |
+
"twitter_0": {"agent_id": 0, "response": "...", "platform": "twitter"},
|
| 2012 |
+
"reddit_0": {"agent_id": 0, "response": "...", "platform": "reddit"},
|
| 2013 |
+
...
|
| 2014 |
+
}
|
| 2015 |
+
},
|
| 2016 |
+
"timestamp": "2025-12-08T10:00:01"
|
| 2017 |
+
}
|
| 2018 |
+
}
|
| 2019 |
+
"""
|
| 2020 |
+
try:
|
| 2021 |
+
data = request.get_json() or {}
|
| 2022 |
+
|
| 2023 |
+
simulation_id = data.get('simulation_id')
|
| 2024 |
+
prompt = data.get('prompt')
|
| 2025 |
+
platform = data.get('platform') # 可选:twitter/reddit/None
|
| 2026 |
+
timeout = data.get('timeout', 180)
|
| 2027 |
+
|
| 2028 |
+
if not simulation_id:
|
| 2029 |
+
return jsonify({
|
| 2030 |
+
"success": False,
|
| 2031 |
+
"error": "请提供 simulation_id"
|
| 2032 |
+
}), 400
|
| 2033 |
+
|
| 2034 |
+
if not prompt:
|
| 2035 |
+
return jsonify({
|
| 2036 |
+
"success": False,
|
| 2037 |
+
"error": "请提供 prompt(采访问题)"
|
| 2038 |
+
}), 400
|
| 2039 |
+
|
| 2040 |
+
# 验证platform参数
|
| 2041 |
+
if platform and platform not in ("twitter", "reddit"):
|
| 2042 |
+
return jsonify({
|
| 2043 |
+
"success": False,
|
| 2044 |
+
"error": "platform 参数只能是 'twitter' 或 'reddit'"
|
| 2045 |
+
}), 400
|
| 2046 |
+
|
| 2047 |
+
# 检查环境状态
|
| 2048 |
+
if not SimulationRunner.check_env_alive(simulation_id):
|
| 2049 |
+
return jsonify({
|
| 2050 |
+
"success": False,
|
| 2051 |
+
"error": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。"
|
| 2052 |
+
}), 400
|
| 2053 |
+
|
| 2054 |
+
result = SimulationRunner.interview_all_agents(
|
| 2055 |
+
simulation_id=simulation_id,
|
| 2056 |
+
prompt=prompt,
|
| 2057 |
+
platform=platform,
|
| 2058 |
+
timeout=timeout
|
| 2059 |
+
)
|
| 2060 |
+
|
| 2061 |
+
return jsonify({
|
| 2062 |
+
"success": result.get("success", False),
|
| 2063 |
+
"data": result
|
| 2064 |
+
})
|
| 2065 |
+
|
| 2066 |
+
except ValueError as e:
|
| 2067 |
+
return jsonify({
|
| 2068 |
+
"success": False,
|
| 2069 |
+
"error": str(e)
|
| 2070 |
+
}), 400
|
| 2071 |
+
|
| 2072 |
+
except TimeoutError as e:
|
| 2073 |
+
return jsonify({
|
| 2074 |
+
"success": False,
|
| 2075 |
+
"error": f"等待全局Interview响应超时: {str(e)}"
|
| 2076 |
+
}), 504
|
| 2077 |
+
|
| 2078 |
+
except Exception as e:
|
| 2079 |
+
logger.error(f"全局Interview失败: {str(e)}")
|
| 2080 |
+
return jsonify({
|
| 2081 |
+
"success": False,
|
| 2082 |
+
"error": str(e),
|
| 2083 |
+
"traceback": traceback.format_exc()
|
| 2084 |
+
}), 500
|
| 2085 |
+
|
| 2086 |
+
|
| 2087 |
+
@simulation_bp.route('/interview/history', methods=['POST'])
|
| 2088 |
+
def get_interview_history():
|
| 2089 |
+
"""
|
| 2090 |
+
获取Interview历史记录
|
| 2091 |
+
|
| 2092 |
+
从模拟数据库中读取所有Interview记录
|
| 2093 |
+
|
| 2094 |
+
请求(JSON):
|
| 2095 |
+
{
|
| 2096 |
+
"simulation_id": "sim_xxxx", // 必填,模拟ID
|
| 2097 |
+
"platform": "reddit", // 可选,平台类型(reddit/twitter),默认reddit
|
| 2098 |
+
"agent_id": 0, // 可选,过滤Agent ID
|
| 2099 |
+
"limit": 100 // 可选,返回数量,默认100
|
| 2100 |
+
}
|
| 2101 |
+
|
| 2102 |
+
返回:
|
| 2103 |
+
{
|
| 2104 |
+
"success": true,
|
| 2105 |
+
"data": {
|
| 2106 |
+
"count": 10,
|
| 2107 |
+
"history": [
|
| 2108 |
+
{
|
| 2109 |
+
"agent_id": 0,
|
| 2110 |
+
"response": "我认为...",
|
| 2111 |
+
"prompt": "你对这件事有什么看法?",
|
| 2112 |
+
"timestamp": "2025-12-08T10:00:00",
|
| 2113 |
+
"platform": "reddit"
|
| 2114 |
+
},
|
| 2115 |
+
...
|
| 2116 |
+
]
|
| 2117 |
+
}
|
| 2118 |
+
}
|
| 2119 |
+
"""
|
| 2120 |
+
try:
|
| 2121 |
+
data = request.get_json() or {}
|
| 2122 |
+
|
| 2123 |
+
simulation_id = data.get('simulation_id')
|
| 2124 |
+
platform = data.get('platform', 'reddit')
|
| 2125 |
+
agent_id = data.get('agent_id')
|
| 2126 |
+
limit = data.get('limit', 100)
|
| 2127 |
+
|
| 2128 |
+
if not simulation_id:
|
| 2129 |
+
return jsonify({
|
| 2130 |
+
"success": False,
|
| 2131 |
+
"error": "请提供 simulation_id"
|
| 2132 |
+
}), 400
|
| 2133 |
+
|
| 2134 |
+
history = SimulationRunner.get_interview_history(
|
| 2135 |
+
simulation_id=simulation_id,
|
| 2136 |
+
platform=platform,
|
| 2137 |
+
agent_id=agent_id,
|
| 2138 |
+
limit=limit
|
| 2139 |
+
)
|
| 2140 |
+
|
| 2141 |
+
return jsonify({
|
| 2142 |
+
"success": True,
|
| 2143 |
+
"data": {
|
| 2144 |
+
"count": len(history),
|
| 2145 |
+
"history": history
|
| 2146 |
+
}
|
| 2147 |
+
})
|
| 2148 |
+
|
| 2149 |
+
except Exception as e:
|
| 2150 |
+
logger.error(f"获取Interview历史失败: {str(e)}")
|
| 2151 |
+
return jsonify({
|
| 2152 |
+
"success": False,
|
| 2153 |
+
"error": str(e),
|
| 2154 |
+
"traceback": traceback.format_exc()
|
| 2155 |
+
}), 500
|
| 2156 |
+
|
| 2157 |
+
|
| 2158 |
+
@simulation_bp.route('/env-status', methods=['POST'])
|
| 2159 |
+
def get_env_status():
|
| 2160 |
+
"""
|
| 2161 |
+
获取模拟环境状态
|
| 2162 |
+
|
| 2163 |
+
检查模拟环境是否存活(可以接收Interview命令)
|
| 2164 |
+
|
| 2165 |
+
请求(JSON):
|
| 2166 |
+
{
|
| 2167 |
+
"simulation_id": "sim_xxxx" // 必填,模拟ID
|
| 2168 |
+
}
|
| 2169 |
+
|
| 2170 |
+
返回:
|
| 2171 |
+
{
|
| 2172 |
+
"success": true,
|
| 2173 |
+
"data": {
|
| 2174 |
+
"simulation_id": "sim_xxxx",
|
| 2175 |
+
"env_alive": true,
|
| 2176 |
+
"twitter_available": true,
|
| 2177 |
+
"reddit_available": true,
|
| 2178 |
+
"message": "环境正在运行,可以接收Interview命令"
|
| 2179 |
+
}
|
| 2180 |
+
}
|
| 2181 |
+
"""
|
| 2182 |
+
try:
|
| 2183 |
+
data = request.get_json() or {}
|
| 2184 |
+
|
| 2185 |
+
simulation_id = data.get('simulation_id')
|
| 2186 |
+
|
| 2187 |
+
if not simulation_id:
|
| 2188 |
+
return jsonify({
|
| 2189 |
+
"success": False,
|
| 2190 |
+
"error": "请提供 simulation_id"
|
| 2191 |
+
}), 400
|
| 2192 |
+
|
| 2193 |
+
env_alive = SimulationRunner.check_env_alive(simulation_id)
|
| 2194 |
+
|
| 2195 |
+
# 获取更详细的状态信息
|
| 2196 |
+
env_status = SimulationRunner.get_env_status_detail(simulation_id)
|
| 2197 |
+
|
| 2198 |
+
if env_alive:
|
| 2199 |
+
message = "环境正在运行,可以接收Interview命令"
|
| 2200 |
+
else:
|
| 2201 |
+
message = "环境未运行或已关闭"
|
| 2202 |
+
|
| 2203 |
+
return jsonify({
|
| 2204 |
+
"success": True,
|
| 2205 |
+
"data": {
|
| 2206 |
+
"simulation_id": simulation_id,
|
| 2207 |
+
"env_alive": env_alive,
|
| 2208 |
+
"twitter_available": env_status.get("twitter_available", False),
|
| 2209 |
+
"reddit_available": env_status.get("reddit_available", False),
|
| 2210 |
+
"message": message
|
| 2211 |
+
}
|
| 2212 |
+
})
|
| 2213 |
+
|
| 2214 |
+
except Exception as e:
|
| 2215 |
+
logger.error(f"获取环境状态失败: {str(e)}")
|
| 2216 |
+
return jsonify({
|
| 2217 |
+
"success": False,
|
| 2218 |
+
"error": str(e),
|
| 2219 |
+
"traceback": traceback.format_exc()
|
| 2220 |
+
}), 500
|
| 2221 |
+
|
| 2222 |
+
|
| 2223 |
+
@simulation_bp.route('/close-env', methods=['POST'])
|
| 2224 |
+
def close_simulation_env():
|
| 2225 |
+
"""
|
| 2226 |
+
关闭模拟环境
|
| 2227 |
+
|
| 2228 |
+
向模拟发送关闭环境命令,使其优雅退出等待命令模式。
|
| 2229 |
+
|
| 2230 |
+
注意:这不同于 /stop 接口,/stop 会强制终止进程,
|
| 2231 |
+
而此接口会让模拟优雅地关闭环境并退出。
|
| 2232 |
+
|
| 2233 |
+
请求(JSON):
|
| 2234 |
+
{
|
| 2235 |
+
"simulation_id": "sim_xxxx", // 必填,模拟ID
|
| 2236 |
+
"timeout": 30 // 可选,超时时间(秒),默认30
|
| 2237 |
+
}
|
| 2238 |
+
|
| 2239 |
+
返回:
|
| 2240 |
+
{
|
| 2241 |
+
"success": true,
|
| 2242 |
+
"data": {
|
| 2243 |
+
"message": "环境关闭命令已发送",
|
| 2244 |
+
"result": {...},
|
| 2245 |
+
"timestamp": "2025-12-08T10:00:01"
|
| 2246 |
+
}
|
| 2247 |
+
}
|
| 2248 |
+
"""
|
| 2249 |
+
try:
|
| 2250 |
+
data = request.get_json() or {}
|
| 2251 |
+
|
| 2252 |
+
simulation_id = data.get('simulation_id')
|
| 2253 |
+
timeout = data.get('timeout', 30)
|
| 2254 |
+
|
| 2255 |
+
if not simulation_id:
|
| 2256 |
+
return jsonify({
|
| 2257 |
+
"success": False,
|
| 2258 |
+
"error": "请提供 simulation_id"
|
| 2259 |
+
}), 400
|
| 2260 |
+
|
| 2261 |
+
result = SimulationRunner.close_simulation_env(
|
| 2262 |
+
simulation_id=simulation_id,
|
| 2263 |
+
timeout=timeout
|
| 2264 |
+
)
|
| 2265 |
+
|
| 2266 |
+
# 更新模拟状态
|
| 2267 |
+
manager = SimulationManager()
|
| 2268 |
+
state = manager.get_simulation(simulation_id)
|
| 2269 |
+
if state:
|
| 2270 |
+
state.status = SimulationStatus.COMPLETED
|
| 2271 |
+
manager._save_simulation_state(state)
|
| 2272 |
+
|
| 2273 |
+
return jsonify({
|
| 2274 |
+
"success": result.get("success", False),
|
| 2275 |
+
"data": result
|
| 2276 |
+
})
|
| 2277 |
+
|
| 2278 |
+
except ValueError as e:
|
| 2279 |
+
return jsonify({
|
| 2280 |
+
"success": False,
|
| 2281 |
+
"error": str(e)
|
| 2282 |
+
}), 400
|
| 2283 |
+
|
| 2284 |
+
except Exception as e:
|
| 2285 |
+
logger.error(f"关闭环境失败: {str(e)}")
|
| 2286 |
+
return jsonify({
|
| 2287 |
+
"success": False,
|
| 2288 |
+
"error": str(e),
|
| 2289 |
+
"traceback": traceback.format_exc()
|
| 2290 |
+
}), 500
|
backend/app/services/__init__.py
CHANGED
|
@@ -28,6 +28,14 @@ from .zep_graph_memory_updater import (
|
|
| 28 |
ZepGraphMemoryManager,
|
| 29 |
AgentActivity
|
| 30 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
__all__ = [
|
| 33 |
'OntologyGenerator',
|
|
@@ -55,5 +63,11 @@ __all__ = [
|
|
| 55 |
'ZepGraphMemoryUpdater',
|
| 56 |
'ZepGraphMemoryManager',
|
| 57 |
'AgentActivity',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
]
|
| 59 |
|
|
|
|
| 28 |
ZepGraphMemoryManager,
|
| 29 |
AgentActivity
|
| 30 |
)
|
| 31 |
+
from .simulation_ipc import (
|
| 32 |
+
SimulationIPCClient,
|
| 33 |
+
SimulationIPCServer,
|
| 34 |
+
IPCCommand,
|
| 35 |
+
IPCResponse,
|
| 36 |
+
CommandType,
|
| 37 |
+
CommandStatus
|
| 38 |
+
)
|
| 39 |
|
| 40 |
__all__ = [
|
| 41 |
'OntologyGenerator',
|
|
|
|
| 63 |
'ZepGraphMemoryUpdater',
|
| 64 |
'ZepGraphMemoryManager',
|
| 65 |
'AgentActivity',
|
| 66 |
+
'SimulationIPCClient',
|
| 67 |
+
'SimulationIPCServer',
|
| 68 |
+
'IPCCommand',
|
| 69 |
+
'IPCResponse',
|
| 70 |
+
'CommandType',
|
| 71 |
+
'CommandStatus',
|
| 72 |
]
|
| 73 |
|
backend/app/services/simulation_ipc.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
模拟IPC通信模块
|
| 3 |
+
用于Flask后端和模拟脚本之间的进程间通信
|
| 4 |
+
|
| 5 |
+
通过文件系统实现简单的命令/响应模式:
|
| 6 |
+
1. Flask写入命令到 commands/ 目录
|
| 7 |
+
2. 模拟脚本轮询命令目录,执行命令并写入响应到 responses/ 目录
|
| 8 |
+
3. Flask轮询响应目录获取结果
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import json
|
| 13 |
+
import time
|
| 14 |
+
import uuid
|
| 15 |
+
from typing import Dict, Any, Optional, List
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
from enum import Enum
|
| 19 |
+
|
| 20 |
+
from ..utils.logger import get_logger
|
| 21 |
+
|
| 22 |
+
logger = get_logger('mirofish.simulation_ipc')
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class CommandType(str, Enum):
|
| 26 |
+
"""命令类型"""
|
| 27 |
+
INTERVIEW = "interview" # 单个Agent采访
|
| 28 |
+
BATCH_INTERVIEW = "batch_interview" # 批量采访
|
| 29 |
+
CLOSE_ENV = "close_env" # 关闭环境
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class CommandStatus(str, Enum):
|
| 33 |
+
"""命令状态"""
|
| 34 |
+
PENDING = "pending"
|
| 35 |
+
PROCESSING = "processing"
|
| 36 |
+
COMPLETED = "completed"
|
| 37 |
+
FAILED = "failed"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class IPCCommand:
|
| 42 |
+
"""IPC命令"""
|
| 43 |
+
command_id: str
|
| 44 |
+
command_type: CommandType
|
| 45 |
+
args: Dict[str, Any]
|
| 46 |
+
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
| 47 |
+
|
| 48 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 49 |
+
return {
|
| 50 |
+
"command_id": self.command_id,
|
| 51 |
+
"command_type": self.command_type.value,
|
| 52 |
+
"args": self.args,
|
| 53 |
+
"timestamp": self.timestamp
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
@classmethod
|
| 57 |
+
def from_dict(cls, data: Dict[str, Any]) -> 'IPCCommand':
|
| 58 |
+
return cls(
|
| 59 |
+
command_id=data["command_id"],
|
| 60 |
+
command_type=CommandType(data["command_type"]),
|
| 61 |
+
args=data.get("args", {}),
|
| 62 |
+
timestamp=data.get("timestamp", datetime.now().isoformat())
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class IPCResponse:
|
| 68 |
+
"""IPC响应"""
|
| 69 |
+
command_id: str
|
| 70 |
+
status: CommandStatus
|
| 71 |
+
result: Optional[Dict[str, Any]] = None
|
| 72 |
+
error: Optional[str] = None
|
| 73 |
+
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
| 74 |
+
|
| 75 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 76 |
+
return {
|
| 77 |
+
"command_id": self.command_id,
|
| 78 |
+
"status": self.status.value,
|
| 79 |
+
"result": self.result,
|
| 80 |
+
"error": self.error,
|
| 81 |
+
"timestamp": self.timestamp
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
@classmethod
|
| 85 |
+
def from_dict(cls, data: Dict[str, Any]) -> 'IPCResponse':
|
| 86 |
+
return cls(
|
| 87 |
+
command_id=data["command_id"],
|
| 88 |
+
status=CommandStatus(data["status"]),
|
| 89 |
+
result=data.get("result"),
|
| 90 |
+
error=data.get("error"),
|
| 91 |
+
timestamp=data.get("timestamp", datetime.now().isoformat())
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class SimulationIPCClient:
|
| 96 |
+
"""
|
| 97 |
+
模拟IPC客户端(Flask端使用)
|
| 98 |
+
|
| 99 |
+
用于向模拟进程发送命令并等待响应
|
| 100 |
+
"""
|
| 101 |
+
|
| 102 |
+
def __init__(self, simulation_dir: str):
|
| 103 |
+
"""
|
| 104 |
+
初始化IPC客户端
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
simulation_dir: 模拟数据目录
|
| 108 |
+
"""
|
| 109 |
+
self.simulation_dir = simulation_dir
|
| 110 |
+
self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
|
| 111 |
+
self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
|
| 112 |
+
|
| 113 |
+
# 确保目录存在
|
| 114 |
+
os.makedirs(self.commands_dir, exist_ok=True)
|
| 115 |
+
os.makedirs(self.responses_dir, exist_ok=True)
|
| 116 |
+
|
| 117 |
+
def send_command(
|
| 118 |
+
self,
|
| 119 |
+
command_type: CommandType,
|
| 120 |
+
args: Dict[str, Any],
|
| 121 |
+
timeout: float = 60.0,
|
| 122 |
+
poll_interval: float = 0.5
|
| 123 |
+
) -> IPCResponse:
|
| 124 |
+
"""
|
| 125 |
+
发送命令并等待响应
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
command_type: 命令类型
|
| 129 |
+
args: 命令参数
|
| 130 |
+
timeout: 超时时间(秒)
|
| 131 |
+
poll_interval: 轮询间隔(秒)
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
IPCResponse
|
| 135 |
+
|
| 136 |
+
Raises:
|
| 137 |
+
TimeoutError: 等待响应超时
|
| 138 |
+
"""
|
| 139 |
+
command_id = str(uuid.uuid4())
|
| 140 |
+
command = IPCCommand(
|
| 141 |
+
command_id=command_id,
|
| 142 |
+
command_type=command_type,
|
| 143 |
+
args=args
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# 写入命令文件
|
| 147 |
+
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
|
| 148 |
+
with open(command_file, 'w', encoding='utf-8') as f:
|
| 149 |
+
json.dump(command.to_dict(), f, ensure_ascii=False, indent=2)
|
| 150 |
+
|
| 151 |
+
logger.info(f"发送IPC命令: {command_type.value}, command_id={command_id}")
|
| 152 |
+
|
| 153 |
+
# 等待响应
|
| 154 |
+
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
|
| 155 |
+
start_time = time.time()
|
| 156 |
+
|
| 157 |
+
while time.time() - start_time < timeout:
|
| 158 |
+
if os.path.exists(response_file):
|
| 159 |
+
try:
|
| 160 |
+
with open(response_file, 'r', encoding='utf-8') as f:
|
| 161 |
+
response_data = json.load(f)
|
| 162 |
+
response = IPCResponse.from_dict(response_data)
|
| 163 |
+
|
| 164 |
+
# 清理命令和响应文件
|
| 165 |
+
try:
|
| 166 |
+
os.remove(command_file)
|
| 167 |
+
os.remove(response_file)
|
| 168 |
+
except OSError:
|
| 169 |
+
pass
|
| 170 |
+
|
| 171 |
+
logger.info(f"收到IPC响应: command_id={command_id}, status={response.status.value}")
|
| 172 |
+
return response
|
| 173 |
+
except (json.JSONDecodeError, KeyError) as e:
|
| 174 |
+
logger.warning(f"解析响应失败: {e}")
|
| 175 |
+
|
| 176 |
+
time.sleep(poll_interval)
|
| 177 |
+
|
| 178 |
+
# 超时
|
| 179 |
+
logger.error(f"等待IPC响应超时: command_id={command_id}")
|
| 180 |
+
|
| 181 |
+
# 清理命令文件
|
| 182 |
+
try:
|
| 183 |
+
os.remove(command_file)
|
| 184 |
+
except OSError:
|
| 185 |
+
pass
|
| 186 |
+
|
| 187 |
+
raise TimeoutError(f"等待命令响应超时 ({timeout}秒)")
|
| 188 |
+
|
| 189 |
+
def send_interview(
|
| 190 |
+
self,
|
| 191 |
+
agent_id: int,
|
| 192 |
+
prompt: str,
|
| 193 |
+
platform: str = None,
|
| 194 |
+
timeout: float = 60.0
|
| 195 |
+
) -> IPCResponse:
|
| 196 |
+
"""
|
| 197 |
+
发送单个Agent采访命令
|
| 198 |
+
|
| 199 |
+
Args:
|
| 200 |
+
agent_id: Agent ID
|
| 201 |
+
prompt: 采访问题
|
| 202 |
+
platform: 指定平台(可选)
|
| 203 |
+
- "twitter": 只采访Twitter平台
|
| 204 |
+
- "reddit": 只采访Reddit平台
|
| 205 |
+
- None: 双平台模拟时同时采访两个平台,单平台模拟时采访该平台
|
| 206 |
+
timeout: 超时时间
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
IPCResponse,result字段包含采访结果
|
| 210 |
+
"""
|
| 211 |
+
args = {
|
| 212 |
+
"agent_id": agent_id,
|
| 213 |
+
"prompt": prompt
|
| 214 |
+
}
|
| 215 |
+
if platform:
|
| 216 |
+
args["platform"] = platform
|
| 217 |
+
|
| 218 |
+
return self.send_command(
|
| 219 |
+
command_type=CommandType.INTERVIEW,
|
| 220 |
+
args=args,
|
| 221 |
+
timeout=timeout
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
def send_batch_interview(
|
| 225 |
+
self,
|
| 226 |
+
interviews: List[Dict[str, Any]],
|
| 227 |
+
platform: str = None,
|
| 228 |
+
timeout: float = 120.0
|
| 229 |
+
) -> IPCResponse:
|
| 230 |
+
"""
|
| 231 |
+
发送批量采访命令
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
interviews: 采访列表,每个元素包含 {"agent_id": int, "prompt": str, "platform": str(可选)}
|
| 235 |
+
platform: 默认平台(可选,会被每个采访项的platform覆盖)
|
| 236 |
+
- "twitter": 默认只采访Twitter平台
|
| 237 |
+
- "reddit": 默认只采访Reddit平台
|
| 238 |
+
- None: 双平台模拟时每个Agent同时采访两个平台
|
| 239 |
+
timeout: 超时时间
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
IPCResponse,result字段包含所有采访结果
|
| 243 |
+
"""
|
| 244 |
+
args = {"interviews": interviews}
|
| 245 |
+
if platform:
|
| 246 |
+
args["platform"] = platform
|
| 247 |
+
|
| 248 |
+
return self.send_command(
|
| 249 |
+
command_type=CommandType.BATCH_INTERVIEW,
|
| 250 |
+
args=args,
|
| 251 |
+
timeout=timeout
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
def send_close_env(self, timeout: float = 30.0) -> IPCResponse:
|
| 255 |
+
"""
|
| 256 |
+
发送关闭环境命令
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
timeout: 超时时间
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
IPCResponse
|
| 263 |
+
"""
|
| 264 |
+
return self.send_command(
|
| 265 |
+
command_type=CommandType.CLOSE_ENV,
|
| 266 |
+
args={},
|
| 267 |
+
timeout=timeout
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
def check_env_alive(self) -> bool:
|
| 271 |
+
"""
|
| 272 |
+
检查模拟环境是否存活
|
| 273 |
+
|
| 274 |
+
通过检查 env_status.json 文件来判断
|
| 275 |
+
"""
|
| 276 |
+
status_file = os.path.join(self.simulation_dir, "env_status.json")
|
| 277 |
+
if not os.path.exists(status_file):
|
| 278 |
+
return False
|
| 279 |
+
|
| 280 |
+
try:
|
| 281 |
+
with open(status_file, 'r', encoding='utf-8') as f:
|
| 282 |
+
status = json.load(f)
|
| 283 |
+
return status.get("status") == "alive"
|
| 284 |
+
except (json.JSONDecodeError, OSError):
|
| 285 |
+
return False
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
class SimulationIPCServer:
|
| 289 |
+
"""
|
| 290 |
+
模拟IPC服务器(模拟脚本端使用)
|
| 291 |
+
|
| 292 |
+
轮询命令目录,执行命令并返回响应
|
| 293 |
+
"""
|
| 294 |
+
|
| 295 |
+
def __init__(self, simulation_dir: str):
|
| 296 |
+
"""
|
| 297 |
+
初始化IPC服务器
|
| 298 |
+
|
| 299 |
+
Args:
|
| 300 |
+
simulation_dir: 模拟数据目录
|
| 301 |
+
"""
|
| 302 |
+
self.simulation_dir = simulation_dir
|
| 303 |
+
self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
|
| 304 |
+
self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
|
| 305 |
+
|
| 306 |
+
# 确保目录存在
|
| 307 |
+
os.makedirs(self.commands_dir, exist_ok=True)
|
| 308 |
+
os.makedirs(self.responses_dir, exist_ok=True)
|
| 309 |
+
|
| 310 |
+
# 环境状态
|
| 311 |
+
self._running = False
|
| 312 |
+
|
| 313 |
+
def start(self):
|
| 314 |
+
"""标记服务器为运行状态"""
|
| 315 |
+
self._running = True
|
| 316 |
+
self._update_env_status("alive")
|
| 317 |
+
|
| 318 |
+
def stop(self):
|
| 319 |
+
"""标记服务器为停止状态"""
|
| 320 |
+
self._running = False
|
| 321 |
+
self._update_env_status("stopped")
|
| 322 |
+
|
| 323 |
+
def _update_env_status(self, status: str):
|
| 324 |
+
"""更新环境状态文件"""
|
| 325 |
+
status_file = os.path.join(self.simulation_dir, "env_status.json")
|
| 326 |
+
with open(status_file, 'w', encoding='utf-8') as f:
|
| 327 |
+
json.dump({
|
| 328 |
+
"status": status,
|
| 329 |
+
"timestamp": datetime.now().isoformat()
|
| 330 |
+
}, f, ensure_ascii=False, indent=2)
|
| 331 |
+
|
| 332 |
+
def poll_commands(self) -> Optional[IPCCommand]:
|
| 333 |
+
"""
|
| 334 |
+
轮询命令目录,返回第一个待处理的命令
|
| 335 |
+
|
| 336 |
+
Returns:
|
| 337 |
+
IPCCommand 或 None
|
| 338 |
+
"""
|
| 339 |
+
if not os.path.exists(self.commands_dir):
|
| 340 |
+
return None
|
| 341 |
+
|
| 342 |
+
# 按时间排序获取命令文件
|
| 343 |
+
command_files = []
|
| 344 |
+
for filename in os.listdir(self.commands_dir):
|
| 345 |
+
if filename.endswith('.json'):
|
| 346 |
+
filepath = os.path.join(self.commands_dir, filename)
|
| 347 |
+
command_files.append((filepath, os.path.getmtime(filepath)))
|
| 348 |
+
|
| 349 |
+
command_files.sort(key=lambda x: x[1])
|
| 350 |
+
|
| 351 |
+
for filepath, _ in command_files:
|
| 352 |
+
try:
|
| 353 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 354 |
+
data = json.load(f)
|
| 355 |
+
return IPCCommand.from_dict(data)
|
| 356 |
+
except (json.JSONDecodeError, KeyError, OSError) as e:
|
| 357 |
+
logger.warning(f"读取命令文件失败: {filepath}, {e}")
|
| 358 |
+
continue
|
| 359 |
+
|
| 360 |
+
return None
|
| 361 |
+
|
| 362 |
+
def send_response(self, response: IPCResponse):
|
| 363 |
+
"""
|
| 364 |
+
发送响应
|
| 365 |
+
|
| 366 |
+
Args:
|
| 367 |
+
response: IPC响应
|
| 368 |
+
"""
|
| 369 |
+
response_file = os.path.join(self.responses_dir, f"{response.command_id}.json")
|
| 370 |
+
with open(response_file, 'w', encoding='utf-8') as f:
|
| 371 |
+
json.dump(response.to_dict(), f, ensure_ascii=False, indent=2)
|
| 372 |
+
|
| 373 |
+
# 删除命令文件
|
| 374 |
+
command_file = os.path.join(self.commands_dir, f"{response.command_id}.json")
|
| 375 |
+
try:
|
| 376 |
+
os.remove(command_file)
|
| 377 |
+
except OSError:
|
| 378 |
+
pass
|
| 379 |
+
|
| 380 |
+
def send_success(self, command_id: str, result: Dict[str, Any]):
|
| 381 |
+
"""发送成功响应"""
|
| 382 |
+
self.send_response(IPCResponse(
|
| 383 |
+
command_id=command_id,
|
| 384 |
+
status=CommandStatus.COMPLETED,
|
| 385 |
+
result=result
|
| 386 |
+
))
|
| 387 |
+
|
| 388 |
+
def send_error(self, command_id: str, error: str):
|
| 389 |
+
"""发送错误响应"""
|
| 390 |
+
self.send_response(IPCResponse(
|
| 391 |
+
command_id=command_id,
|
| 392 |
+
status=CommandStatus.FAILED,
|
| 393 |
+
error=error
|
| 394 |
+
))
|
backend/app/services/simulation_runner.py
CHANGED
|
@@ -12,7 +12,7 @@ import threading
|
|
| 12 |
import subprocess
|
| 13 |
import signal
|
| 14 |
import atexit
|
| 15 |
-
from typing import Dict, Any, List, Optional
|
| 16 |
from dataclasses import dataclass, field
|
| 17 |
from datetime import datetime
|
| 18 |
from enum import Enum
|
|
@@ -21,6 +21,7 @@ from queue import Queue
|
|
| 21 |
from ..config import Config
|
| 22 |
from ..utils.logger import get_logger
|
| 23 |
from .zep_graph_memory_updater import ZepGraphMemoryManager
|
|
|
|
| 24 |
|
| 25 |
logger = get_logger('mirofish.simulation_runner')
|
| 26 |
|
|
@@ -989,4 +990,365 @@ class SimulationRunner:
|
|
| 989 |
if process.poll() is None:
|
| 990 |
running.append(sim_id)
|
| 991 |
return running
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 992 |
|
|
|
|
| 12 |
import subprocess
|
| 13 |
import signal
|
| 14 |
import atexit
|
| 15 |
+
from typing import Dict, Any, List, Optional, Union
|
| 16 |
from dataclasses import dataclass, field
|
| 17 |
from datetime import datetime
|
| 18 |
from enum import Enum
|
|
|
|
| 21 |
from ..config import Config
|
| 22 |
from ..utils.logger import get_logger
|
| 23 |
from .zep_graph_memory_updater import ZepGraphMemoryManager
|
| 24 |
+
from .simulation_ipc import SimulationIPCClient, CommandType, IPCResponse
|
| 25 |
|
| 26 |
logger = get_logger('mirofish.simulation_runner')
|
| 27 |
|
|
|
|
| 990 |
if process.poll() is None:
|
| 991 |
running.append(sim_id)
|
| 992 |
return running
|
| 993 |
+
|
| 994 |
+
# ============== Interview 功能 ==============
|
| 995 |
+
|
| 996 |
+
@classmethod
|
| 997 |
+
def check_env_alive(cls, simulation_id: str) -> bool:
|
| 998 |
+
"""
|
| 999 |
+
检查模拟环境是否存活(可以接收Interview命令)
|
| 1000 |
+
|
| 1001 |
+
Args:
|
| 1002 |
+
simulation_id: 模拟ID
|
| 1003 |
+
|
| 1004 |
+
Returns:
|
| 1005 |
+
True 表示环境存活,False 表示环境已关闭
|
| 1006 |
+
"""
|
| 1007 |
+
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
| 1008 |
+
if not os.path.exists(sim_dir):
|
| 1009 |
+
return False
|
| 1010 |
+
|
| 1011 |
+
ipc_client = SimulationIPCClient(sim_dir)
|
| 1012 |
+
return ipc_client.check_env_alive()
|
| 1013 |
+
|
| 1014 |
+
@classmethod
|
| 1015 |
+
def get_env_status_detail(cls, simulation_id: str) -> Dict[str, Any]:
|
| 1016 |
+
"""
|
| 1017 |
+
获取模拟环境的详细状态信息
|
| 1018 |
+
|
| 1019 |
+
Args:
|
| 1020 |
+
simulation_id: 模拟ID
|
| 1021 |
+
|
| 1022 |
+
Returns:
|
| 1023 |
+
状态详情字典,包含 status, twitter_available, reddit_available, timestamp
|
| 1024 |
+
"""
|
| 1025 |
+
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
| 1026 |
+
status_file = os.path.join(sim_dir, "env_status.json")
|
| 1027 |
+
|
| 1028 |
+
default_status = {
|
| 1029 |
+
"status": "stopped",
|
| 1030 |
+
"twitter_available": False,
|
| 1031 |
+
"reddit_available": False,
|
| 1032 |
+
"timestamp": None
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
if not os.path.exists(status_file):
|
| 1036 |
+
return default_status
|
| 1037 |
+
|
| 1038 |
+
try:
|
| 1039 |
+
with open(status_file, 'r', encoding='utf-8') as f:
|
| 1040 |
+
status = json.load(f)
|
| 1041 |
+
return {
|
| 1042 |
+
"status": status.get("status", "stopped"),
|
| 1043 |
+
"twitter_available": status.get("twitter_available", False),
|
| 1044 |
+
"reddit_available": status.get("reddit_available", False),
|
| 1045 |
+
"timestamp": status.get("timestamp")
|
| 1046 |
+
}
|
| 1047 |
+
except (json.JSONDecodeError, OSError):
|
| 1048 |
+
return default_status
|
| 1049 |
+
|
| 1050 |
+
@classmethod
|
| 1051 |
+
def interview_agent(
|
| 1052 |
+
cls,
|
| 1053 |
+
simulation_id: str,
|
| 1054 |
+
agent_id: int,
|
| 1055 |
+
prompt: str,
|
| 1056 |
+
platform: str = None,
|
| 1057 |
+
timeout: float = 60.0
|
| 1058 |
+
) -> Dict[str, Any]:
|
| 1059 |
+
"""
|
| 1060 |
+
采访单个Agent
|
| 1061 |
+
|
| 1062 |
+
Args:
|
| 1063 |
+
simulation_id: 模拟ID
|
| 1064 |
+
agent_id: Agent ID
|
| 1065 |
+
prompt: 采访问题
|
| 1066 |
+
platform: 指定平台(可选)
|
| 1067 |
+
- "twitter": 只采访Twitter平台
|
| 1068 |
+
- "reddit": 只采访Reddit平台
|
| 1069 |
+
- None: 双平台模拟时同时采访两个平台,返回整合结果
|
| 1070 |
+
timeout: 超时时间(秒)
|
| 1071 |
+
|
| 1072 |
+
Returns:
|
| 1073 |
+
采访结果字典
|
| 1074 |
+
|
| 1075 |
+
Raises:
|
| 1076 |
+
ValueError: 模拟不存在或环境未运行
|
| 1077 |
+
TimeoutError: 等待响应超时
|
| 1078 |
+
"""
|
| 1079 |
+
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
| 1080 |
+
if not os.path.exists(sim_dir):
|
| 1081 |
+
raise ValueError(f"模拟不存在: {simulation_id}")
|
| 1082 |
+
|
| 1083 |
+
ipc_client = SimulationIPCClient(sim_dir)
|
| 1084 |
+
|
| 1085 |
+
if not ipc_client.check_env_alive():
|
| 1086 |
+
raise ValueError(f"模拟环境未运行或已关闭,无法执行Interview: {simulation_id}")
|
| 1087 |
+
|
| 1088 |
+
logger.info(f"发送Interview命令: simulation_id={simulation_id}, agent_id={agent_id}, platform={platform}")
|
| 1089 |
+
|
| 1090 |
+
response = ipc_client.send_interview(
|
| 1091 |
+
agent_id=agent_id,
|
| 1092 |
+
prompt=prompt,
|
| 1093 |
+
platform=platform,
|
| 1094 |
+
timeout=timeout
|
| 1095 |
+
)
|
| 1096 |
+
|
| 1097 |
+
if response.status.value == "completed":
|
| 1098 |
+
return {
|
| 1099 |
+
"success": True,
|
| 1100 |
+
"agent_id": agent_id,
|
| 1101 |
+
"prompt": prompt,
|
| 1102 |
+
"result": response.result,
|
| 1103 |
+
"timestamp": response.timestamp
|
| 1104 |
+
}
|
| 1105 |
+
else:
|
| 1106 |
+
return {
|
| 1107 |
+
"success": False,
|
| 1108 |
+
"agent_id": agent_id,
|
| 1109 |
+
"prompt": prompt,
|
| 1110 |
+
"error": response.error,
|
| 1111 |
+
"timestamp": response.timestamp
|
| 1112 |
+
}
|
| 1113 |
+
|
| 1114 |
+
@classmethod
|
| 1115 |
+
def interview_agents_batch(
|
| 1116 |
+
cls,
|
| 1117 |
+
simulation_id: str,
|
| 1118 |
+
interviews: List[Dict[str, Any]],
|
| 1119 |
+
platform: str = None,
|
| 1120 |
+
timeout: float = 120.0
|
| 1121 |
+
) -> Dict[str, Any]:
|
| 1122 |
+
"""
|
| 1123 |
+
批量采访多个Agent
|
| 1124 |
+
|
| 1125 |
+
Args:
|
| 1126 |
+
simulation_id: 模拟ID
|
| 1127 |
+
interviews: 采访列表,每个元素包含 {"agent_id": int, "prompt": str, "platform": str(可选)}
|
| 1128 |
+
platform: 默认平台(可选,会被每个采访项的platform覆盖)
|
| 1129 |
+
- "twitter": 默认只采访Twitter平台
|
| 1130 |
+
- "reddit": 默认只采访Reddit平台
|
| 1131 |
+
- None: 双平台模拟时每个Agent同时采访两个平台
|
| 1132 |
+
timeout: 超时时间(秒)
|
| 1133 |
+
|
| 1134 |
+
Returns:
|
| 1135 |
+
批量采访结果字典
|
| 1136 |
+
|
| 1137 |
+
Raises:
|
| 1138 |
+
ValueError: 模拟不存在或环境未运行
|
| 1139 |
+
TimeoutError: 等待响应超时
|
| 1140 |
+
"""
|
| 1141 |
+
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
| 1142 |
+
if not os.path.exists(sim_dir):
|
| 1143 |
+
raise ValueError(f"模拟不存在: {simulation_id}")
|
| 1144 |
+
|
| 1145 |
+
ipc_client = SimulationIPCClient(sim_dir)
|
| 1146 |
+
|
| 1147 |
+
if not ipc_client.check_env_alive():
|
| 1148 |
+
raise ValueError(f"模拟环境未运行或已关闭,无法执行Interview: {simulation_id}")
|
| 1149 |
+
|
| 1150 |
+
logger.info(f"发送批量Interview命令: simulation_id={simulation_id}, count={len(interviews)}, platform={platform}")
|
| 1151 |
+
|
| 1152 |
+
response = ipc_client.send_batch_interview(
|
| 1153 |
+
interviews=interviews,
|
| 1154 |
+
platform=platform,
|
| 1155 |
+
timeout=timeout
|
| 1156 |
+
)
|
| 1157 |
+
|
| 1158 |
+
if response.status.value == "completed":
|
| 1159 |
+
return {
|
| 1160 |
+
"success": True,
|
| 1161 |
+
"interviews_count": len(interviews),
|
| 1162 |
+
"result": response.result,
|
| 1163 |
+
"timestamp": response.timestamp
|
| 1164 |
+
}
|
| 1165 |
+
else:
|
| 1166 |
+
return {
|
| 1167 |
+
"success": False,
|
| 1168 |
+
"interviews_count": len(interviews),
|
| 1169 |
+
"error": response.error,
|
| 1170 |
+
"timestamp": response.timestamp
|
| 1171 |
+
}
|
| 1172 |
+
|
| 1173 |
+
@classmethod
|
| 1174 |
+
def interview_all_agents(
|
| 1175 |
+
cls,
|
| 1176 |
+
simulation_id: str,
|
| 1177 |
+
prompt: str,
|
| 1178 |
+
platform: str = None,
|
| 1179 |
+
timeout: float = 180.0
|
| 1180 |
+
) -> Dict[str, Any]:
|
| 1181 |
+
"""
|
| 1182 |
+
采访所有Agent(全局采访)
|
| 1183 |
+
|
| 1184 |
+
使用相同的问题采访模拟中的所有Agent
|
| 1185 |
+
|
| 1186 |
+
Args:
|
| 1187 |
+
simulation_id: 模拟ID
|
| 1188 |
+
prompt: 采访问题(所有Agent使用相同问题)
|
| 1189 |
+
platform: 指定平台(可选)
|
| 1190 |
+
- "twitter": 只采访Twitter平台
|
| 1191 |
+
- "reddit": 只采访Reddit平台
|
| 1192 |
+
- None: 双平台模拟时每个Agent同时采访两个平台
|
| 1193 |
+
timeout: 超时时间(秒)
|
| 1194 |
+
|
| 1195 |
+
Returns:
|
| 1196 |
+
全局采访结果字典
|
| 1197 |
+
"""
|
| 1198 |
+
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
| 1199 |
+
if not os.path.exists(sim_dir):
|
| 1200 |
+
raise ValueError(f"模拟不存在: {simulation_id}")
|
| 1201 |
+
|
| 1202 |
+
# 从配置文件获取所有Agent信息
|
| 1203 |
+
config_path = os.path.join(sim_dir, "simulation_config.json")
|
| 1204 |
+
if not os.path.exists(config_path):
|
| 1205 |
+
raise ValueError(f"模拟配置不存在: {simulation_id}")
|
| 1206 |
+
|
| 1207 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
| 1208 |
+
config = json.load(f)
|
| 1209 |
+
|
| 1210 |
+
agent_configs = config.get("agent_configs", [])
|
| 1211 |
+
if not agent_configs:
|
| 1212 |
+
raise ValueError(f"模拟配置中没有Agent: {simulation_id}")
|
| 1213 |
+
|
| 1214 |
+
# 构建批量采访列表
|
| 1215 |
+
interviews = []
|
| 1216 |
+
for agent_config in agent_configs:
|
| 1217 |
+
agent_id = agent_config.get("agent_id")
|
| 1218 |
+
if agent_id is not None:
|
| 1219 |
+
interviews.append({
|
| 1220 |
+
"agent_id": agent_id,
|
| 1221 |
+
"prompt": prompt
|
| 1222 |
+
})
|
| 1223 |
+
|
| 1224 |
+
logger.info(f"发送全局Interview命令: simulation_id={simulation_id}, agent_count={len(interviews)}, platform={platform}")
|
| 1225 |
+
|
| 1226 |
+
return cls.interview_agents_batch(
|
| 1227 |
+
simulation_id=simulation_id,
|
| 1228 |
+
interviews=interviews,
|
| 1229 |
+
platform=platform,
|
| 1230 |
+
timeout=timeout
|
| 1231 |
+
)
|
| 1232 |
+
|
| 1233 |
+
@classmethod
|
| 1234 |
+
def close_simulation_env(
|
| 1235 |
+
cls,
|
| 1236 |
+
simulation_id: str,
|
| 1237 |
+
timeout: float = 30.0
|
| 1238 |
+
) -> Dict[str, Any]:
|
| 1239 |
+
"""
|
| 1240 |
+
关闭模拟环境(而不是停止模拟进程)
|
| 1241 |
+
|
| 1242 |
+
向模拟发送关闭环境命令,使其优雅退出等待命令模式
|
| 1243 |
+
|
| 1244 |
+
Args:
|
| 1245 |
+
simulation_id: 模拟ID
|
| 1246 |
+
timeout: 超时时间(秒)
|
| 1247 |
+
|
| 1248 |
+
Returns:
|
| 1249 |
+
操作结果字典
|
| 1250 |
+
"""
|
| 1251 |
+
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
| 1252 |
+
if not os.path.exists(sim_dir):
|
| 1253 |
+
raise ValueError(f"模拟不存在: {simulation_id}")
|
| 1254 |
+
|
| 1255 |
+
ipc_client = SimulationIPCClient(sim_dir)
|
| 1256 |
+
|
| 1257 |
+
if not ipc_client.check_env_alive():
|
| 1258 |
+
return {
|
| 1259 |
+
"success": True,
|
| 1260 |
+
"message": "环境已经关闭"
|
| 1261 |
+
}
|
| 1262 |
+
|
| 1263 |
+
logger.info(f"发送关闭环境命令: simulation_id={simulation_id}")
|
| 1264 |
+
|
| 1265 |
+
try:
|
| 1266 |
+
response = ipc_client.send_close_env(timeout=timeout)
|
| 1267 |
+
|
| 1268 |
+
return {
|
| 1269 |
+
"success": response.status.value == "completed",
|
| 1270 |
+
"message": "环境��闭命令已发送",
|
| 1271 |
+
"result": response.result,
|
| 1272 |
+
"timestamp": response.timestamp
|
| 1273 |
+
}
|
| 1274 |
+
except TimeoutError:
|
| 1275 |
+
# 超时可能是因为环境正在关闭
|
| 1276 |
+
return {
|
| 1277 |
+
"success": True,
|
| 1278 |
+
"message": "环境关闭命令已发送(等待响应超时,环境可能正在关闭)"
|
| 1279 |
+
}
|
| 1280 |
+
|
| 1281 |
+
@classmethod
|
| 1282 |
+
def get_interview_history(
|
| 1283 |
+
cls,
|
| 1284 |
+
simulation_id: str,
|
| 1285 |
+
platform: str = "reddit",
|
| 1286 |
+
agent_id: Optional[int] = None,
|
| 1287 |
+
limit: int = 100
|
| 1288 |
+
) -> List[Dict[str, Any]]:
|
| 1289 |
+
"""
|
| 1290 |
+
获取Interview历史记录(从数据库读取)
|
| 1291 |
+
|
| 1292 |
+
Args:
|
| 1293 |
+
simulation_id: 模拟ID
|
| 1294 |
+
platform: 平台类型(reddit/twitter)
|
| 1295 |
+
agent_id: 过滤Agent ID(可选)
|
| 1296 |
+
limit: 返回数量限制
|
| 1297 |
+
|
| 1298 |
+
Returns:
|
| 1299 |
+
Interview历史记录列表
|
| 1300 |
+
"""
|
| 1301 |
+
import sqlite3
|
| 1302 |
+
|
| 1303 |
+
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
|
| 1304 |
+
db_path = os.path.join(sim_dir, f"{platform}_simulation.db")
|
| 1305 |
+
|
| 1306 |
+
if not os.path.exists(db_path):
|
| 1307 |
+
return []
|
| 1308 |
+
|
| 1309 |
+
results = []
|
| 1310 |
+
|
| 1311 |
+
try:
|
| 1312 |
+
conn = sqlite3.connect(db_path)
|
| 1313 |
+
cursor = conn.cursor()
|
| 1314 |
+
|
| 1315 |
+
# 构建查询
|
| 1316 |
+
# 注意:ActionType.INTERVIEW.value 应该是字符串形式
|
| 1317 |
+
if agent_id is not None:
|
| 1318 |
+
cursor.execute("""
|
| 1319 |
+
SELECT user_id, info, created_at
|
| 1320 |
+
FROM trace
|
| 1321 |
+
WHERE action = 'interview' AND user_id = ?
|
| 1322 |
+
ORDER BY created_at DESC
|
| 1323 |
+
LIMIT ?
|
| 1324 |
+
""", (agent_id, limit))
|
| 1325 |
+
else:
|
| 1326 |
+
cursor.execute("""
|
| 1327 |
+
SELECT user_id, info, created_at
|
| 1328 |
+
FROM trace
|
| 1329 |
+
WHERE action = 'interview'
|
| 1330 |
+
ORDER BY created_at DESC
|
| 1331 |
+
LIMIT ?
|
| 1332 |
+
""", (limit,))
|
| 1333 |
+
|
| 1334 |
+
for user_id, info_json, created_at in cursor.fetchall():
|
| 1335 |
+
try:
|
| 1336 |
+
info = json.loads(info_json) if info_json else {}
|
| 1337 |
+
except json.JSONDecodeError:
|
| 1338 |
+
info = {"raw": info_json}
|
| 1339 |
+
|
| 1340 |
+
results.append({
|
| 1341 |
+
"agent_id": user_id,
|
| 1342 |
+
"response": info.get("response", info),
|
| 1343 |
+
"prompt": info.get("prompt", ""),
|
| 1344 |
+
"timestamp": created_at,
|
| 1345 |
+
"platform": platform
|
| 1346 |
+
})
|
| 1347 |
+
|
| 1348 |
+
conn.close()
|
| 1349 |
+
|
| 1350 |
+
except Exception as e:
|
| 1351 |
+
logger.error(f"读取Interview历史失败: {e}")
|
| 1352 |
+
|
| 1353 |
+
return results
|
| 1354 |
|
backend/scripts/run_parallel_simulation.py
CHANGED
|
@@ -2,8 +2,18 @@
|
|
| 2 |
OASIS 双平台并行模拟预设脚本
|
| 3 |
同时运行Twitter和Reddit模拟,读取相同的配置文件
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
使用方式:
|
| 6 |
python run_parallel_simulation.py --config simulation_config.json
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
日志结构:
|
| 9 |
sim_xxx/
|
|
@@ -119,7 +129,7 @@ except ImportError as e:
|
|
| 119 |
sys.exit(1)
|
| 120 |
|
| 121 |
|
| 122 |
-
# Twitter可用动作
|
| 123 |
TWITTER_ACTIONS = [
|
| 124 |
ActionType.CREATE_POST,
|
| 125 |
ActionType.LIKE_POST,
|
|
@@ -129,7 +139,7 @@ TWITTER_ACTIONS = [
|
|
| 129 |
ActionType.QUOTE_POST,
|
| 130 |
]
|
| 131 |
|
| 132 |
-
# Reddit可用动作
|
| 133 |
REDDIT_ACTIONS = [
|
| 134 |
ActionType.LIKE_POST,
|
| 135 |
ActionType.DISLIKE_POST,
|
|
@@ -147,6 +157,405 @@ REDDIT_ACTIONS = [
|
|
| 147 |
]
|
| 148 |
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
def load_config(config_path: str) -> Dict[str, Any]:
|
| 151 |
"""加载配置文件"""
|
| 152 |
with open(config_path, 'r', encoding='utf-8') as f:
|
|
@@ -398,13 +807,21 @@ def get_active_agents_for_round(
|
|
| 398 |
return active_agents
|
| 399 |
|
| 400 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
async def run_twitter_simulation(
|
| 402 |
config: Dict[str, Any],
|
| 403 |
simulation_dir: str,
|
| 404 |
action_logger: Optional[PlatformActionLogger] = None,
|
| 405 |
main_logger: Optional[SimulationLogManager] = None,
|
| 406 |
max_rounds: Optional[int] = None
|
| 407 |
-
):
|
| 408 |
"""运行Twitter模拟
|
| 409 |
|
| 410 |
Args:
|
|
@@ -413,7 +830,12 @@ async def run_twitter_simulation(
|
|
| 413 |
action_logger: 动作日志记录器
|
| 414 |
main_logger: 主日志管理器
|
| 415 |
max_rounds: 最大模拟轮数(可选,用于截断过长的模拟)
|
|
|
|
|
|
|
|
|
|
| 416 |
"""
|
|
|
|
|
|
|
| 417 |
def log_info(msg):
|
| 418 |
if main_logger:
|
| 419 |
main_logger.info(f"[Twitter] {msg}")
|
|
@@ -428,9 +850,9 @@ async def run_twitter_simulation(
|
|
| 428 |
profile_path = os.path.join(simulation_dir, "twitter_profiles.csv")
|
| 429 |
if not os.path.exists(profile_path):
|
| 430 |
log_info(f"错误: Profile文件不存在: {profile_path}")
|
| 431 |
-
return
|
| 432 |
|
| 433 |
-
agent_graph = await generate_twitter_agent_graph(
|
| 434 |
profile_path=profile_path,
|
| 435 |
model=model,
|
| 436 |
available_actions=TWITTER_ACTIONS,
|
|
@@ -439,7 +861,7 @@ async def run_twitter_simulation(
|
|
| 439 |
# 从配置文件获取 Agent 真实名称映射(使用 entity_name 而非默认的 Agent_X)
|
| 440 |
agent_names = get_agent_names_from_config(config)
|
| 441 |
# 如果配置中没有某个 agent,则使用 OASIS 的默认名称
|
| 442 |
-
for agent_id, agent in agent_graph.get_agents():
|
| 443 |
if agent_id not in agent_names:
|
| 444 |
agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}')
|
| 445 |
|
|
@@ -447,14 +869,14 @@ async def run_twitter_simulation(
|
|
| 447 |
if os.path.exists(db_path):
|
| 448 |
os.remove(db_path)
|
| 449 |
|
| 450 |
-
env = oasis.make(
|
| 451 |
-
agent_graph=agent_graph,
|
| 452 |
platform=oasis.DefaultPlatformType.TWITTER,
|
| 453 |
database_path=db_path,
|
| 454 |
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
|
| 455 |
)
|
| 456 |
|
| 457 |
-
await env.reset()
|
| 458 |
log_info("环境已启动")
|
| 459 |
|
| 460 |
if action_logger:
|
|
@@ -478,7 +900,7 @@ async def run_twitter_simulation(
|
|
| 478 |
agent_id = post.get("poster_agent_id", 0)
|
| 479 |
content = post.get("content", "")
|
| 480 |
try:
|
| 481 |
-
agent = env.agent_graph.get_agent(agent_id)
|
| 482 |
initial_actions[agent] = ManualAction(
|
| 483 |
action_type=ActionType.CREATE_POST,
|
| 484 |
action_args={"content": content}
|
|
@@ -498,7 +920,7 @@ async def run_twitter_simulation(
|
|
| 498 |
pass
|
| 499 |
|
| 500 |
if initial_actions:
|
| 501 |
-
await env.step(initial_actions)
|
| 502 |
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
|
| 503 |
|
| 504 |
# 记录 round 0 结束
|
|
@@ -526,7 +948,7 @@ async def run_twitter_simulation(
|
|
| 526 |
simulated_day = simulated_minutes // (60 * 24) + 1
|
| 527 |
|
| 528 |
active_agents = get_active_agents_for_round(
|
| 529 |
-
env, config, simulated_hour, round_num
|
| 530 |
)
|
| 531 |
|
| 532 |
# 无论是否有活跃agent,都记录round开始
|
|
@@ -540,7 +962,7 @@ async def run_twitter_simulation(
|
|
| 540 |
continue
|
| 541 |
|
| 542 |
actions = {agent: LLMAction() for _, agent in active_agents}
|
| 543 |
-
await env.step(actions)
|
| 544 |
|
| 545 |
# 从数据库获取实际执行的动作并记录
|
| 546 |
actual_actions, last_rowid = fetch_new_actions_from_db(
|
|
@@ -567,13 +989,16 @@ async def run_twitter_simulation(
|
|
| 567 |
progress = (round_num + 1) / total_rounds * 100
|
| 568 |
log_info(f"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)")
|
| 569 |
|
| 570 |
-
|
| 571 |
|
| 572 |
if action_logger:
|
| 573 |
action_logger.log_simulation_end(total_rounds, total_actions)
|
| 574 |
|
|
|
|
| 575 |
elapsed = (datetime.now() - start_time).total_seconds()
|
| 576 |
-
log_info(f"模拟完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}")
|
|
|
|
|
|
|
| 577 |
|
| 578 |
|
| 579 |
async def run_reddit_simulation(
|
|
@@ -582,7 +1007,7 @@ async def run_reddit_simulation(
|
|
| 582 |
action_logger: Optional[PlatformActionLogger] = None,
|
| 583 |
main_logger: Optional[SimulationLogManager] = None,
|
| 584 |
max_rounds: Optional[int] = None
|
| 585 |
-
):
|
| 586 |
"""运行Reddit模拟
|
| 587 |
|
| 588 |
Args:
|
|
@@ -591,7 +1016,12 @@ async def run_reddit_simulation(
|
|
| 591 |
action_logger: 动作日志记录器
|
| 592 |
main_logger: 主日志管理器
|
| 593 |
max_rounds: 最大模拟轮数(可选,用于截断过长的模拟)
|
|
|
|
|
|
|
|
|
|
| 594 |
"""
|
|
|
|
|
|
|
| 595 |
def log_info(msg):
|
| 596 |
if main_logger:
|
| 597 |
main_logger.info(f"[Reddit] {msg}")
|
|
@@ -605,9 +1035,9 @@ async def run_reddit_simulation(
|
|
| 605 |
profile_path = os.path.join(simulation_dir, "reddit_profiles.json")
|
| 606 |
if not os.path.exists(profile_path):
|
| 607 |
log_info(f"错误: Profile文件不存在: {profile_path}")
|
| 608 |
-
return
|
| 609 |
|
| 610 |
-
agent_graph = await generate_reddit_agent_graph(
|
| 611 |
profile_path=profile_path,
|
| 612 |
model=model,
|
| 613 |
available_actions=REDDIT_ACTIONS,
|
|
@@ -616,7 +1046,7 @@ async def run_reddit_simulation(
|
|
| 616 |
# 从配置文件获取 Agent 真实名称映射(使用 entity_name 而非默认的 Agent_X)
|
| 617 |
agent_names = get_agent_names_from_config(config)
|
| 618 |
# 如果配置中没有某个 agent,则使用 OASIS 的默认名称
|
| 619 |
-
for agent_id, agent in agent_graph.get_agents():
|
| 620 |
if agent_id not in agent_names:
|
| 621 |
agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}')
|
| 622 |
|
|
@@ -624,14 +1054,14 @@ async def run_reddit_simulation(
|
|
| 624 |
if os.path.exists(db_path):
|
| 625 |
os.remove(db_path)
|
| 626 |
|
| 627 |
-
env = oasis.make(
|
| 628 |
-
agent_graph=agent_graph,
|
| 629 |
platform=oasis.DefaultPlatformType.REDDIT,
|
| 630 |
database_path=db_path,
|
| 631 |
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
|
| 632 |
)
|
| 633 |
|
| 634 |
-
await env.reset()
|
| 635 |
log_info("环境已启动")
|
| 636 |
|
| 637 |
if action_logger:
|
|
@@ -655,7 +1085,7 @@ async def run_reddit_simulation(
|
|
| 655 |
agent_id = post.get("poster_agent_id", 0)
|
| 656 |
content = post.get("content", "")
|
| 657 |
try:
|
| 658 |
-
agent = env.agent_graph.get_agent(agent_id)
|
| 659 |
if agent in initial_actions:
|
| 660 |
if not isinstance(initial_actions[agent], list):
|
| 661 |
initial_actions[agent] = [initial_actions[agent]]
|
|
@@ -683,7 +1113,7 @@ async def run_reddit_simulation(
|
|
| 683 |
pass
|
| 684 |
|
| 685 |
if initial_actions:
|
| 686 |
-
await env.step(initial_actions)
|
| 687 |
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
|
| 688 |
|
| 689 |
# 记录 round 0 结束
|
|
@@ -711,7 +1141,7 @@ async def run_reddit_simulation(
|
|
| 711 |
simulated_day = simulated_minutes // (60 * 24) + 1
|
| 712 |
|
| 713 |
active_agents = get_active_agents_for_round(
|
| 714 |
-
env, config, simulated_hour, round_num
|
| 715 |
)
|
| 716 |
|
| 717 |
# 无论是否有活跃agent,都记录round开始
|
|
@@ -725,7 +1155,7 @@ async def run_reddit_simulation(
|
|
| 725 |
continue
|
| 726 |
|
| 727 |
actions = {agent: LLMAction() for _, agent in active_agents}
|
| 728 |
-
await env.step(actions)
|
| 729 |
|
| 730 |
# 从数据库获取实际执行的动作并记录
|
| 731 |
actual_actions, last_rowid = fetch_new_actions_from_db(
|
|
@@ -752,13 +1182,16 @@ async def run_reddit_simulation(
|
|
| 752 |
progress = (round_num + 1) / total_rounds * 100
|
| 753 |
log_info(f"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)")
|
| 754 |
|
| 755 |
-
|
| 756 |
|
| 757 |
if action_logger:
|
| 758 |
action_logger.log_simulation_end(total_rounds, total_actions)
|
| 759 |
|
|
|
|
| 760 |
elapsed = (datetime.now() - start_time).total_seconds()
|
| 761 |
-
log_info(f"模拟完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}")
|
|
|
|
|
|
|
| 762 |
|
| 763 |
|
| 764 |
async def main():
|
|
@@ -785,6 +1218,12 @@ async def main():
|
|
| 785 |
default=None,
|
| 786 |
help='最大模拟轮数(可选,用于截断过长的模拟)'
|
| 787 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
|
| 789 |
args = parser.parse_args()
|
| 790 |
|
|
@@ -794,6 +1233,7 @@ async def main():
|
|
| 794 |
|
| 795 |
config = load_config(args.config)
|
| 796 |
simulation_dir = os.path.dirname(args.config) or "."
|
|
|
|
| 797 |
|
| 798 |
# 初始化日志配置(禁用 OASIS 日志,清理旧文件)
|
| 799 |
init_logging_for_simulation(simulation_dir)
|
|
@@ -807,6 +1247,7 @@ async def main():
|
|
| 807 |
log_manager.info("OASIS 双平台并行模拟")
|
| 808 |
log_manager.info(f"配置文件: {args.config}")
|
| 809 |
log_manager.info(f"模拟ID: {config.get('simulation_id', 'unknown')}")
|
|
|
|
| 810 |
log_manager.info("=" * 60)
|
| 811 |
|
| 812 |
time_config = config.get("time_config", {})
|
|
@@ -832,20 +1273,70 @@ async def main():
|
|
| 832 |
|
| 833 |
start_time = datetime.now()
|
| 834 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 835 |
if args.twitter_only:
|
| 836 |
-
await run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds)
|
| 837 |
elif args.reddit_only:
|
| 838 |
-
await run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds)
|
| 839 |
else:
|
| 840 |
# 并行运行(每个平台使用独立的日志记录器)
|
| 841 |
-
await asyncio.gather(
|
| 842 |
run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds),
|
| 843 |
run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds),
|
| 844 |
)
|
|
|
|
| 845 |
|
| 846 |
total_elapsed = (datetime.now() - start_time).total_seconds()
|
| 847 |
log_manager.info("=" * 60)
|
| 848 |
-
log_manager.info(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 849 |
log_manager.info(f"日志文件:")
|
| 850 |
log_manager.info(f" - {os.path.join(simulation_dir, 'simulation.log')}")
|
| 851 |
log_manager.info(f" - {os.path.join(simulation_dir, 'twitter', 'actions.jsonl')}")
|
|
@@ -855,4 +1346,3 @@ async def main():
|
|
| 855 |
|
| 856 |
if __name__ == "__main__":
|
| 857 |
asyncio.run(main())
|
| 858 |
-
|
|
|
|
| 2 |
OASIS 双平台并行模拟预设脚本
|
| 3 |
同时运行Twitter和Reddit模拟,读取相同的配置文件
|
| 4 |
|
| 5 |
+
功能特性:
|
| 6 |
+
- 双平台(Twitter + Reddit)并行模拟
|
| 7 |
+
- 完成模拟后不立即关闭环境,进入等待命令模式
|
| 8 |
+
- 支持通过IPC接收Interview命令
|
| 9 |
+
- 支持单个Agent采访和批量采访
|
| 10 |
+
- 支持远程关闭环境命令
|
| 11 |
+
|
| 12 |
使用方式:
|
| 13 |
python run_parallel_simulation.py --config simulation_config.json
|
| 14 |
+
python run_parallel_simulation.py --config simulation_config.json --no-wait # 完成后立即关闭
|
| 15 |
+
python run_parallel_simulation.py --config simulation_config.json --twitter-only
|
| 16 |
+
python run_parallel_simulation.py --config simulation_config.json --reddit-only
|
| 17 |
|
| 18 |
日志结构:
|
| 19 |
sim_xxx/
|
|
|
|
| 129 |
sys.exit(1)
|
| 130 |
|
| 131 |
|
| 132 |
+
# Twitter可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发)
|
| 133 |
TWITTER_ACTIONS = [
|
| 134 |
ActionType.CREATE_POST,
|
| 135 |
ActionType.LIKE_POST,
|
|
|
|
| 139 |
ActionType.QUOTE_POST,
|
| 140 |
]
|
| 141 |
|
| 142 |
+
# Reddit可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发)
|
| 143 |
REDDIT_ACTIONS = [
|
| 144 |
ActionType.LIKE_POST,
|
| 145 |
ActionType.DISLIKE_POST,
|
|
|
|
| 157 |
]
|
| 158 |
|
| 159 |
|
| 160 |
+
# IPC相关常量
|
| 161 |
+
IPC_COMMANDS_DIR = "ipc_commands"
|
| 162 |
+
IPC_RESPONSES_DIR = "ipc_responses"
|
| 163 |
+
ENV_STATUS_FILE = "env_status.json"
|
| 164 |
+
|
| 165 |
+
class CommandType:
|
| 166 |
+
"""命令类型常量"""
|
| 167 |
+
INTERVIEW = "interview"
|
| 168 |
+
BATCH_INTERVIEW = "batch_interview"
|
| 169 |
+
CLOSE_ENV = "close_env"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class ParallelIPCHandler:
|
| 173 |
+
"""
|
| 174 |
+
双平台IPC命令处理器
|
| 175 |
+
|
| 176 |
+
管理两个平台的环境,处理Interview命令
|
| 177 |
+
"""
|
| 178 |
+
|
| 179 |
+
def __init__(
|
| 180 |
+
self,
|
| 181 |
+
simulation_dir: str,
|
| 182 |
+
twitter_env=None,
|
| 183 |
+
twitter_agent_graph=None,
|
| 184 |
+
reddit_env=None,
|
| 185 |
+
reddit_agent_graph=None
|
| 186 |
+
):
|
| 187 |
+
self.simulation_dir = simulation_dir
|
| 188 |
+
self.twitter_env = twitter_env
|
| 189 |
+
self.twitter_agent_graph = twitter_agent_graph
|
| 190 |
+
self.reddit_env = reddit_env
|
| 191 |
+
self.reddit_agent_graph = reddit_agent_graph
|
| 192 |
+
|
| 193 |
+
self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)
|
| 194 |
+
self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)
|
| 195 |
+
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
|
| 196 |
+
|
| 197 |
+
# 确保目录存在
|
| 198 |
+
os.makedirs(self.commands_dir, exist_ok=True)
|
| 199 |
+
os.makedirs(self.responses_dir, exist_ok=True)
|
| 200 |
+
|
| 201 |
+
def update_status(self, status: str):
|
| 202 |
+
"""更新环境状态"""
|
| 203 |
+
with open(self.status_file, 'w', encoding='utf-8') as f:
|
| 204 |
+
json.dump({
|
| 205 |
+
"status": status,
|
| 206 |
+
"twitter_available": self.twitter_env is not None,
|
| 207 |
+
"reddit_available": self.reddit_env is not None,
|
| 208 |
+
"timestamp": datetime.now().isoformat()
|
| 209 |
+
}, f, ensure_ascii=False, indent=2)
|
| 210 |
+
|
| 211 |
+
def poll_command(self) -> Optional[Dict[str, Any]]:
|
| 212 |
+
"""轮询获取待处理命令"""
|
| 213 |
+
if not os.path.exists(self.commands_dir):
|
| 214 |
+
return None
|
| 215 |
+
|
| 216 |
+
# 获取命令文件(按时间排序)
|
| 217 |
+
command_files = []
|
| 218 |
+
for filename in os.listdir(self.commands_dir):
|
| 219 |
+
if filename.endswith('.json'):
|
| 220 |
+
filepath = os.path.join(self.commands_dir, filename)
|
| 221 |
+
command_files.append((filepath, os.path.getmtime(filepath)))
|
| 222 |
+
|
| 223 |
+
command_files.sort(key=lambda x: x[1])
|
| 224 |
+
|
| 225 |
+
for filepath, _ in command_files:
|
| 226 |
+
try:
|
| 227 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 228 |
+
return json.load(f)
|
| 229 |
+
except (json.JSONDecodeError, OSError):
|
| 230 |
+
continue
|
| 231 |
+
|
| 232 |
+
return None
|
| 233 |
+
|
| 234 |
+
def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
|
| 235 |
+
"""发送响应"""
|
| 236 |
+
response = {
|
| 237 |
+
"command_id": command_id,
|
| 238 |
+
"status": status,
|
| 239 |
+
"result": result,
|
| 240 |
+
"error": error,
|
| 241 |
+
"timestamp": datetime.now().isoformat()
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
|
| 245 |
+
with open(response_file, 'w', encoding='utf-8') as f:
|
| 246 |
+
json.dump(response, f, ensure_ascii=False, indent=2)
|
| 247 |
+
|
| 248 |
+
# 删除命令文件
|
| 249 |
+
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
|
| 250 |
+
try:
|
| 251 |
+
os.remove(command_file)
|
| 252 |
+
except OSError:
|
| 253 |
+
pass
|
| 254 |
+
|
| 255 |
+
def _get_env_and_graph(self, platform: str):
|
| 256 |
+
"""
|
| 257 |
+
获取指定平台的环境和agent_graph
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
platform: 平台名称 ("twitter" 或 "reddit")
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
(env, agent_graph, platform_name) 或 (None, None, None)
|
| 264 |
+
"""
|
| 265 |
+
if platform == "twitter" and self.twitter_env:
|
| 266 |
+
return self.twitter_env, self.twitter_agent_graph, "twitter"
|
| 267 |
+
elif platform == "reddit" and self.reddit_env:
|
| 268 |
+
return self.reddit_env, self.reddit_agent_graph, "reddit"
|
| 269 |
+
else:
|
| 270 |
+
return None, None, None
|
| 271 |
+
|
| 272 |
+
async def _interview_single_platform(self, agent_id: int, prompt: str, platform: str) -> Dict[str, Any]:
|
| 273 |
+
"""
|
| 274 |
+
在单个平台上执行Interview
|
| 275 |
+
|
| 276 |
+
Returns:
|
| 277 |
+
包含结果的字典,或包含error的字典
|
| 278 |
+
"""
|
| 279 |
+
env, agent_graph, actual_platform = self._get_env_and_graph(platform)
|
| 280 |
+
|
| 281 |
+
if not env or not agent_graph:
|
| 282 |
+
return {"platform": platform, "error": f"{platform}平台不可用"}
|
| 283 |
+
|
| 284 |
+
try:
|
| 285 |
+
agent = agent_graph.get_agent(agent_id)
|
| 286 |
+
interview_action = ManualAction(
|
| 287 |
+
action_type=ActionType.INTERVIEW,
|
| 288 |
+
action_args={"prompt": prompt}
|
| 289 |
+
)
|
| 290 |
+
actions = {agent: interview_action}
|
| 291 |
+
await env.step(actions)
|
| 292 |
+
|
| 293 |
+
result = self._get_interview_result(agent_id, actual_platform)
|
| 294 |
+
result["platform"] = actual_platform
|
| 295 |
+
return result
|
| 296 |
+
|
| 297 |
+
except Exception as e:
|
| 298 |
+
return {"platform": platform, "error": str(e)}
|
| 299 |
+
|
| 300 |
+
async def handle_interview(self, command_id: str, agent_id: int, prompt: str, platform: str = None) -> bool:
|
| 301 |
+
"""
|
| 302 |
+
处理单个Agent采访命令
|
| 303 |
+
|
| 304 |
+
Args:
|
| 305 |
+
command_id: 命令ID
|
| 306 |
+
agent_id: Agent ID
|
| 307 |
+
prompt: 采访问题
|
| 308 |
+
platform: 指定平台(可选)
|
| 309 |
+
- "twitter": 只采访Twitter平台
|
| 310 |
+
- "reddit": 只采访Reddit平台
|
| 311 |
+
- None/不指定: 同时采访两个平台,返回整合结果
|
| 312 |
+
|
| 313 |
+
Returns:
|
| 314 |
+
True 表示成功,False 表示失败
|
| 315 |
+
"""
|
| 316 |
+
# 如果指定了平台,只采访该平台
|
| 317 |
+
if platform in ("twitter", "reddit"):
|
| 318 |
+
result = await self._interview_single_platform(agent_id, prompt, platform)
|
| 319 |
+
|
| 320 |
+
if "error" in result:
|
| 321 |
+
self.send_response(command_id, "failed", error=result["error"])
|
| 322 |
+
print(f" Interview失败: agent_id={agent_id}, platform={platform}, error={result['error']}")
|
| 323 |
+
return False
|
| 324 |
+
else:
|
| 325 |
+
self.send_response(command_id, "completed", result=result)
|
| 326 |
+
print(f" Interview完成: agent_id={agent_id}, platform={platform}")
|
| 327 |
+
return True
|
| 328 |
+
|
| 329 |
+
# 未指定平台:同时采访两个平台
|
| 330 |
+
if not self.twitter_env and not self.reddit_env:
|
| 331 |
+
self.send_response(command_id, "failed", error="没有可用的模拟环境")
|
| 332 |
+
return False
|
| 333 |
+
|
| 334 |
+
results = {
|
| 335 |
+
"agent_id": agent_id,
|
| 336 |
+
"prompt": prompt,
|
| 337 |
+
"platforms": {}
|
| 338 |
+
}
|
| 339 |
+
success_count = 0
|
| 340 |
+
|
| 341 |
+
# 并行采访两个平台
|
| 342 |
+
tasks = []
|
| 343 |
+
platforms_to_interview = []
|
| 344 |
+
|
| 345 |
+
if self.twitter_env:
|
| 346 |
+
tasks.append(self._interview_single_platform(agent_id, prompt, "twitter"))
|
| 347 |
+
platforms_to_interview.append("twitter")
|
| 348 |
+
|
| 349 |
+
if self.reddit_env:
|
| 350 |
+
tasks.append(self._interview_single_platform(agent_id, prompt, "reddit"))
|
| 351 |
+
platforms_to_interview.append("reddit")
|
| 352 |
+
|
| 353 |
+
# 并行执行
|
| 354 |
+
platform_results = await asyncio.gather(*tasks)
|
| 355 |
+
|
| 356 |
+
for platform_name, platform_result in zip(platforms_to_interview, platform_results):
|
| 357 |
+
results["platforms"][platform_name] = platform_result
|
| 358 |
+
if "error" not in platform_result:
|
| 359 |
+
success_count += 1
|
| 360 |
+
|
| 361 |
+
if success_count > 0:
|
| 362 |
+
self.send_response(command_id, "completed", result=results)
|
| 363 |
+
print(f" Interview完成: agent_id={agent_id}, 成功平台数={success_count}/{len(platforms_to_interview)}")
|
| 364 |
+
return True
|
| 365 |
+
else:
|
| 366 |
+
errors = [f"{p}: {r.get('error', '未知错误')}" for p, r in results["platforms"].items()]
|
| 367 |
+
self.send_response(command_id, "failed", error="; ".join(errors))
|
| 368 |
+
print(f" Interview失败: agent_id={agent_id}, 所有平台都失败")
|
| 369 |
+
return False
|
| 370 |
+
|
| 371 |
+
async def handle_batch_interview(self, command_id: str, interviews: List[Dict], platform: str = None) -> bool:
|
| 372 |
+
"""
|
| 373 |
+
处理批量采访命令
|
| 374 |
+
|
| 375 |
+
Args:
|
| 376 |
+
command_id: 命令ID
|
| 377 |
+
interviews: [{"agent_id": int, "prompt": str, "platform": str(optional)}, ...]
|
| 378 |
+
platform: 默认平台(可被每个interview项覆盖)
|
| 379 |
+
- "twitter": 只采访Twitter平台
|
| 380 |
+
- "reddit": 只采访Reddit平台
|
| 381 |
+
- None/不指定: 每个Agent同时采访两个平台
|
| 382 |
+
"""
|
| 383 |
+
# 按平台分组
|
| 384 |
+
twitter_interviews = []
|
| 385 |
+
reddit_interviews = []
|
| 386 |
+
both_platforms_interviews = [] # 需要同时采访两个平台的
|
| 387 |
+
|
| 388 |
+
for interview in interviews:
|
| 389 |
+
item_platform = interview.get("platform", platform)
|
| 390 |
+
if item_platform == "twitter":
|
| 391 |
+
twitter_interviews.append(interview)
|
| 392 |
+
elif item_platform == "reddit":
|
| 393 |
+
reddit_interviews.append(interview)
|
| 394 |
+
else:
|
| 395 |
+
# 未指定平台:两个平台都采访
|
| 396 |
+
both_platforms_interviews.append(interview)
|
| 397 |
+
|
| 398 |
+
# 把 both_platforms_interviews 拆分到两个平台
|
| 399 |
+
if both_platforms_interviews:
|
| 400 |
+
if self.twitter_env:
|
| 401 |
+
twitter_interviews.extend(both_platforms_interviews)
|
| 402 |
+
if self.reddit_env:
|
| 403 |
+
reddit_interviews.extend(both_platforms_interviews)
|
| 404 |
+
|
| 405 |
+
results = {}
|
| 406 |
+
|
| 407 |
+
# 处理Twitter平台的采访
|
| 408 |
+
if twitter_interviews and self.twitter_env:
|
| 409 |
+
try:
|
| 410 |
+
twitter_actions = {}
|
| 411 |
+
for interview in twitter_interviews:
|
| 412 |
+
agent_id = interview.get("agent_id")
|
| 413 |
+
prompt = interview.get("prompt", "")
|
| 414 |
+
try:
|
| 415 |
+
agent = self.twitter_agent_graph.get_agent(agent_id)
|
| 416 |
+
twitter_actions[agent] = ManualAction(
|
| 417 |
+
action_type=ActionType.INTERVIEW,
|
| 418 |
+
action_args={"prompt": prompt}
|
| 419 |
+
)
|
| 420 |
+
except Exception as e:
|
| 421 |
+
print(f" 警告: 无法获取Twitter Agent {agent_id}: {e}")
|
| 422 |
+
|
| 423 |
+
if twitter_actions:
|
| 424 |
+
await self.twitter_env.step(twitter_actions)
|
| 425 |
+
|
| 426 |
+
for interview in twitter_interviews:
|
| 427 |
+
agent_id = interview.get("agent_id")
|
| 428 |
+
result = self._get_interview_result(agent_id, "twitter")
|
| 429 |
+
result["platform"] = "twitter"
|
| 430 |
+
results[f"twitter_{agent_id}"] = result
|
| 431 |
+
except Exception as e:
|
| 432 |
+
print(f" Twitter批量Interview失败: {e}")
|
| 433 |
+
|
| 434 |
+
# 处理Reddit平台的采访
|
| 435 |
+
if reddit_interviews and self.reddit_env:
|
| 436 |
+
try:
|
| 437 |
+
reddit_actions = {}
|
| 438 |
+
for interview in reddit_interviews:
|
| 439 |
+
agent_id = interview.get("agent_id")
|
| 440 |
+
prompt = interview.get("prompt", "")
|
| 441 |
+
try:
|
| 442 |
+
agent = self.reddit_agent_graph.get_agent(agent_id)
|
| 443 |
+
reddit_actions[agent] = ManualAction(
|
| 444 |
+
action_type=ActionType.INTERVIEW,
|
| 445 |
+
action_args={"prompt": prompt}
|
| 446 |
+
)
|
| 447 |
+
except Exception as e:
|
| 448 |
+
print(f" 警告: 无法获取Reddit Agent {agent_id}: {e}")
|
| 449 |
+
|
| 450 |
+
if reddit_actions:
|
| 451 |
+
await self.reddit_env.step(reddit_actions)
|
| 452 |
+
|
| 453 |
+
for interview in reddit_interviews:
|
| 454 |
+
agent_id = interview.get("agent_id")
|
| 455 |
+
result = self._get_interview_result(agent_id, "reddit")
|
| 456 |
+
result["platform"] = "reddit"
|
| 457 |
+
results[f"reddit_{agent_id}"] = result
|
| 458 |
+
except Exception as e:
|
| 459 |
+
print(f" Reddit批量Interview失败: {e}")
|
| 460 |
+
|
| 461 |
+
if results:
|
| 462 |
+
self.send_response(command_id, "completed", result={
|
| 463 |
+
"interviews_count": len(results),
|
| 464 |
+
"results": results
|
| 465 |
+
})
|
| 466 |
+
print(f" 批量Interview完成: {len(results)} 个Agent")
|
| 467 |
+
return True
|
| 468 |
+
else:
|
| 469 |
+
self.send_response(command_id, "failed", error="没有成功的采访")
|
| 470 |
+
return False
|
| 471 |
+
|
| 472 |
+
def _get_interview_result(self, agent_id: int, platform: str) -> Dict[str, Any]:
|
| 473 |
+
"""从数据库获取最新的Interview结果"""
|
| 474 |
+
db_path = os.path.join(self.simulation_dir, f"{platform}_simulation.db")
|
| 475 |
+
|
| 476 |
+
result = {
|
| 477 |
+
"agent_id": agent_id,
|
| 478 |
+
"response": None,
|
| 479 |
+
"timestamp": None
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
if not os.path.exists(db_path):
|
| 483 |
+
return result
|
| 484 |
+
|
| 485 |
+
try:
|
| 486 |
+
conn = sqlite3.connect(db_path)
|
| 487 |
+
cursor = conn.cursor()
|
| 488 |
+
|
| 489 |
+
# 查询最新的Interview记录
|
| 490 |
+
cursor.execute("""
|
| 491 |
+
SELECT user_id, info, created_at
|
| 492 |
+
FROM trace
|
| 493 |
+
WHERE action = ? AND user_id = ?
|
| 494 |
+
ORDER BY created_at DESC
|
| 495 |
+
LIMIT 1
|
| 496 |
+
""", (ActionType.INTERVIEW.value, agent_id))
|
| 497 |
+
|
| 498 |
+
row = cursor.fetchone()
|
| 499 |
+
if row:
|
| 500 |
+
user_id, info_json, created_at = row
|
| 501 |
+
try:
|
| 502 |
+
info = json.loads(info_json) if info_json else {}
|
| 503 |
+
result["response"] = info.get("response", info)
|
| 504 |
+
result["timestamp"] = created_at
|
| 505 |
+
except json.JSONDecodeError:
|
| 506 |
+
result["response"] = info_json
|
| 507 |
+
|
| 508 |
+
conn.close()
|
| 509 |
+
|
| 510 |
+
except Exception as e:
|
| 511 |
+
print(f" 读取Interview结果失败: {e}")
|
| 512 |
+
|
| 513 |
+
return result
|
| 514 |
+
|
| 515 |
+
async def process_commands(self) -> bool:
|
| 516 |
+
"""
|
| 517 |
+
处理所有待处理命令
|
| 518 |
+
|
| 519 |
+
Returns:
|
| 520 |
+
True 表示继续运行,False 表示应该退出
|
| 521 |
+
"""
|
| 522 |
+
command = self.poll_command()
|
| 523 |
+
if not command:
|
| 524 |
+
return True
|
| 525 |
+
|
| 526 |
+
command_id = command.get("command_id")
|
| 527 |
+
command_type = command.get("command_type")
|
| 528 |
+
args = command.get("args", {})
|
| 529 |
+
|
| 530 |
+
print(f"\n收到IPC命令: {command_type}, id={command_id}")
|
| 531 |
+
|
| 532 |
+
if command_type == CommandType.INTERVIEW:
|
| 533 |
+
await self.handle_interview(
|
| 534 |
+
command_id,
|
| 535 |
+
args.get("agent_id", 0),
|
| 536 |
+
args.get("prompt", ""),
|
| 537 |
+
args.get("platform")
|
| 538 |
+
)
|
| 539 |
+
return True
|
| 540 |
+
|
| 541 |
+
elif command_type == CommandType.BATCH_INTERVIEW:
|
| 542 |
+
await self.handle_batch_interview(
|
| 543 |
+
command_id,
|
| 544 |
+
args.get("interviews", []),
|
| 545 |
+
args.get("platform")
|
| 546 |
+
)
|
| 547 |
+
return True
|
| 548 |
+
|
| 549 |
+
elif command_type == CommandType.CLOSE_ENV:
|
| 550 |
+
print("收到关闭环境命令")
|
| 551 |
+
self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
|
| 552 |
+
return False
|
| 553 |
+
|
| 554 |
+
else:
|
| 555 |
+
self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
|
| 556 |
+
return True
|
| 557 |
+
|
| 558 |
+
|
| 559 |
def load_config(config_path: str) -> Dict[str, Any]:
|
| 560 |
"""加载配置文件"""
|
| 561 |
with open(config_path, 'r', encoding='utf-8') as f:
|
|
|
|
| 807 |
return active_agents
|
| 808 |
|
| 809 |
|
| 810 |
+
class PlatformSimulation:
|
| 811 |
+
"""平台模拟结果容器"""
|
| 812 |
+
def __init__(self):
|
| 813 |
+
self.env = None
|
| 814 |
+
self.agent_graph = None
|
| 815 |
+
self.total_actions = 0
|
| 816 |
+
|
| 817 |
+
|
| 818 |
async def run_twitter_simulation(
|
| 819 |
config: Dict[str, Any],
|
| 820 |
simulation_dir: str,
|
| 821 |
action_logger: Optional[PlatformActionLogger] = None,
|
| 822 |
main_logger: Optional[SimulationLogManager] = None,
|
| 823 |
max_rounds: Optional[int] = None
|
| 824 |
+
) -> PlatformSimulation:
|
| 825 |
"""运行Twitter模拟
|
| 826 |
|
| 827 |
Args:
|
|
|
|
| 830 |
action_logger: 动作日志记录器
|
| 831 |
main_logger: 主日志管理器
|
| 832 |
max_rounds: 最大模拟轮数(可选,用于截断过长的模拟)
|
| 833 |
+
|
| 834 |
+
Returns:
|
| 835 |
+
PlatformSimulation: 包含env和agent_graph的结果对象
|
| 836 |
"""
|
| 837 |
+
result = PlatformSimulation()
|
| 838 |
+
|
| 839 |
def log_info(msg):
|
| 840 |
if main_logger:
|
| 841 |
main_logger.info(f"[Twitter] {msg}")
|
|
|
|
| 850 |
profile_path = os.path.join(simulation_dir, "twitter_profiles.csv")
|
| 851 |
if not os.path.exists(profile_path):
|
| 852 |
log_info(f"错误: Profile文件不存在: {profile_path}")
|
| 853 |
+
return result
|
| 854 |
|
| 855 |
+
result.agent_graph = await generate_twitter_agent_graph(
|
| 856 |
profile_path=profile_path,
|
| 857 |
model=model,
|
| 858 |
available_actions=TWITTER_ACTIONS,
|
|
|
|
| 861 |
# 从配置文件获取 Agent 真实名称映射(使用 entity_name 而非默认的 Agent_X)
|
| 862 |
agent_names = get_agent_names_from_config(config)
|
| 863 |
# 如果配置中没有某个 agent,则使用 OASIS 的默认名称
|
| 864 |
+
for agent_id, agent in result.agent_graph.get_agents():
|
| 865 |
if agent_id not in agent_names:
|
| 866 |
agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}')
|
| 867 |
|
|
|
|
| 869 |
if os.path.exists(db_path):
|
| 870 |
os.remove(db_path)
|
| 871 |
|
| 872 |
+
result.env = oasis.make(
|
| 873 |
+
agent_graph=result.agent_graph,
|
| 874 |
platform=oasis.DefaultPlatformType.TWITTER,
|
| 875 |
database_path=db_path,
|
| 876 |
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
|
| 877 |
)
|
| 878 |
|
| 879 |
+
await result.env.reset()
|
| 880 |
log_info("环境已启动")
|
| 881 |
|
| 882 |
if action_logger:
|
|
|
|
| 900 |
agent_id = post.get("poster_agent_id", 0)
|
| 901 |
content = post.get("content", "")
|
| 902 |
try:
|
| 903 |
+
agent = result.env.agent_graph.get_agent(agent_id)
|
| 904 |
initial_actions[agent] = ManualAction(
|
| 905 |
action_type=ActionType.CREATE_POST,
|
| 906 |
action_args={"content": content}
|
|
|
|
| 920 |
pass
|
| 921 |
|
| 922 |
if initial_actions:
|
| 923 |
+
await result.env.step(initial_actions)
|
| 924 |
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
|
| 925 |
|
| 926 |
# 记录 round 0 结束
|
|
|
|
| 948 |
simulated_day = simulated_minutes // (60 * 24) + 1
|
| 949 |
|
| 950 |
active_agents = get_active_agents_for_round(
|
| 951 |
+
result.env, config, simulated_hour, round_num
|
| 952 |
)
|
| 953 |
|
| 954 |
# 无论是否有活跃agent,都记录round开始
|
|
|
|
| 962 |
continue
|
| 963 |
|
| 964 |
actions = {agent: LLMAction() for _, agent in active_agents}
|
| 965 |
+
await result.env.step(actions)
|
| 966 |
|
| 967 |
# 从数据库获取实际执行的动作并记录
|
| 968 |
actual_actions, last_rowid = fetch_new_actions_from_db(
|
|
|
|
| 989 |
progress = (round_num + 1) / total_rounds * 100
|
| 990 |
log_info(f"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)")
|
| 991 |
|
| 992 |
+
# 注意:不关闭环境,保留给Interview使用
|
| 993 |
|
| 994 |
if action_logger:
|
| 995 |
action_logger.log_simulation_end(total_rounds, total_actions)
|
| 996 |
|
| 997 |
+
result.total_actions = total_actions
|
| 998 |
elapsed = (datetime.now() - start_time).total_seconds()
|
| 999 |
+
log_info(f"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}")
|
| 1000 |
+
|
| 1001 |
+
return result
|
| 1002 |
|
| 1003 |
|
| 1004 |
async def run_reddit_simulation(
|
|
|
|
| 1007 |
action_logger: Optional[PlatformActionLogger] = None,
|
| 1008 |
main_logger: Optional[SimulationLogManager] = None,
|
| 1009 |
max_rounds: Optional[int] = None
|
| 1010 |
+
) -> PlatformSimulation:
|
| 1011 |
"""运行Reddit模拟
|
| 1012 |
|
| 1013 |
Args:
|
|
|
|
| 1016 |
action_logger: 动作日志记录器
|
| 1017 |
main_logger: 主日志管理器
|
| 1018 |
max_rounds: 最大模拟轮数(可选,用于截断过长的模拟)
|
| 1019 |
+
|
| 1020 |
+
Returns:
|
| 1021 |
+
PlatformSimulation: 包含env和agent_graph的结果对象
|
| 1022 |
"""
|
| 1023 |
+
result = PlatformSimulation()
|
| 1024 |
+
|
| 1025 |
def log_info(msg):
|
| 1026 |
if main_logger:
|
| 1027 |
main_logger.info(f"[Reddit] {msg}")
|
|
|
|
| 1035 |
profile_path = os.path.join(simulation_dir, "reddit_profiles.json")
|
| 1036 |
if not os.path.exists(profile_path):
|
| 1037 |
log_info(f"错误: Profile文件不存在: {profile_path}")
|
| 1038 |
+
return result
|
| 1039 |
|
| 1040 |
+
result.agent_graph = await generate_reddit_agent_graph(
|
| 1041 |
profile_path=profile_path,
|
| 1042 |
model=model,
|
| 1043 |
available_actions=REDDIT_ACTIONS,
|
|
|
|
| 1046 |
# 从配置文件获取 Agent 真实名称映射(使用 entity_name 而非默认的 Agent_X)
|
| 1047 |
agent_names = get_agent_names_from_config(config)
|
| 1048 |
# 如果配置中没有某个 agent,则使用 OASIS 的默认名称
|
| 1049 |
+
for agent_id, agent in result.agent_graph.get_agents():
|
| 1050 |
if agent_id not in agent_names:
|
| 1051 |
agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}')
|
| 1052 |
|
|
|
|
| 1054 |
if os.path.exists(db_path):
|
| 1055 |
os.remove(db_path)
|
| 1056 |
|
| 1057 |
+
result.env = oasis.make(
|
| 1058 |
+
agent_graph=result.agent_graph,
|
| 1059 |
platform=oasis.DefaultPlatformType.REDDIT,
|
| 1060 |
database_path=db_path,
|
| 1061 |
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
|
| 1062 |
)
|
| 1063 |
|
| 1064 |
+
await result.env.reset()
|
| 1065 |
log_info("环境已启动")
|
| 1066 |
|
| 1067 |
if action_logger:
|
|
|
|
| 1085 |
agent_id = post.get("poster_agent_id", 0)
|
| 1086 |
content = post.get("content", "")
|
| 1087 |
try:
|
| 1088 |
+
agent = result.env.agent_graph.get_agent(agent_id)
|
| 1089 |
if agent in initial_actions:
|
| 1090 |
if not isinstance(initial_actions[agent], list):
|
| 1091 |
initial_actions[agent] = [initial_actions[agent]]
|
|
|
|
| 1113 |
pass
|
| 1114 |
|
| 1115 |
if initial_actions:
|
| 1116 |
+
await result.env.step(initial_actions)
|
| 1117 |
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
|
| 1118 |
|
| 1119 |
# 记录 round 0 结束
|
|
|
|
| 1141 |
simulated_day = simulated_minutes // (60 * 24) + 1
|
| 1142 |
|
| 1143 |
active_agents = get_active_agents_for_round(
|
| 1144 |
+
result.env, config, simulated_hour, round_num
|
| 1145 |
)
|
| 1146 |
|
| 1147 |
# 无论是否有活跃agent,都记录round开始
|
|
|
|
| 1155 |
continue
|
| 1156 |
|
| 1157 |
actions = {agent: LLMAction() for _, agent in active_agents}
|
| 1158 |
+
await result.env.step(actions)
|
| 1159 |
|
| 1160 |
# 从数据库获取实际执行的动作并记录
|
| 1161 |
actual_actions, last_rowid = fetch_new_actions_from_db(
|
|
|
|
| 1182 |
progress = (round_num + 1) / total_rounds * 100
|
| 1183 |
log_info(f"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)")
|
| 1184 |
|
| 1185 |
+
# 注意:不关闭环境,保留给Interview使用
|
| 1186 |
|
| 1187 |
if action_logger:
|
| 1188 |
action_logger.log_simulation_end(total_rounds, total_actions)
|
| 1189 |
|
| 1190 |
+
result.total_actions = total_actions
|
| 1191 |
elapsed = (datetime.now() - start_time).total_seconds()
|
| 1192 |
+
log_info(f"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}")
|
| 1193 |
+
|
| 1194 |
+
return result
|
| 1195 |
|
| 1196 |
|
| 1197 |
async def main():
|
|
|
|
| 1218 |
default=None,
|
| 1219 |
help='最大模拟轮数(可选,用于截断过长的模拟)'
|
| 1220 |
)
|
| 1221 |
+
parser.add_argument(
|
| 1222 |
+
'--no-wait',
|
| 1223 |
+
action='store_true',
|
| 1224 |
+
default=False,
|
| 1225 |
+
help='模拟完成后立即关闭环境,不进入等待命令模式'
|
| 1226 |
+
)
|
| 1227 |
|
| 1228 |
args = parser.parse_args()
|
| 1229 |
|
|
|
|
| 1233 |
|
| 1234 |
config = load_config(args.config)
|
| 1235 |
simulation_dir = os.path.dirname(args.config) or "."
|
| 1236 |
+
wait_for_commands = not args.no_wait
|
| 1237 |
|
| 1238 |
# 初始化日志配置(禁用 OASIS 日志,清理旧文件)
|
| 1239 |
init_logging_for_simulation(simulation_dir)
|
|
|
|
| 1247 |
log_manager.info("OASIS 双平台并行模拟")
|
| 1248 |
log_manager.info(f"配置文件: {args.config}")
|
| 1249 |
log_manager.info(f"模拟ID: {config.get('simulation_id', 'unknown')}")
|
| 1250 |
+
log_manager.info(f"等待命令模式: {'启用' if wait_for_commands else '禁用'}")
|
| 1251 |
log_manager.info("=" * 60)
|
| 1252 |
|
| 1253 |
time_config = config.get("time_config", {})
|
|
|
|
| 1273 |
|
| 1274 |
start_time = datetime.now()
|
| 1275 |
|
| 1276 |
+
# 存储两个平台的模拟结果
|
| 1277 |
+
twitter_result: Optional[PlatformSimulation] = None
|
| 1278 |
+
reddit_result: Optional[PlatformSimulation] = None
|
| 1279 |
+
|
| 1280 |
if args.twitter_only:
|
| 1281 |
+
twitter_result = await run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds)
|
| 1282 |
elif args.reddit_only:
|
| 1283 |
+
reddit_result = await run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds)
|
| 1284 |
else:
|
| 1285 |
# 并行运行(每个平台使用独立的日志记录器)
|
| 1286 |
+
results = await asyncio.gather(
|
| 1287 |
run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds),
|
| 1288 |
run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds),
|
| 1289 |
)
|
| 1290 |
+
twitter_result, reddit_result = results
|
| 1291 |
|
| 1292 |
total_elapsed = (datetime.now() - start_time).total_seconds()
|
| 1293 |
log_manager.info("=" * 60)
|
| 1294 |
+
log_manager.info(f"模拟循环完成! 总耗时: {total_elapsed:.1f}秒")
|
| 1295 |
+
|
| 1296 |
+
# 是否进入等待命令模式
|
| 1297 |
+
if wait_for_commands:
|
| 1298 |
+
log_manager.info("")
|
| 1299 |
+
log_manager.info("=" * 60)
|
| 1300 |
+
log_manager.info("进入等待命令模式 - 环境保持运行")
|
| 1301 |
+
log_manager.info("支持的命令: interview, batch_interview, close_env")
|
| 1302 |
+
log_manager.info("=" * 60)
|
| 1303 |
+
|
| 1304 |
+
# 创建IPC处理器
|
| 1305 |
+
ipc_handler = ParallelIPCHandler(
|
| 1306 |
+
simulation_dir=simulation_dir,
|
| 1307 |
+
twitter_env=twitter_result.env if twitter_result else None,
|
| 1308 |
+
twitter_agent_graph=twitter_result.agent_graph if twitter_result else None,
|
| 1309 |
+
reddit_env=reddit_result.env if reddit_result else None,
|
| 1310 |
+
reddit_agent_graph=reddit_result.agent_graph if reddit_result else None
|
| 1311 |
+
)
|
| 1312 |
+
ipc_handler.update_status("alive")
|
| 1313 |
+
|
| 1314 |
+
# 等待命令循环
|
| 1315 |
+
try:
|
| 1316 |
+
while True:
|
| 1317 |
+
should_continue = await ipc_handler.process_commands()
|
| 1318 |
+
if not should_continue:
|
| 1319 |
+
break
|
| 1320 |
+
await asyncio.sleep(0.5) # 轮询间隔
|
| 1321 |
+
except KeyboardInterrupt:
|
| 1322 |
+
print("\n收到中断信号")
|
| 1323 |
+
except Exception as e:
|
| 1324 |
+
print(f"\n命令处理出错: {e}")
|
| 1325 |
+
|
| 1326 |
+
log_manager.info("\n关闭环境...")
|
| 1327 |
+
ipc_handler.update_status("stopped")
|
| 1328 |
+
|
| 1329 |
+
# 关闭环境
|
| 1330 |
+
if twitter_result and twitter_result.env:
|
| 1331 |
+
await twitter_result.env.close()
|
| 1332 |
+
log_manager.info("[Twitter] 环境已关闭")
|
| 1333 |
+
|
| 1334 |
+
if reddit_result and reddit_result.env:
|
| 1335 |
+
await reddit_result.env.close()
|
| 1336 |
+
log_manager.info("[Reddit] 环境已关闭")
|
| 1337 |
+
|
| 1338 |
+
log_manager.info("=" * 60)
|
| 1339 |
+
log_manager.info(f"全部完成!")
|
| 1340 |
log_manager.info(f"日志文件:")
|
| 1341 |
log_manager.info(f" - {os.path.join(simulation_dir, 'simulation.log')}")
|
| 1342 |
log_manager.info(f" - {os.path.join(simulation_dir, 'twitter', 'actions.jsonl')}")
|
|
|
|
| 1346 |
|
| 1347 |
if __name__ == "__main__":
|
| 1348 |
asyncio.run(main())
|
|
|
backend/scripts/run_reddit_simulation.py
CHANGED
|
@@ -2,8 +2,15 @@
|
|
| 2 |
OASIS Reddit模拟预设脚本
|
| 3 |
此脚本读取配置文件中的参数来执行模拟,实现全程自动化
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
使用方式:
|
| 6 |
python run_reddit_simulation.py --config /path/to/simulation_config.json
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
import argparse
|
|
@@ -13,8 +20,9 @@ import logging
|
|
| 13 |
import os
|
| 14 |
import random
|
| 15 |
import sys
|
|
|
|
| 16 |
from datetime import datetime
|
| 17 |
-
from typing import Dict, Any, List
|
| 18 |
|
| 19 |
# 添加项目路径
|
| 20 |
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -118,10 +126,261 @@ except ImportError as e:
|
|
| 118 |
sys.exit(1)
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
class RedditSimulationRunner:
|
| 122 |
"""Reddit模拟运行器"""
|
| 123 |
|
| 124 |
-
# Reddit可用动作
|
| 125 |
AVAILABLE_ACTIONS = [
|
| 126 |
ActionType.LIKE_POST,
|
| 127 |
ActionType.DISLIKE_POST,
|
|
@@ -138,16 +397,21 @@ class RedditSimulationRunner:
|
|
| 138 |
ActionType.MUTE,
|
| 139 |
]
|
| 140 |
|
| 141 |
-
def __init__(self, config_path: str):
|
| 142 |
"""
|
| 143 |
初始化模拟运行器
|
| 144 |
|
| 145 |
Args:
|
| 146 |
config_path: 配置文件路径 (simulation_config.json)
|
|
|
|
| 147 |
"""
|
| 148 |
self.config_path = config_path
|
| 149 |
self.config = self._load_config()
|
| 150 |
self.simulation_dir = os.path.dirname(config_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
def _load_config(self) -> Dict[str, Any]:
|
| 153 |
"""加载配置文件"""
|
|
@@ -261,6 +525,7 @@ class RedditSimulationRunner:
|
|
| 261 |
print("OASIS Reddit模拟")
|
| 262 |
print(f"配置文件: {self.config_path}")
|
| 263 |
print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
|
|
|
|
| 264 |
print("=" * 60)
|
| 265 |
|
| 266 |
time_config = self.config.get("time_config", {})
|
|
@@ -292,7 +557,7 @@ class RedditSimulationRunner:
|
|
| 292 |
print(f"错误: Profile文件不存在: {profile_path}")
|
| 293 |
return
|
| 294 |
|
| 295 |
-
agent_graph = await generate_reddit_agent_graph(
|
| 296 |
profile_path=profile_path,
|
| 297 |
model=model,
|
| 298 |
available_actions=self.AVAILABLE_ACTIONS,
|
|
@@ -304,16 +569,20 @@ class RedditSimulationRunner:
|
|
| 304 |
print(f"已删除旧数据库: {db_path}")
|
| 305 |
|
| 306 |
print("创建OASIS环境...")
|
| 307 |
-
env = oasis.make(
|
| 308 |
-
agent_graph=agent_graph,
|
| 309 |
platform=oasis.DefaultPlatformType.REDDIT,
|
| 310 |
database_path=db_path,
|
| 311 |
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
|
| 312 |
)
|
| 313 |
|
| 314 |
-
await env.reset()
|
| 315 |
print("环境初始化完成\n")
|
| 316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
# 执行初始事件
|
| 318 |
event_config = self.config.get("event_config", {})
|
| 319 |
initial_posts = event_config.get("initial_posts", [])
|
|
@@ -325,7 +594,7 @@ class RedditSimulationRunner:
|
|
| 325 |
agent_id = post.get("poster_agent_id", 0)
|
| 326 |
content = post.get("content", "")
|
| 327 |
try:
|
| 328 |
-
agent = env.agent_graph.get_agent(agent_id)
|
| 329 |
if agent in initial_actions:
|
| 330 |
if not isinstance(initial_actions[agent], list):
|
| 331 |
initial_actions[agent] = [initial_actions[agent]]
|
|
@@ -342,7 +611,7 @@ class RedditSimulationRunner:
|
|
| 342 |
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
|
| 343 |
|
| 344 |
if initial_actions:
|
| 345 |
-
await env.step(initial_actions)
|
| 346 |
print(f" 已发布 {len(initial_actions)} 条初始帖子")
|
| 347 |
|
| 348 |
# 主模拟循环
|
|
@@ -355,7 +624,7 @@ class RedditSimulationRunner:
|
|
| 355 |
simulated_day = simulated_minutes // (60 * 24) + 1
|
| 356 |
|
| 357 |
active_agents = self._get_active_agents_for_round(
|
| 358 |
-
env, simulated_hour, round_num
|
| 359 |
)
|
| 360 |
|
| 361 |
if not active_agents:
|
|
@@ -366,7 +635,7 @@ class RedditSimulationRunner:
|
|
| 366 |
for _, agent in active_agents
|
| 367 |
}
|
| 368 |
|
| 369 |
-
await env.step(actions)
|
| 370 |
|
| 371 |
if (round_num + 1) % 10 == 0 or round_num == 0:
|
| 372 |
elapsed = (datetime.now() - start_time).total_seconds()
|
|
@@ -376,12 +645,39 @@ class RedditSimulationRunner:
|
|
| 376 |
f"- {len(active_agents)} agents active "
|
| 377 |
f"- elapsed: {elapsed:.1f}s")
|
| 378 |
|
| 379 |
-
await env.close()
|
| 380 |
-
|
| 381 |
total_elapsed = (datetime.now() - start_time).total_seconds()
|
| 382 |
-
print(f"\n模拟完成!")
|
| 383 |
print(f" - 总耗时: {total_elapsed:.1f}秒")
|
| 384 |
print(f" - 数据库: {db_path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
print("=" * 60)
|
| 386 |
|
| 387 |
|
|
@@ -399,6 +695,12 @@ async def main():
|
|
| 399 |
default=None,
|
| 400 |
help='最大模拟轮数(可选,用于截断过长的模拟)'
|
| 401 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
args = parser.parse_args()
|
| 404 |
|
|
@@ -410,7 +712,10 @@ async def main():
|
|
| 410 |
simulation_dir = os.path.dirname(args.config) or "."
|
| 411 |
setup_oasis_logging(os.path.join(simulation_dir, "log"))
|
| 412 |
|
| 413 |
-
runner = RedditSimulationRunner(
|
|
|
|
|
|
|
|
|
|
| 414 |
await runner.run(max_rounds=args.max_rounds)
|
| 415 |
|
| 416 |
|
|
|
|
| 2 |
OASIS Reddit模拟预设脚本
|
| 3 |
此脚本读取配置文件中的参数来执行模拟,实现全程自动化
|
| 4 |
|
| 5 |
+
功能特性:
|
| 6 |
+
- 完成模拟后不立即关闭环境,进入等待命令模式
|
| 7 |
+
- 支持通过IPC接收Interview命令
|
| 8 |
+
- 支持单个Agent采访和批量采访
|
| 9 |
+
- 支持远程关闭环境命令
|
| 10 |
+
|
| 11 |
使用方式:
|
| 12 |
python run_reddit_simulation.py --config /path/to/simulation_config.json
|
| 13 |
+
python run_reddit_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭
|
| 14 |
"""
|
| 15 |
|
| 16 |
import argparse
|
|
|
|
| 20 |
import os
|
| 21 |
import random
|
| 22 |
import sys
|
| 23 |
+
import sqlite3
|
| 24 |
from datetime import datetime
|
| 25 |
+
from typing import Dict, Any, List, Optional
|
| 26 |
|
| 27 |
# 添加项目路径
|
| 28 |
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
| 126 |
sys.exit(1)
|
| 127 |
|
| 128 |
|
| 129 |
+
# IPC相关常量
|
| 130 |
+
IPC_COMMANDS_DIR = "ipc_commands"
|
| 131 |
+
IPC_RESPONSES_DIR = "ipc_responses"
|
| 132 |
+
ENV_STATUS_FILE = "env_status.json"
|
| 133 |
+
|
| 134 |
+
class CommandType:
|
| 135 |
+
"""命令类型常量"""
|
| 136 |
+
INTERVIEW = "interview"
|
| 137 |
+
BATCH_INTERVIEW = "batch_interview"
|
| 138 |
+
CLOSE_ENV = "close_env"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class IPCHandler:
|
| 142 |
+
"""IPC命令处理器"""
|
| 143 |
+
|
| 144 |
+
def __init__(self, simulation_dir: str, env, agent_graph):
|
| 145 |
+
self.simulation_dir = simulation_dir
|
| 146 |
+
self.env = env
|
| 147 |
+
self.agent_graph = agent_graph
|
| 148 |
+
self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)
|
| 149 |
+
self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)
|
| 150 |
+
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
|
| 151 |
+
self._running = True
|
| 152 |
+
|
| 153 |
+
# 确保目录存在
|
| 154 |
+
os.makedirs(self.commands_dir, exist_ok=True)
|
| 155 |
+
os.makedirs(self.responses_dir, exist_ok=True)
|
| 156 |
+
|
| 157 |
+
def update_status(self, status: str):
|
| 158 |
+
"""更新环境状态"""
|
| 159 |
+
with open(self.status_file, 'w', encoding='utf-8') as f:
|
| 160 |
+
json.dump({
|
| 161 |
+
"status": status,
|
| 162 |
+
"timestamp": datetime.now().isoformat()
|
| 163 |
+
}, f, ensure_ascii=False, indent=2)
|
| 164 |
+
|
| 165 |
+
def poll_command(self) -> Optional[Dict[str, Any]]:
|
| 166 |
+
"""轮询获取待处理命令"""
|
| 167 |
+
if not os.path.exists(self.commands_dir):
|
| 168 |
+
return None
|
| 169 |
+
|
| 170 |
+
# 获取命令文件(按时间排序)
|
| 171 |
+
command_files = []
|
| 172 |
+
for filename in os.listdir(self.commands_dir):
|
| 173 |
+
if filename.endswith('.json'):
|
| 174 |
+
filepath = os.path.join(self.commands_dir, filename)
|
| 175 |
+
command_files.append((filepath, os.path.getmtime(filepath)))
|
| 176 |
+
|
| 177 |
+
command_files.sort(key=lambda x: x[1])
|
| 178 |
+
|
| 179 |
+
for filepath, _ in command_files:
|
| 180 |
+
try:
|
| 181 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 182 |
+
return json.load(f)
|
| 183 |
+
except (json.JSONDecodeError, OSError):
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
|
| 189 |
+
"""发送响应"""
|
| 190 |
+
response = {
|
| 191 |
+
"command_id": command_id,
|
| 192 |
+
"status": status,
|
| 193 |
+
"result": result,
|
| 194 |
+
"error": error,
|
| 195 |
+
"timestamp": datetime.now().isoformat()
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
|
| 199 |
+
with open(response_file, 'w', encoding='utf-8') as f:
|
| 200 |
+
json.dump(response, f, ensure_ascii=False, indent=2)
|
| 201 |
+
|
| 202 |
+
# 删除命令文件
|
| 203 |
+
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
|
| 204 |
+
try:
|
| 205 |
+
os.remove(command_file)
|
| 206 |
+
except OSError:
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
|
| 210 |
+
"""
|
| 211 |
+
处理单个Agent采访命令
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
True 表示成功,False 表示失败
|
| 215 |
+
"""
|
| 216 |
+
try:
|
| 217 |
+
# 获取Agent
|
| 218 |
+
agent = self.agent_graph.get_agent(agent_id)
|
| 219 |
+
|
| 220 |
+
# 创建Interview动作
|
| 221 |
+
interview_action = ManualAction(
|
| 222 |
+
action_type=ActionType.INTERVIEW,
|
| 223 |
+
action_args={"prompt": prompt}
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# 执行Interview
|
| 227 |
+
actions = {agent: interview_action}
|
| 228 |
+
await self.env.step(actions)
|
| 229 |
+
|
| 230 |
+
# 从数据库获取结果
|
| 231 |
+
result = self._get_interview_result(agent_id)
|
| 232 |
+
|
| 233 |
+
self.send_response(command_id, "completed", result=result)
|
| 234 |
+
print(f" Interview完成: agent_id={agent_id}")
|
| 235 |
+
return True
|
| 236 |
+
|
| 237 |
+
except Exception as e:
|
| 238 |
+
error_msg = str(e)
|
| 239 |
+
print(f" Interview失败: agent_id={agent_id}, error={error_msg}")
|
| 240 |
+
self.send_response(command_id, "failed", error=error_msg)
|
| 241 |
+
return False
|
| 242 |
+
|
| 243 |
+
async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
|
| 244 |
+
"""
|
| 245 |
+
处理批量采访命令
|
| 246 |
+
|
| 247 |
+
Args:
|
| 248 |
+
interviews: [{"agent_id": int, "prompt": str}, ...]
|
| 249 |
+
"""
|
| 250 |
+
try:
|
| 251 |
+
# 构建动作字典
|
| 252 |
+
actions = {}
|
| 253 |
+
agent_prompts = {} # 记录每个agent的prompt
|
| 254 |
+
|
| 255 |
+
for interview in interviews:
|
| 256 |
+
agent_id = interview.get("agent_id")
|
| 257 |
+
prompt = interview.get("prompt", "")
|
| 258 |
+
|
| 259 |
+
try:
|
| 260 |
+
agent = self.agent_graph.get_agent(agent_id)
|
| 261 |
+
actions[agent] = ManualAction(
|
| 262 |
+
action_type=ActionType.INTERVIEW,
|
| 263 |
+
action_args={"prompt": prompt}
|
| 264 |
+
)
|
| 265 |
+
agent_prompts[agent_id] = prompt
|
| 266 |
+
except Exception as e:
|
| 267 |
+
print(f" 警告: 无法获取Agent {agent_id}: {e}")
|
| 268 |
+
|
| 269 |
+
if not actions:
|
| 270 |
+
self.send_response(command_id, "failed", error="没有有效的Agent")
|
| 271 |
+
return False
|
| 272 |
+
|
| 273 |
+
# 执行批量Interview
|
| 274 |
+
await self.env.step(actions)
|
| 275 |
+
|
| 276 |
+
# 获取所有结果
|
| 277 |
+
results = {}
|
| 278 |
+
for agent_id in agent_prompts.keys():
|
| 279 |
+
result = self._get_interview_result(agent_id)
|
| 280 |
+
results[agent_id] = result
|
| 281 |
+
|
| 282 |
+
self.send_response(command_id, "completed", result={
|
| 283 |
+
"interviews_count": len(results),
|
| 284 |
+
"results": results
|
| 285 |
+
})
|
| 286 |
+
print(f" 批量Interview完成: {len(results)} 个Agent")
|
| 287 |
+
return True
|
| 288 |
+
|
| 289 |
+
except Exception as e:
|
| 290 |
+
error_msg = str(e)
|
| 291 |
+
print(f" 批量Interview失败: {error_msg}")
|
| 292 |
+
self.send_response(command_id, "failed", error=error_msg)
|
| 293 |
+
return False
|
| 294 |
+
|
| 295 |
+
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
|
| 296 |
+
"""从数据库获取最新的Interview结果"""
|
| 297 |
+
db_path = os.path.join(self.simulation_dir, "reddit_simulation.db")
|
| 298 |
+
|
| 299 |
+
result = {
|
| 300 |
+
"agent_id": agent_id,
|
| 301 |
+
"response": None,
|
| 302 |
+
"timestamp": None
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
if not os.path.exists(db_path):
|
| 306 |
+
return result
|
| 307 |
+
|
| 308 |
+
try:
|
| 309 |
+
conn = sqlite3.connect(db_path)
|
| 310 |
+
cursor = conn.cursor()
|
| 311 |
+
|
| 312 |
+
# 查询最新的Interview记录
|
| 313 |
+
cursor.execute("""
|
| 314 |
+
SELECT user_id, info, created_at
|
| 315 |
+
FROM trace
|
| 316 |
+
WHERE action = ? AND user_id = ?
|
| 317 |
+
ORDER BY created_at DESC
|
| 318 |
+
LIMIT 1
|
| 319 |
+
""", (ActionType.INTERVIEW.value, agent_id))
|
| 320 |
+
|
| 321 |
+
row = cursor.fetchone()
|
| 322 |
+
if row:
|
| 323 |
+
user_id, info_json, created_at = row
|
| 324 |
+
try:
|
| 325 |
+
info = json.loads(info_json) if info_json else {}
|
| 326 |
+
result["response"] = info.get("response", info)
|
| 327 |
+
result["timestamp"] = created_at
|
| 328 |
+
except json.JSONDecodeError:
|
| 329 |
+
result["response"] = info_json
|
| 330 |
+
|
| 331 |
+
conn.close()
|
| 332 |
+
|
| 333 |
+
except Exception as e:
|
| 334 |
+
print(f" 读取Interview结果失败: {e}")
|
| 335 |
+
|
| 336 |
+
return result
|
| 337 |
+
|
| 338 |
+
async def process_commands(self) -> bool:
|
| 339 |
+
"""
|
| 340 |
+
处理所有待处理命令
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
True 表示继续运行,False 表示应该退出
|
| 344 |
+
"""
|
| 345 |
+
command = self.poll_command()
|
| 346 |
+
if not command:
|
| 347 |
+
return True
|
| 348 |
+
|
| 349 |
+
command_id = command.get("command_id")
|
| 350 |
+
command_type = command.get("command_type")
|
| 351 |
+
args = command.get("args", {})
|
| 352 |
+
|
| 353 |
+
print(f"\n收到IPC命令: {command_type}, id={command_id}")
|
| 354 |
+
|
| 355 |
+
if command_type == CommandType.INTERVIEW:
|
| 356 |
+
await self.handle_interview(
|
| 357 |
+
command_id,
|
| 358 |
+
args.get("agent_id", 0),
|
| 359 |
+
args.get("prompt", "")
|
| 360 |
+
)
|
| 361 |
+
return True
|
| 362 |
+
|
| 363 |
+
elif command_type == CommandType.BATCH_INTERVIEW:
|
| 364 |
+
await self.handle_batch_interview(
|
| 365 |
+
command_id,
|
| 366 |
+
args.get("interviews", [])
|
| 367 |
+
)
|
| 368 |
+
return True
|
| 369 |
+
|
| 370 |
+
elif command_type == CommandType.CLOSE_ENV:
|
| 371 |
+
print("收到关闭环境命令")
|
| 372 |
+
self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
|
| 373 |
+
return False
|
| 374 |
+
|
| 375 |
+
else:
|
| 376 |
+
self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
|
| 377 |
+
return True
|
| 378 |
+
|
| 379 |
+
|
| 380 |
class RedditSimulationRunner:
|
| 381 |
"""Reddit模拟运行器"""
|
| 382 |
|
| 383 |
+
# Reddit可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发)
|
| 384 |
AVAILABLE_ACTIONS = [
|
| 385 |
ActionType.LIKE_POST,
|
| 386 |
ActionType.DISLIKE_POST,
|
|
|
|
| 397 |
ActionType.MUTE,
|
| 398 |
]
|
| 399 |
|
| 400 |
+
def __init__(self, config_path: str, wait_for_commands: bool = True):
|
| 401 |
"""
|
| 402 |
初始化模拟运行器
|
| 403 |
|
| 404 |
Args:
|
| 405 |
config_path: 配置文件路径 (simulation_config.json)
|
| 406 |
+
wait_for_commands: 模拟完成后是否等待命令(默认True)
|
| 407 |
"""
|
| 408 |
self.config_path = config_path
|
| 409 |
self.config = self._load_config()
|
| 410 |
self.simulation_dir = os.path.dirname(config_path)
|
| 411 |
+
self.wait_for_commands = wait_for_commands
|
| 412 |
+
self.env = None
|
| 413 |
+
self.agent_graph = None
|
| 414 |
+
self.ipc_handler = None
|
| 415 |
|
| 416 |
def _load_config(self) -> Dict[str, Any]:
|
| 417 |
"""加载配置文件"""
|
|
|
|
| 525 |
print("OASIS Reddit模拟")
|
| 526 |
print(f"配置文件: {self.config_path}")
|
| 527 |
print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
|
| 528 |
+
print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}")
|
| 529 |
print("=" * 60)
|
| 530 |
|
| 531 |
time_config = self.config.get("time_config", {})
|
|
|
|
| 557 |
print(f"错误: Profile文件不存在: {profile_path}")
|
| 558 |
return
|
| 559 |
|
| 560 |
+
self.agent_graph = await generate_reddit_agent_graph(
|
| 561 |
profile_path=profile_path,
|
| 562 |
model=model,
|
| 563 |
available_actions=self.AVAILABLE_ACTIONS,
|
|
|
|
| 569 |
print(f"已删除旧数据库: {db_path}")
|
| 570 |
|
| 571 |
print("创建OASIS环境...")
|
| 572 |
+
self.env = oasis.make(
|
| 573 |
+
agent_graph=self.agent_graph,
|
| 574 |
platform=oasis.DefaultPlatformType.REDDIT,
|
| 575 |
database_path=db_path,
|
| 576 |
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
|
| 577 |
)
|
| 578 |
|
| 579 |
+
await self.env.reset()
|
| 580 |
print("环境初始化完成\n")
|
| 581 |
|
| 582 |
+
# 初始化IPC处理器
|
| 583 |
+
self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
|
| 584 |
+
self.ipc_handler.update_status("running")
|
| 585 |
+
|
| 586 |
# 执行初始事件
|
| 587 |
event_config = self.config.get("event_config", {})
|
| 588 |
initial_posts = event_config.get("initial_posts", [])
|
|
|
|
| 594 |
agent_id = post.get("poster_agent_id", 0)
|
| 595 |
content = post.get("content", "")
|
| 596 |
try:
|
| 597 |
+
agent = self.env.agent_graph.get_agent(agent_id)
|
| 598 |
if agent in initial_actions:
|
| 599 |
if not isinstance(initial_actions[agent], list):
|
| 600 |
initial_actions[agent] = [initial_actions[agent]]
|
|
|
|
| 611 |
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
|
| 612 |
|
| 613 |
if initial_actions:
|
| 614 |
+
await self.env.step(initial_actions)
|
| 615 |
print(f" 已发布 {len(initial_actions)} 条初始帖子")
|
| 616 |
|
| 617 |
# 主模拟循环
|
|
|
|
| 624 |
simulated_day = simulated_minutes // (60 * 24) + 1
|
| 625 |
|
| 626 |
active_agents = self._get_active_agents_for_round(
|
| 627 |
+
self.env, simulated_hour, round_num
|
| 628 |
)
|
| 629 |
|
| 630 |
if not active_agents:
|
|
|
|
| 635 |
for _, agent in active_agents
|
| 636 |
}
|
| 637 |
|
| 638 |
+
await self.env.step(actions)
|
| 639 |
|
| 640 |
if (round_num + 1) % 10 == 0 or round_num == 0:
|
| 641 |
elapsed = (datetime.now() - start_time).total_seconds()
|
|
|
|
| 645 |
f"- {len(active_agents)} agents active "
|
| 646 |
f"- elapsed: {elapsed:.1f}s")
|
| 647 |
|
|
|
|
|
|
|
| 648 |
total_elapsed = (datetime.now() - start_time).total_seconds()
|
| 649 |
+
print(f"\n模拟循环完成!")
|
| 650 |
print(f" - 总耗时: {total_elapsed:.1f}秒")
|
| 651 |
print(f" - 数据库: {db_path}")
|
| 652 |
+
|
| 653 |
+
# 是否进入等待命令模式
|
| 654 |
+
if self.wait_for_commands:
|
| 655 |
+
print("\n" + "=" * 60)
|
| 656 |
+
print("进入等待命令模式 - 环境保持运行")
|
| 657 |
+
print("支持的命令: interview, batch_interview, close_env")
|
| 658 |
+
print("=" * 60)
|
| 659 |
+
|
| 660 |
+
self.ipc_handler.update_status("alive")
|
| 661 |
+
|
| 662 |
+
# 等待命令循环
|
| 663 |
+
try:
|
| 664 |
+
while True:
|
| 665 |
+
should_continue = await self.ipc_handler.process_commands()
|
| 666 |
+
if not should_continue:
|
| 667 |
+
break
|
| 668 |
+
await asyncio.sleep(0.5) # 轮询间隔
|
| 669 |
+
except KeyboardInterrupt:
|
| 670 |
+
print("\n收到中断信号")
|
| 671 |
+
except Exception as e:
|
| 672 |
+
print(f"\n命令处理出错: {e}")
|
| 673 |
+
|
| 674 |
+
print("\n关闭环境...")
|
| 675 |
+
|
| 676 |
+
# 关闭环境
|
| 677 |
+
self.ipc_handler.update_status("stopped")
|
| 678 |
+
await self.env.close()
|
| 679 |
+
|
| 680 |
+
print("环境已关闭")
|
| 681 |
print("=" * 60)
|
| 682 |
|
| 683 |
|
|
|
|
| 695 |
default=None,
|
| 696 |
help='最大模拟轮数(可选,用于截断过长的模拟)'
|
| 697 |
)
|
| 698 |
+
parser.add_argument(
|
| 699 |
+
'--no-wait',
|
| 700 |
+
action='store_true',
|
| 701 |
+
default=False,
|
| 702 |
+
help='模拟完成后立即关闭环境,不进入等待命令模式'
|
| 703 |
+
)
|
| 704 |
|
| 705 |
args = parser.parse_args()
|
| 706 |
|
|
|
|
| 712 |
simulation_dir = os.path.dirname(args.config) or "."
|
| 713 |
setup_oasis_logging(os.path.join(simulation_dir, "log"))
|
| 714 |
|
| 715 |
+
runner = RedditSimulationRunner(
|
| 716 |
+
config_path=args.config,
|
| 717 |
+
wait_for_commands=not args.no_wait
|
| 718 |
+
)
|
| 719 |
await runner.run(max_rounds=args.max_rounds)
|
| 720 |
|
| 721 |
|
backend/scripts/run_twitter_simulation.py
CHANGED
|
@@ -2,8 +2,15 @@
|
|
| 2 |
OASIS Twitter模拟预设脚本
|
| 3 |
此脚本读取配置文件中的参数来执行模拟,实现全程自动化
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
使用方式:
|
| 6 |
python run_twitter_simulation.py --config /path/to/simulation_config.json
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
import argparse
|
|
@@ -13,8 +20,9 @@ import logging
|
|
| 13 |
import os
|
| 14 |
import random
|
| 15 |
import sys
|
|
|
|
| 16 |
from datetime import datetime
|
| 17 |
-
from typing import Dict, Any, List
|
| 18 |
|
| 19 |
# 添加项目路径
|
| 20 |
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -118,10 +126,261 @@ except ImportError as e:
|
|
| 118 |
sys.exit(1)
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
class TwitterSimulationRunner:
|
| 122 |
"""Twitter模拟运行器"""
|
| 123 |
|
| 124 |
-
# Twitter可用动作
|
| 125 |
AVAILABLE_ACTIONS = [
|
| 126 |
ActionType.CREATE_POST,
|
| 127 |
ActionType.LIKE_POST,
|
|
@@ -131,16 +390,21 @@ class TwitterSimulationRunner:
|
|
| 131 |
ActionType.QUOTE_POST,
|
| 132 |
]
|
| 133 |
|
| 134 |
-
def __init__(self, config_path: str):
|
| 135 |
"""
|
| 136 |
初始化模拟运行器
|
| 137 |
|
| 138 |
Args:
|
| 139 |
config_path: 配置文件路径 (simulation_config.json)
|
|
|
|
| 140 |
"""
|
| 141 |
self.config_path = config_path
|
| 142 |
self.config = self._load_config()
|
| 143 |
self.simulation_dir = os.path.dirname(config_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
def _load_config(self) -> Dict[str, Any]:
|
| 146 |
"""加载配置文件"""
|
|
@@ -269,6 +533,7 @@ class TwitterSimulationRunner:
|
|
| 269 |
print("OASIS Twitter模拟")
|
| 270 |
print(f"配置文件: {self.config_path}")
|
| 271 |
print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
|
|
|
|
| 272 |
print("=" * 60)
|
| 273 |
|
| 274 |
# 加载时间配置
|
|
@@ -305,7 +570,7 @@ class TwitterSimulationRunner:
|
|
| 305 |
print(f"错误: Profile文件不存在: {profile_path}")
|
| 306 |
return
|
| 307 |
|
| 308 |
-
agent_graph = await generate_twitter_agent_graph(
|
| 309 |
profile_path=profile_path,
|
| 310 |
model=model,
|
| 311 |
available_actions=self.AVAILABLE_ACTIONS,
|
|
@@ -319,16 +584,20 @@ class TwitterSimulationRunner:
|
|
| 319 |
|
| 320 |
# 创建环境
|
| 321 |
print("创建OASIS环境...")
|
| 322 |
-
env = oasis.make(
|
| 323 |
-
agent_graph=agent_graph,
|
| 324 |
platform=oasis.DefaultPlatformType.TWITTER,
|
| 325 |
database_path=db_path,
|
| 326 |
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
|
| 327 |
)
|
| 328 |
|
| 329 |
-
await env.reset()
|
| 330 |
print("环境初始化完成\n")
|
| 331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
# 执行初始事件
|
| 333 |
event_config = self.config.get("event_config", {})
|
| 334 |
initial_posts = event_config.get("initial_posts", [])
|
|
@@ -340,7 +609,7 @@ class TwitterSimulationRunner:
|
|
| 340 |
agent_id = post.get("poster_agent_id", 0)
|
| 341 |
content = post.get("content", "")
|
| 342 |
try:
|
| 343 |
-
agent = env.agent_graph.get_agent(agent_id)
|
| 344 |
initial_actions[agent] = ManualAction(
|
| 345 |
action_type=ActionType.CREATE_POST,
|
| 346 |
action_args={"content": content}
|
|
@@ -349,7 +618,7 @@ class TwitterSimulationRunner:
|
|
| 349 |
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
|
| 350 |
|
| 351 |
if initial_actions:
|
| 352 |
-
await env.step(initial_actions)
|
| 353 |
print(f" 已发布 {len(initial_actions)} 条初始帖子")
|
| 354 |
|
| 355 |
# 主模拟循环
|
|
@@ -364,7 +633,7 @@ class TwitterSimulationRunner:
|
|
| 364 |
|
| 365 |
# 获取本轮激活的Agent
|
| 366 |
active_agents = self._get_active_agents_for_round(
|
| 367 |
-
env, simulated_hour, round_num
|
| 368 |
)
|
| 369 |
|
| 370 |
if not active_agents:
|
|
@@ -377,7 +646,7 @@ class TwitterSimulationRunner:
|
|
| 377 |
}
|
| 378 |
|
| 379 |
# 执行动作
|
| 380 |
-
await env.step(actions)
|
| 381 |
|
| 382 |
# 打印进度
|
| 383 |
if (round_num + 1) % 10 == 0 or round_num == 0:
|
|
@@ -388,13 +657,39 @@ class TwitterSimulationRunner:
|
|
| 388 |
f"- {len(active_agents)} agents active "
|
| 389 |
f"- elapsed: {elapsed:.1f}s")
|
| 390 |
|
| 391 |
-
# 关闭环境
|
| 392 |
-
await env.close()
|
| 393 |
-
|
| 394 |
total_elapsed = (datetime.now() - start_time).total_seconds()
|
| 395 |
-
print(f"\n模拟完成!")
|
| 396 |
print(f" - 总耗时: {total_elapsed:.1f}秒")
|
| 397 |
print(f" - 数据库: {db_path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
print("=" * 60)
|
| 399 |
|
| 400 |
|
|
@@ -412,6 +707,12 @@ async def main():
|
|
| 412 |
default=None,
|
| 413 |
help='最大模拟轮数(可选,用于截断过长的模拟)'
|
| 414 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
|
| 416 |
args = parser.parse_args()
|
| 417 |
|
|
@@ -423,10 +724,12 @@ async def main():
|
|
| 423 |
simulation_dir = os.path.dirname(args.config) or "."
|
| 424 |
setup_oasis_logging(os.path.join(simulation_dir, "log"))
|
| 425 |
|
| 426 |
-
runner = TwitterSimulationRunner(
|
|
|
|
|
|
|
|
|
|
| 427 |
await runner.run(max_rounds=args.max_rounds)
|
| 428 |
|
| 429 |
|
| 430 |
if __name__ == "__main__":
|
| 431 |
asyncio.run(main())
|
| 432 |
-
|
|
|
|
| 2 |
OASIS Twitter模拟预设脚本
|
| 3 |
此脚本读取配置文件中的参数来执行模拟,实现全程自动化
|
| 4 |
|
| 5 |
+
功能特性:
|
| 6 |
+
- 完成模拟后不立即关闭环境,进入等待命令模式
|
| 7 |
+
- 支持通过IPC接收Interview命令
|
| 8 |
+
- 支持单个Agent采访和批量采访
|
| 9 |
+
- 支持远程关闭环境命令
|
| 10 |
+
|
| 11 |
使用方式:
|
| 12 |
python run_twitter_simulation.py --config /path/to/simulation_config.json
|
| 13 |
+
python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭
|
| 14 |
"""
|
| 15 |
|
| 16 |
import argparse
|
|
|
|
| 20 |
import os
|
| 21 |
import random
|
| 22 |
import sys
|
| 23 |
+
import sqlite3
|
| 24 |
from datetime import datetime
|
| 25 |
+
from typing import Dict, Any, List, Optional
|
| 26 |
|
| 27 |
# 添加项目路径
|
| 28 |
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
| 126 |
sys.exit(1)
|
| 127 |
|
| 128 |
|
| 129 |
+
# IPC相关常量
|
| 130 |
+
IPC_COMMANDS_DIR = "ipc_commands"
|
| 131 |
+
IPC_RESPONSES_DIR = "ipc_responses"
|
| 132 |
+
ENV_STATUS_FILE = "env_status.json"
|
| 133 |
+
|
| 134 |
+
class CommandType:
|
| 135 |
+
"""命令类型常量"""
|
| 136 |
+
INTERVIEW = "interview"
|
| 137 |
+
BATCH_INTERVIEW = "batch_interview"
|
| 138 |
+
CLOSE_ENV = "close_env"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class IPCHandler:
|
| 142 |
+
"""IPC命令处理器"""
|
| 143 |
+
|
| 144 |
+
def __init__(self, simulation_dir: str, env, agent_graph):
|
| 145 |
+
self.simulation_dir = simulation_dir
|
| 146 |
+
self.env = env
|
| 147 |
+
self.agent_graph = agent_graph
|
| 148 |
+
self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)
|
| 149 |
+
self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)
|
| 150 |
+
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
|
| 151 |
+
self._running = True
|
| 152 |
+
|
| 153 |
+
# 确保目录存在
|
| 154 |
+
os.makedirs(self.commands_dir, exist_ok=True)
|
| 155 |
+
os.makedirs(self.responses_dir, exist_ok=True)
|
| 156 |
+
|
| 157 |
+
def update_status(self, status: str):
|
| 158 |
+
"""更新环境状态"""
|
| 159 |
+
with open(self.status_file, 'w', encoding='utf-8') as f:
|
| 160 |
+
json.dump({
|
| 161 |
+
"status": status,
|
| 162 |
+
"timestamp": datetime.now().isoformat()
|
| 163 |
+
}, f, ensure_ascii=False, indent=2)
|
| 164 |
+
|
| 165 |
+
def poll_command(self) -> Optional[Dict[str, Any]]:
|
| 166 |
+
"""轮询获取待处理命令"""
|
| 167 |
+
if not os.path.exists(self.commands_dir):
|
| 168 |
+
return None
|
| 169 |
+
|
| 170 |
+
# 获取命令文件(按时间排序)
|
| 171 |
+
command_files = []
|
| 172 |
+
for filename in os.listdir(self.commands_dir):
|
| 173 |
+
if filename.endswith('.json'):
|
| 174 |
+
filepath = os.path.join(self.commands_dir, filename)
|
| 175 |
+
command_files.append((filepath, os.path.getmtime(filepath)))
|
| 176 |
+
|
| 177 |
+
command_files.sort(key=lambda x: x[1])
|
| 178 |
+
|
| 179 |
+
for filepath, _ in command_files:
|
| 180 |
+
try:
|
| 181 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 182 |
+
return json.load(f)
|
| 183 |
+
except (json.JSONDecodeError, OSError):
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
|
| 189 |
+
"""发送响应"""
|
| 190 |
+
response = {
|
| 191 |
+
"command_id": command_id,
|
| 192 |
+
"status": status,
|
| 193 |
+
"result": result,
|
| 194 |
+
"error": error,
|
| 195 |
+
"timestamp": datetime.now().isoformat()
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
|
| 199 |
+
with open(response_file, 'w', encoding='utf-8') as f:
|
| 200 |
+
json.dump(response, f, ensure_ascii=False, indent=2)
|
| 201 |
+
|
| 202 |
+
# 删除命令文件
|
| 203 |
+
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
|
| 204 |
+
try:
|
| 205 |
+
os.remove(command_file)
|
| 206 |
+
except OSError:
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
|
| 210 |
+
"""
|
| 211 |
+
处理单个Agent采访命令
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
True 表示成功,False 表示失败
|
| 215 |
+
"""
|
| 216 |
+
try:
|
| 217 |
+
# 获取Agent
|
| 218 |
+
agent = self.agent_graph.get_agent(agent_id)
|
| 219 |
+
|
| 220 |
+
# 创建Interview动作
|
| 221 |
+
interview_action = ManualAction(
|
| 222 |
+
action_type=ActionType.INTERVIEW,
|
| 223 |
+
action_args={"prompt": prompt}
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# 执行Interview
|
| 227 |
+
actions = {agent: interview_action}
|
| 228 |
+
await self.env.step(actions)
|
| 229 |
+
|
| 230 |
+
# 从数据库获取结果
|
| 231 |
+
result = self._get_interview_result(agent_id)
|
| 232 |
+
|
| 233 |
+
self.send_response(command_id, "completed", result=result)
|
| 234 |
+
print(f" Interview完成: agent_id={agent_id}")
|
| 235 |
+
return True
|
| 236 |
+
|
| 237 |
+
except Exception as e:
|
| 238 |
+
error_msg = str(e)
|
| 239 |
+
print(f" Interview失败: agent_id={agent_id}, error={error_msg}")
|
| 240 |
+
self.send_response(command_id, "failed", error=error_msg)
|
| 241 |
+
return False
|
| 242 |
+
|
| 243 |
+
async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
|
| 244 |
+
"""
|
| 245 |
+
处理批量采访命令
|
| 246 |
+
|
| 247 |
+
Args:
|
| 248 |
+
interviews: [{"agent_id": int, "prompt": str}, ...]
|
| 249 |
+
"""
|
| 250 |
+
try:
|
| 251 |
+
# 构建动作字典
|
| 252 |
+
actions = {}
|
| 253 |
+
agent_prompts = {} # 记录每个agent的prompt
|
| 254 |
+
|
| 255 |
+
for interview in interviews:
|
| 256 |
+
agent_id = interview.get("agent_id")
|
| 257 |
+
prompt = interview.get("prompt", "")
|
| 258 |
+
|
| 259 |
+
try:
|
| 260 |
+
agent = self.agent_graph.get_agent(agent_id)
|
| 261 |
+
actions[agent] = ManualAction(
|
| 262 |
+
action_type=ActionType.INTERVIEW,
|
| 263 |
+
action_args={"prompt": prompt}
|
| 264 |
+
)
|
| 265 |
+
agent_prompts[agent_id] = prompt
|
| 266 |
+
except Exception as e:
|
| 267 |
+
print(f" 警告: 无法获取Agent {agent_id}: {e}")
|
| 268 |
+
|
| 269 |
+
if not actions:
|
| 270 |
+
self.send_response(command_id, "failed", error="没有有效的Agent")
|
| 271 |
+
return False
|
| 272 |
+
|
| 273 |
+
# 执行批量Interview
|
| 274 |
+
await self.env.step(actions)
|
| 275 |
+
|
| 276 |
+
# 获取所有结果
|
| 277 |
+
results = {}
|
| 278 |
+
for agent_id in agent_prompts.keys():
|
| 279 |
+
result = self._get_interview_result(agent_id)
|
| 280 |
+
results[agent_id] = result
|
| 281 |
+
|
| 282 |
+
self.send_response(command_id, "completed", result={
|
| 283 |
+
"interviews_count": len(results),
|
| 284 |
+
"results": results
|
| 285 |
+
})
|
| 286 |
+
print(f" 批量Interview完成: {len(results)} 个Agent")
|
| 287 |
+
return True
|
| 288 |
+
|
| 289 |
+
except Exception as e:
|
| 290 |
+
error_msg = str(e)
|
| 291 |
+
print(f" 批量Interview失败: {error_msg}")
|
| 292 |
+
self.send_response(command_id, "failed", error=error_msg)
|
| 293 |
+
return False
|
| 294 |
+
|
| 295 |
+
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
|
| 296 |
+
"""从数据库获取最新的Interview结果"""
|
| 297 |
+
db_path = os.path.join(self.simulation_dir, "twitter_simulation.db")
|
| 298 |
+
|
| 299 |
+
result = {
|
| 300 |
+
"agent_id": agent_id,
|
| 301 |
+
"response": None,
|
| 302 |
+
"timestamp": None
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
if not os.path.exists(db_path):
|
| 306 |
+
return result
|
| 307 |
+
|
| 308 |
+
try:
|
| 309 |
+
conn = sqlite3.connect(db_path)
|
| 310 |
+
cursor = conn.cursor()
|
| 311 |
+
|
| 312 |
+
# 查询最新的Interview记录
|
| 313 |
+
cursor.execute("""
|
| 314 |
+
SELECT user_id, info, created_at
|
| 315 |
+
FROM trace
|
| 316 |
+
WHERE action = ? AND user_id = ?
|
| 317 |
+
ORDER BY created_at DESC
|
| 318 |
+
LIMIT 1
|
| 319 |
+
""", (ActionType.INTERVIEW.value, agent_id))
|
| 320 |
+
|
| 321 |
+
row = cursor.fetchone()
|
| 322 |
+
if row:
|
| 323 |
+
user_id, info_json, created_at = row
|
| 324 |
+
try:
|
| 325 |
+
info = json.loads(info_json) if info_json else {}
|
| 326 |
+
result["response"] = info.get("response", info)
|
| 327 |
+
result["timestamp"] = created_at
|
| 328 |
+
except json.JSONDecodeError:
|
| 329 |
+
result["response"] = info_json
|
| 330 |
+
|
| 331 |
+
conn.close()
|
| 332 |
+
|
| 333 |
+
except Exception as e:
|
| 334 |
+
print(f" 读取Interview结果失败: {e}")
|
| 335 |
+
|
| 336 |
+
return result
|
| 337 |
+
|
| 338 |
+
async def process_commands(self) -> bool:
|
| 339 |
+
"""
|
| 340 |
+
处理所有待处理命令
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
True 表示继续运行,False 表示应该退出
|
| 344 |
+
"""
|
| 345 |
+
command = self.poll_command()
|
| 346 |
+
if not command:
|
| 347 |
+
return True
|
| 348 |
+
|
| 349 |
+
command_id = command.get("command_id")
|
| 350 |
+
command_type = command.get("command_type")
|
| 351 |
+
args = command.get("args", {})
|
| 352 |
+
|
| 353 |
+
print(f"\n收到IPC命令: {command_type}, id={command_id}")
|
| 354 |
+
|
| 355 |
+
if command_type == CommandType.INTERVIEW:
|
| 356 |
+
await self.handle_interview(
|
| 357 |
+
command_id,
|
| 358 |
+
args.get("agent_id", 0),
|
| 359 |
+
args.get("prompt", "")
|
| 360 |
+
)
|
| 361 |
+
return True
|
| 362 |
+
|
| 363 |
+
elif command_type == CommandType.BATCH_INTERVIEW:
|
| 364 |
+
await self.handle_batch_interview(
|
| 365 |
+
command_id,
|
| 366 |
+
args.get("interviews", [])
|
| 367 |
+
)
|
| 368 |
+
return True
|
| 369 |
+
|
| 370 |
+
elif command_type == CommandType.CLOSE_ENV:
|
| 371 |
+
print("收到关闭环境命令")
|
| 372 |
+
self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
|
| 373 |
+
return False
|
| 374 |
+
|
| 375 |
+
else:
|
| 376 |
+
self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
|
| 377 |
+
return True
|
| 378 |
+
|
| 379 |
+
|
| 380 |
class TwitterSimulationRunner:
|
| 381 |
"""Twitter模拟运行器"""
|
| 382 |
|
| 383 |
+
# Twitter可用动作(不包含INTERVIEW,INTERVIEW只能通过ManualAction手动触发)
|
| 384 |
AVAILABLE_ACTIONS = [
|
| 385 |
ActionType.CREATE_POST,
|
| 386 |
ActionType.LIKE_POST,
|
|
|
|
| 390 |
ActionType.QUOTE_POST,
|
| 391 |
]
|
| 392 |
|
| 393 |
+
def __init__(self, config_path: str, wait_for_commands: bool = True):
|
| 394 |
"""
|
| 395 |
初始化模拟运行器
|
| 396 |
|
| 397 |
Args:
|
| 398 |
config_path: 配置文件路径 (simulation_config.json)
|
| 399 |
+
wait_for_commands: 模拟完成后是否等待命令(默认True)
|
| 400 |
"""
|
| 401 |
self.config_path = config_path
|
| 402 |
self.config = self._load_config()
|
| 403 |
self.simulation_dir = os.path.dirname(config_path)
|
| 404 |
+
self.wait_for_commands = wait_for_commands
|
| 405 |
+
self.env = None
|
| 406 |
+
self.agent_graph = None
|
| 407 |
+
self.ipc_handler = None
|
| 408 |
|
| 409 |
def _load_config(self) -> Dict[str, Any]:
|
| 410 |
"""加载配置文件"""
|
|
|
|
| 533 |
print("OASIS Twitter模拟")
|
| 534 |
print(f"配置文件: {self.config_path}")
|
| 535 |
print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
|
| 536 |
+
print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}")
|
| 537 |
print("=" * 60)
|
| 538 |
|
| 539 |
# 加载时间配置
|
|
|
|
| 570 |
print(f"错误: Profile文件不存在: {profile_path}")
|
| 571 |
return
|
| 572 |
|
| 573 |
+
self.agent_graph = await generate_twitter_agent_graph(
|
| 574 |
profile_path=profile_path,
|
| 575 |
model=model,
|
| 576 |
available_actions=self.AVAILABLE_ACTIONS,
|
|
|
|
| 584 |
|
| 585 |
# 创建环境
|
| 586 |
print("创建OASIS环境...")
|
| 587 |
+
self.env = oasis.make(
|
| 588 |
+
agent_graph=self.agent_graph,
|
| 589 |
platform=oasis.DefaultPlatformType.TWITTER,
|
| 590 |
database_path=db_path,
|
| 591 |
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
|
| 592 |
)
|
| 593 |
|
| 594 |
+
await self.env.reset()
|
| 595 |
print("环境初始化完成\n")
|
| 596 |
|
| 597 |
+
# 初始化IPC处理器
|
| 598 |
+
self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
|
| 599 |
+
self.ipc_handler.update_status("running")
|
| 600 |
+
|
| 601 |
# 执行初始事件
|
| 602 |
event_config = self.config.get("event_config", {})
|
| 603 |
initial_posts = event_config.get("initial_posts", [])
|
|
|
|
| 609 |
agent_id = post.get("poster_agent_id", 0)
|
| 610 |
content = post.get("content", "")
|
| 611 |
try:
|
| 612 |
+
agent = self.env.agent_graph.get_agent(agent_id)
|
| 613 |
initial_actions[agent] = ManualAction(
|
| 614 |
action_type=ActionType.CREATE_POST,
|
| 615 |
action_args={"content": content}
|
|
|
|
| 618 |
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
|
| 619 |
|
| 620 |
if initial_actions:
|
| 621 |
+
await self.env.step(initial_actions)
|
| 622 |
print(f" 已发布 {len(initial_actions)} 条初始帖子")
|
| 623 |
|
| 624 |
# 主模拟循环
|
|
|
|
| 633 |
|
| 634 |
# 获取本轮激活的Agent
|
| 635 |
active_agents = self._get_active_agents_for_round(
|
| 636 |
+
self.env, simulated_hour, round_num
|
| 637 |
)
|
| 638 |
|
| 639 |
if not active_agents:
|
|
|
|
| 646 |
}
|
| 647 |
|
| 648 |
# 执行动作
|
| 649 |
+
await self.env.step(actions)
|
| 650 |
|
| 651 |
# 打印进度
|
| 652 |
if (round_num + 1) % 10 == 0 or round_num == 0:
|
|
|
|
| 657 |
f"- {len(active_agents)} agents active "
|
| 658 |
f"- elapsed: {elapsed:.1f}s")
|
| 659 |
|
|
|
|
|
|
|
|
|
|
| 660 |
total_elapsed = (datetime.now() - start_time).total_seconds()
|
| 661 |
+
print(f"\n模拟循环完成!")
|
| 662 |
print(f" - 总耗时: {total_elapsed:.1f}秒")
|
| 663 |
print(f" - 数据库: {db_path}")
|
| 664 |
+
|
| 665 |
+
# 是否进入等待命令模式
|
| 666 |
+
if self.wait_for_commands:
|
| 667 |
+
print("\n" + "=" * 60)
|
| 668 |
+
print("进入等待命令模式 - 环境保持运行")
|
| 669 |
+
print("支持的命令: interview, batch_interview, close_env")
|
| 670 |
+
print("=" * 60)
|
| 671 |
+
|
| 672 |
+
self.ipc_handler.update_status("alive")
|
| 673 |
+
|
| 674 |
+
# 等待命令循环
|
| 675 |
+
try:
|
| 676 |
+
while True:
|
| 677 |
+
should_continue = await self.ipc_handler.process_commands()
|
| 678 |
+
if not should_continue:
|
| 679 |
+
break
|
| 680 |
+
await asyncio.sleep(0.5) # 轮询间隔
|
| 681 |
+
except KeyboardInterrupt:
|
| 682 |
+
print("\n收到中断信号")
|
| 683 |
+
except Exception as e:
|
| 684 |
+
print(f"\n命令处理出错: {e}")
|
| 685 |
+
|
| 686 |
+
print("\n关闭环境...")
|
| 687 |
+
|
| 688 |
+
# 关闭环境
|
| 689 |
+
self.ipc_handler.update_status("stopped")
|
| 690 |
+
await self.env.close()
|
| 691 |
+
|
| 692 |
+
print("环境已关闭")
|
| 693 |
print("=" * 60)
|
| 694 |
|
| 695 |
|
|
|
|
| 707 |
default=None,
|
| 708 |
help='最大模拟轮数(可选,用于截断过长的模拟)'
|
| 709 |
)
|
| 710 |
+
parser.add_argument(
|
| 711 |
+
'--no-wait',
|
| 712 |
+
action='store_true',
|
| 713 |
+
default=False,
|
| 714 |
+
help='模拟完成后立即关闭环境,不进入等待命令模式'
|
| 715 |
+
)
|
| 716 |
|
| 717 |
args = parser.parse_args()
|
| 718 |
|
|
|
|
| 724 |
simulation_dir = os.path.dirname(args.config) or "."
|
| 725 |
setup_oasis_logging(os.path.join(simulation_dir, "log"))
|
| 726 |
|
| 727 |
+
runner = TwitterSimulationRunner(
|
| 728 |
+
config_path=args.config,
|
| 729 |
+
wait_for_commands=not args.no_wait
|
| 730 |
+
)
|
| 731 |
await runner.run(max_rounds=args.max_rounds)
|
| 732 |
|
| 733 |
|
| 734 |
if __name__ == "__main__":
|
| 735 |
asyncio.run(main())
|
|
|